Skip to content

Commit 024f680

Browse files
authored
Merge pull request #242 from JECT-Study/feature/inapp-browser-oauth-redirect
feat: 인앱 브라우저 구글 로그인 외부 브라우저 우회
2 parents 329bd1b + c7136be commit 024f680

4 files changed

Lines changed: 262 additions & 1 deletion

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.ject.vs.config;
2+
3+
import java.util.regex.Pattern;
4+
5+
/**
6+
* User-Agent로 인앱 브라우저(WebView) 여부와 모바일 OS를 판별한다.
7+
*
8+
* <p>구글은 보안 정책상 WebView(인앱 브라우저)에서의 OAuth 로그인을 차단(disallowed_useragent, 403)하므로,
9+
* 인앱 브라우저로 진입한 로그인 요청은 외부 브라우저로 우회시켜야 한다.
10+
* 자세한 우회 처리는 {@link InAppBrowserRedirectFilter} 참고.
11+
*/
12+
public final class InAppBrowserDetector {
13+
14+
private InAppBrowserDetector() {
15+
}
16+
17+
/**
18+
* 대표적인 인앱 브라우저 UA 시그니처.
19+
* <ul>
20+
* <li>{@code ; wv)} : 안드로이드 WebView 공통 마커(거의 모든 안드로이드 인앱 브라우저가 포함)</li>
21+
* <li>iOS는 WebView 공통 마커가 없어 앱별 시그니처로 판별(Instagram/Threads/카카오톡/라인/네이버 등)</li>
22+
* </ul>
23+
*/
24+
private static final Pattern IN_APP_PATTERN = Pattern.compile(
25+
"; wv\\)" // Android WebView 공통
26+
+ "|FBAN|FBAV|FB_IAB" // Facebook/Messenger
27+
+ "|Instagram|Barcelona|Threads" // Instagram, Threads(=Barcelona)
28+
+ "|KAKAOTALK" // 카카오톡
29+
+ "|Line/|NAVER|DaumApps|BAND" // 라인, 네이버, 다음, 밴드
30+
+ "|everytimeApp|Snapchat|Twitter|Pinterest",
31+
Pattern.CASE_INSENSITIVE);
32+
33+
private static final Pattern ANDROID_PATTERN = Pattern.compile("Android", Pattern.CASE_INSENSITIVE);
34+
private static final Pattern IOS_PATTERN = Pattern.compile("iPhone|iPad|iPod", Pattern.CASE_INSENSITIVE);
35+
36+
public static boolean isInAppBrowser(String userAgent) {
37+
if (userAgent == null || userAgent.isBlank()) {
38+
return false;
39+
}
40+
return IN_APP_PATTERN.matcher(userAgent).find();
41+
}
42+
43+
public static boolean isAndroid(String userAgent) {
44+
return userAgent != null && ANDROID_PATTERN.matcher(userAgent).find();
45+
}
46+
47+
public static boolean isIos(String userAgent) {
48+
return userAgent != null && IOS_PATTERN.matcher(userAgent).find();
49+
}
50+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
141+
}
142+
143+
private String escapeHtmlAttr(String value) {
144+
return escapeHtml(value).replace("\"", "&quot;");
145+
}
146+
147+
private String escapeJs(String value) {
148+
return value.replace("\\", "\\\\").replace("\"", "\\\"");
149+
}
150+
}

src/main/java/com/ject/vs/config/SecurityConfig.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.springframework.context.annotation.Configuration;
77
import org.springframework.http.HttpMethod;
88
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
9+
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter;
910
import org.springframework.security.web.SecurityFilterChain;
1011
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
1112
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@@ -50,7 +51,9 @@ public SecurityFilterChain securityFilterChain(
5051
response.getWriter().write("{\"code\":\"AUTH_001\",\"message\":\"인증이 필요합니다.\"}");
5152
})
5253
)
53-
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
54+
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
55+
// 인앱 브라우저(WebView) 로그인 진입은 구글로 리다이렉트되기 전에 외부 브라우저로 우회시킨다.
56+
.addFilterBefore(new InAppBrowserRedirectFilter(), OAuth2AuthorizationRequestRedirectFilter.class);
5457

5558
return http.build();
5659
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.ject.vs.config;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import static org.assertj.core.api.Assertions.assertThat;
6+
7+
/**
8+
* 인앱 브라우저(WebView) UA 판별 규칙 검증.
9+
*/
10+
class InAppBrowserDetectorTest {
11+
12+
// 실제 인앱 브라우저 UA 샘플
13+
private static final String THREADS_IOS =
14+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 "
15+
+ "(KHTML, like Gecko) Mobile/15E148 Instagram 320.0.0.0 (iPhone; iOS 17_0; en_US; Barcelona)";
16+
private static final String INSTAGRAM_ANDROID =
17+
"Mozilla/5.0 (Linux; Android 13; SM-S908N; wv) AppleWebKit/537.36 (KHTML, like Gecko) "
18+
+ "Version/4.0 Chrome/120.0.0.0 Mobile Safari/537.36 Instagram 320.0.0.0 Android";
19+
private static final String KAKAOTALK_ANDROID =
20+
"Mozilla/5.0 (Linux; Android 12; wv) AppleWebKit/537.36 (KHTML, like Gecko) "
21+
+ "Version/4.0 Chrome/120.0.0.0 Mobile Safari/537.36 KAKAOTALK 10.0.0";
22+
23+
// 정상 브라우저 UA 샘플
24+
private static final String CHROME_ANDROID =
25+
"Mozilla/5.0 (Linux; Android 13; SM-S908N) AppleWebKit/537.36 (KHTML, like Gecko) "
26+
+ "Chrome/120.0.0.0 Mobile Safari/537.36";
27+
private static final String SAFARI_IOS =
28+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 "
29+
+ "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
30+
31+
@Test
32+
void 인앱_브라우저_UA는_감지된다() {
33+
assertThat(InAppBrowserDetector.isInAppBrowser(THREADS_IOS)).isTrue();
34+
assertThat(InAppBrowserDetector.isInAppBrowser(INSTAGRAM_ANDROID)).isTrue();
35+
assertThat(InAppBrowserDetector.isInAppBrowser(KAKAOTALK_ANDROID)).isTrue();
36+
}
37+
38+
@Test
39+
void 일반_크롬과_사파리는_인앱으로_보지_않는다() {
40+
assertThat(InAppBrowserDetector.isInAppBrowser(CHROME_ANDROID)).isFalse();
41+
assertThat(InAppBrowserDetector.isInAppBrowser(SAFARI_IOS)).isFalse();
42+
}
43+
44+
@Test
45+
void UA가_없으면_인앱이_아니다() {
46+
assertThat(InAppBrowserDetector.isInAppBrowser(null)).isFalse();
47+
assertThat(InAppBrowserDetector.isInAppBrowser("")).isFalse();
48+
}
49+
50+
@Test
51+
void 안드로이드와_iOS를_구분한다() {
52+
assertThat(InAppBrowserDetector.isAndroid(INSTAGRAM_ANDROID)).isTrue();
53+
assertThat(InAppBrowserDetector.isIos(INSTAGRAM_ANDROID)).isFalse();
54+
55+
assertThat(InAppBrowserDetector.isIos(THREADS_IOS)).isTrue();
56+
assertThat(InAppBrowserDetector.isAndroid(THREADS_IOS)).isFalse();
57+
}
58+
}

0 commit comments

Comments
 (0)