|
| 1 | +package com.ject.vs.config; |
| 2 | + |
| 3 | +import jakarta.servlet.FilterChain; |
| 4 | +import jakarta.servlet.ServletException; |
| 5 | +import jakarta.servlet.http.HttpServletRequest; |
| 6 | +import jakarta.servlet.http.HttpServletResponse; |
| 7 | +import lombok.extern.slf4j.Slf4j; |
| 8 | +import org.springframework.http.HttpHeaders; |
| 9 | +import org.springframework.web.filter.OncePerRequestFilter; |
| 10 | + |
| 11 | +import java.io.IOException; |
| 12 | + |
| 13 | +/** |
| 14 | + * 인앱 브라우저(WebView)에서 들어온 소셜 로그인 진입 요청({@code /oauth2/authorization/**})을 |
| 15 | + * 외부 브라우저로 우회시킨다. |
| 16 | + * |
| 17 | + * <p>구글은 WebView에서의 OAuth 로그인을 차단(disallowed_useragent, 403)하므로, |
| 18 | + * 인앱 브라우저로 로그인 진입 시 구글로 리다이렉트하지 않고: |
| 19 | + * <ul> |
| 20 | + * <li>안드로이드: {@code intent://} 스킴으로 크롬을 강제로 띄운다.</li> |
| 21 | + * <li>iOS: WebView에서 사파리를 강제 실행할 방법이 없어, "외부 브라우저로 열기" 안내 페이지를 보여준다.</li> |
| 22 | + * </ul> |
| 23 | + * 외부 브라우저는 원래의 로그인 진입 URL을 그대로 다시 열어 OAuth 흐름을 새 세션에서 재시작한다. |
| 24 | + * |
| 25 | + * <p>Spring Security의 {@code OAuth2AuthorizationRequestRedirectFilter} 앞에 등록되어, |
| 26 | + * 구글로 리다이렉트가 일어나기 전에 동작한다. |
| 27 | + */ |
| 28 | +@Slf4j |
| 29 | +public class InAppBrowserRedirectFilter extends OncePerRequestFilter { |
| 30 | + |
| 31 | + private static final String OAUTH_AUTHORIZATION_PREFIX = "/oauth2/authorization/"; |
| 32 | + private static final String CHROME_PACKAGE = "com.android.chrome"; |
| 33 | + |
| 34 | + @Override |
| 35 | + protected void doFilterInternal(HttpServletRequest request, |
| 36 | + HttpServletResponse response, |
| 37 | + FilterChain filterChain) throws ServletException, IOException { |
| 38 | + String uri = request.getRequestURI(); |
| 39 | + String userAgent = request.getHeader(HttpHeaders.USER_AGENT); |
| 40 | + |
| 41 | + if (!uri.startsWith(OAUTH_AUTHORIZATION_PREFIX) || !InAppBrowserDetector.isInAppBrowser(userAgent)) { |
| 42 | + filterChain.doFilter(request, response); |
| 43 | + return; |
| 44 | + } |
| 45 | + |
| 46 | + String targetUrl = buildExternalUrl(request); |
| 47 | + log.info("인앱 브라우저 로그인 우회: uri={}, android={}, ios={}", |
| 48 | + uri, InAppBrowserDetector.isAndroid(userAgent), InAppBrowserDetector.isIos(userAgent)); |
| 49 | + |
| 50 | + response.setStatus(HttpServletResponse.SC_OK); |
| 51 | + response.setContentType("text/html;charset=UTF-8"); |
| 52 | + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); |
| 53 | + |
| 54 | + String html = InAppBrowserDetector.isAndroid(userAgent) |
| 55 | + ? androidIntentPage(targetUrl) |
| 56 | + : iosGuidePage(targetUrl); |
| 57 | + response.getWriter().write(html); |
| 58 | + } |
| 59 | + |
| 60 | + /** 포워딩 헤더(FRAMEWORK 전략)를 반영한 외부 접근 URL(scheme/host 포함)을 복원한다. */ |
| 61 | + private String buildExternalUrl(HttpServletRequest request) { |
| 62 | + StringBuilder url = new StringBuilder(request.getRequestURL()); |
| 63 | + String queryString = request.getQueryString(); |
| 64 | + if (queryString != null && !queryString.isBlank()) { |
| 65 | + url.append('?').append(queryString); |
| 66 | + } |
| 67 | + return url.toString(); |
| 68 | + } |
| 69 | + |
| 70 | + /** 안드로이드: intent:// 스킴으로 크롬을 강제 실행한다. 크롬 미설치 시 마켓 폴백. */ |
| 71 | + private String androidIntentPage(String targetUrl) { |
| 72 | + String hostPathQuery = targetUrl.replaceFirst("^https?://", ""); |
| 73 | + String intentUrl = "intent://" + hostPathQuery |
| 74 | + + "#Intent;scheme=https;package=" + CHROME_PACKAGE + ";" |
| 75 | + + "S.browser_fallback_url=" + targetUrl + ";end"; |
| 76 | + String safeIntentUrl = escapeJs(intentUrl); |
| 77 | + |
| 78 | + return """ |
| 79 | + <!doctype html> |
| 80 | + <html lang="ko"> |
| 81 | + <head> |
| 82 | + <meta charset="utf-8"> |
| 83 | + <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 84 | + <title>외부 브라우저로 이동</title> |
| 85 | + </head> |
| 86 | + <body style="font-family:-apple-system,'Apple SD Gothic Neo',sans-serif;text-align:center;padding:40px 24px;color:#222;"> |
| 87 | + <p style="font-size:16px;line-height:1.6;">크롬으로 이동 중입니다…<br>자동으로 열리지 않으면 아래 버튼을 눌러주세요.</p> |
| 88 | + <a href="%s" style="display:inline-block;margin-top:16px;padding:14px 24px;background:#1a73e8;color:#fff;border-radius:10px;text-decoration:none;font-size:16px;">크롬으로 열기</a> |
| 89 | + <script>location.href = "%s";</script> |
| 90 | + </body> |
| 91 | + </html> |
| 92 | + """.formatted(escapeHtmlAttr(intentUrl), safeIntentUrl); |
| 93 | + } |
| 94 | + |
| 95 | + /** iOS: 사파리를 강제 실행할 수 없으므로 안내 페이지 + 주소 복사 버튼을 제공한다. */ |
| 96 | + private String iosGuidePage(String targetUrl) { |
| 97 | + return """ |
| 98 | + <!doctype html> |
| 99 | + <html lang="ko"> |
| 100 | + <head> |
| 101 | + <meta charset="utf-8"> |
| 102 | + <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 103 | + <title>외부 브라우저에서 로그인</title> |
| 104 | + </head> |
| 105 | + <body style="font-family:-apple-system,'Apple SD Gothic Neo',sans-serif;padding:40px 24px;color:#222;max-width:480px;margin:0 auto;"> |
| 106 | + <h2 style="font-size:20px;">외부 브라우저에서 로그인해 주세요</h2> |
| 107 | + <p style="font-size:15px;line-height:1.7;color:#444;"> |
| 108 | + 보안 정책상 인앱 브라우저에서는 구글 로그인이 제한됩니다.<br> |
| 109 | + 아래 순서로 <b>Safari</b>에서 열어 로그인해 주세요. |
| 110 | + </p> |
| 111 | + <ol style="font-size:15px;line-height:1.9;color:#444;padding-left:20px;"> |
| 112 | + <li>화면 우측 상단 또는 하단의 <b>···</b> · 공유 버튼을 누르세요.</li> |
| 113 | + <li><b>Safari로 열기</b>(기본 브라우저로 열기)를 선택하세요.</li> |
| 114 | + </ol> |
| 115 | + <div style="margin-top:20px;padding:14px;background:#f5f5f7;border-radius:10px;font-size:13px;word-break:break-all;color:#555;">%s</div> |
| 116 | + <button onclick="copyUrl()" style="display:block;width:100%%;margin-top:16px;padding:14px;background:#1a73e8;color:#fff;border:0;border-radius:10px;font-size:16px;">주소 복사하기</button> |
| 117 | + <script> |
| 118 | + var url = "%s"; |
| 119 | + function copyUrl() { |
| 120 | + if (navigator.clipboard) { |
| 121 | + navigator.clipboard.writeText(url).then(showCopied, fallbackCopy); |
| 122 | + } else { |
| 123 | + fallbackCopy(); |
| 124 | + } |
| 125 | + } |
| 126 | + function fallbackCopy() { |
| 127 | + var ta = document.createElement('textarea'); |
| 128 | + ta.value = url; document.body.appendChild(ta); ta.select(); |
| 129 | + try { document.execCommand('copy'); showCopied(); } catch (e) {} |
| 130 | + document.body.removeChild(ta); |
| 131 | + } |
| 132 | + function showCopied() { alert('주소가 복사되었습니다. Safari 주소창에 붙여넣어 주세요.'); } |
| 133 | + </script> |
| 134 | + </body> |
| 135 | + </html> |
| 136 | + """.formatted(escapeHtml(targetUrl), escapeJs(targetUrl)); |
| 137 | + } |
| 138 | + |
| 139 | + private String escapeHtml(String value) { |
| 140 | + return value.replace("&", "&").replace("<", "<").replace(">", ">"); |
| 141 | + } |
| 142 | + |
| 143 | + private String escapeHtmlAttr(String value) { |
| 144 | + return escapeHtml(value).replace("\"", """); |
| 145 | + } |
| 146 | + |
| 147 | + private String escapeJs(String value) { |
| 148 | + return value.replace("\\", "\\\\").replace("\"", "\\\""); |
| 149 | + } |
| 150 | +} |
0 commit comments