โ ๊ฐ๋ฐํ๊ฒฝ
SpringBoot 3.2.11, Gradle
JDK 17
์ต๊ทผ ๋ฉฐ์น ๊ฐ ๊ฐ์๋ฅผ ๋ค์ผ๋ฉด์ Spring Security์ OAuth2 Client ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ํ์ฉํด ๊ตฌ๊ธ, ํ์ด์ค๋ถ, ๋ค์ด๋ฒ OAuth ํ์๊ฐ์ ๊ณผ ๋ก๊ทธ์ธ์ ๊ตฌํํ๋ ์ฐ์ต์ ํด๋ณด์๋ค.
๋ง์นจ ์์ฆ ์งํ์ค์ธ ํ๋ก์ ํธ๊ฐ ์ด์ ๋ง ๊ตฌํ ๋จ๊ณ์ ์ง์ ํด์, ์ฌ๊ธฐ์ ๊ตฌ๊ธ OAuth ๋ก๊ทธ์ธ๊ณผ ํ์๊ฐ์ ๋ง ์ ์ฉํด๋ณด๋ฉด ์ข์ ๊ฒ ๊ฐ์์ ๋ด๊ฐ ํ ๋ฒ ์ ์ฉํด๋ณด๊ฒ ๋ค๊ณ ํ๋ค.
โ ๊ทธ๋ฌ๋ ์ฐ๋ฆฌ ํ๋ก์ ํธ๋ ๊ฐ์ ์ ํ๋ก์ ํธ์๋ ๊ตฌ์กฐ์ ์ธ ์ฐจ์ด๊ฐ ์์๋ค.
๊ฐ์ ํ๋ก์ ํธ์์๋ ๋ก๊ทธ์ธ๊ณผ ํ์๊ฐ์ ์ ์ฒ๋ฆฌํ๋ ๋ฐฑ์๋ ์๋ฒ๊ฐ ๋ฐ๋ก ์์ง ์๊ณ ,
ํ๋ก ํธ ์๋ฒ ํ๋๋ฅผ ๋๊ณ Spring Security๋ฅผ ์ฌ์ฉํ์ฌ ๋ก๊ทธ์ธ๊ณผ ํ์๊ฐ์ ๊ธฐ๋ฅ์ ๊ตฌํํ์๋ค.
๊ทธ๋ฌ๋ ์ฐ๋ฆฌ ํ๋ก์ ํธ๋ ํ๋ก ํธ ์๋ฒ์ ๋ฐฑ ์๋ฒ๊ฐ ๋ถ๋ฆฌ๋์ด ์์๋ค.
ํ๋ก ํธ ์๋ฒ๋ nginx๋ก ๋์ด ์๊ณ , ์ฌ์ฉ์๊ฐ ํ์๊ฐ์ ์์ฒญ์ ๋ณด๋ด๋ฉด ์ด ์์ฒญ์ ๊ฒ์ดํธ์จ์ด๋ฅผ ๊ฑฐ์ณ์
user-service๋ผ๋ ๋ฐฑ์๋ ์๋ฒ์์ ์ฒ๋ฆฌ๋๋๋ก ์ค๊ณ๋์ด ์์๋ค.
๊ทธ๋๋ OAuth ์ธ์ฆ์ ํตํด ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๋ ์๋ฆฌ๋ ์๊ณ ์์ผ๋ ์ด๋ป๊ฒ๋ ๊ตฌํํ ์ ์๊ฒ ์ง ์ถ์ด์
๋งจ๋ ์ ํค๋ฉํ๋ ๋ฐฉ์์ผ๋ก ๊ตฌํ์ ๋์ ํด๋ณด์๋ค!
๊ตฌํ ๊ณผ์ ์ ๋ฐฐ์ด ์ ๋ ๋ง์ ๊ฒ ๊ฐ์์ ์ผ๊ธฐ์ฒ๋ผ ์จ๋ณด๋ ค๊ณ ํ๋ค.
๊ฐ๋ ์ฑ ์ ์ข์ ์ฃผ์, ๋๋ง ์์๋ณผ ์ ์์ ์ฃผ์...!
โ
์ฐ์ ์ ์์ฒญ๊ณผ ์๋ต์ด ์๋ค๊ฐ๋ค ํ๋ ๊ฒ์ ์ง์ ํ์ธํ๊ณ ์ถ์ด์
SpringSecurity์ OAuth2 Client ์์กด์ฑ์ ์ถ๊ฐํ์ง ์๊ณ ์ฉ์ผ๋ก ๊ตฌํ์ ํด๋ณด์๋ค.
์๋๋ ์์ฑ๋ ์ฝ๋์ด๋ค.
๐ front-service (localhost:8080)
loginForm.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Google OAuth Login</title>
</head>
<body>
<h1>Login with Google</h1>
<a id="google-signup-button" href="#">
<button>Google ๊ฐํธ ํ์๊ฐ์
ํ๊ธฐ</button>
</a>
<script>
fetch('http://localhost:8090/oauth_state', { method: 'POST', credentials: 'include' })
.then(response => response.text())
.then(state => {
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
`client_id=668863934871-v4070e9ugtjvb34us0irg2u1muu46ra2.apps.googleusercontent.com` +
`&redirect_uri=http://localhost:8090/asdf` +
`&response_type=code` +
`&scope=openid%20profile%20email` +
`&state=${state}`;
document.getElementById('google-signup-button').href = googleAuthUrl;
});
</script>
<script>
const urlParams = new URLSearchParams(window.location.search);
const status = urlParams.get('status');
const message = urlParams.get('message');
if (status === 'success') {
alert('ํ์๊ฐ์
์๋ฃ!');
} else if (status === 'error') {
if(message === 'alreadyExists') {
alert('์ด๋ฏธ ๊ฐ์
๋ ํ์์
๋๋ค.');
} else {
alert('ํ์๊ฐ์
์ ์คํจํ์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.');
}
}
</script>
</body>
</html>
์ฐ์ ํ๋ก ํธ์๋์์ "Google ๊ฐํธ ํ์๊ฐ์
ํ๊ธฐ" ๋ฒํผ์ ํด๋ฆญํ๋ค.
์ด ๋ฒํผ์ ๋๋ฅด๋ฉด ๊ตฌ๊ธ OAuth ์๋ฒ์ ์ฌ์ฉ์ ์ธ์ฆ ํ์ด์ง๋ก GET ์์ฒญ์ ๋ณด๋ด๊ฒ ๋๋ค.
์ด ์์ฒญ์ ์ฒ๋ฆฌํ๊ธฐ ์ํด ํ์ํ URL์ ๋์ ์ผ๋ก ์์ฑ๋๋ค.
URL์ด ๋์ ์ผ๋ก ์์ฑ๋๋ ์ด์ ๋ ์์ฒญ ํ๋ผ๋ฏธํฐ ์ค state ๊ฐ์ user-service์์ ๋ฐ์์์ผ ํ๊ธฐ ๋๋ฌธ์ด๋ค.
state ๊ฐ์ CSRF ๊ณต๊ฒฉ์ ๋ฐฉ์งํ๊ธฐ ์ํ ์ฉ๋๋ก ์ฌ์ฉ๋๋ค.
๊ตฌ๊ธ OAuth ์๋ฒ์์ ์ฌ์ฉ์๊ฐ ์ธ์ฆ์ ์๋ฃํ๋ฉด,
์์ฒญ ํ๋ผ๋ฏธํฐ ์ค redirect_uri๋ก ์ง์ ํ ์๋ํฌ์ธํธ์์ ์๋ต์ ๋ฐ์๋ณผ ์ ์๋ค.
์๋ํฌ์ธํธ๋ ํ ์คํธ์ฉ์ด๊ธฐ ๋๋ฌธ์ ์์๋ก asdf๋ผ๊ณ ์ค์ ํ๋ค.
์์ฒญ์ ๋ณด๋ผ ๋ response_type=code๋ก ์ง์ ํ๊ธฐ ๋๋ฌธ์ ์๋ต์ผ๋ก ์ฝ๋๊ฐ ์ฌ ๊ฒ์ด๋ค.
๐ user-service (localhost:8090)
1. state๊ฐ ๋์ ์์ฑํ์ฌ front-service์ ๋ณด๋ด์ฃผ๋ ๋ถ๋ถ
SignupController.java
@PostMapping("/oauth_state")
public ResponseEntity<String> generateState(HttpSession session) {
String state = UUID.randomUUID().toString();
session.setAttribute(KEY_OAUTH_STATE, state);
return ResponseEntity.ok(state);
}
์ฐ์ state๊ฐ์ ๋์ ์ผ๋ก ์์ฑํ๊ธฐ ์ํ ์๋ํฌ์ธํธ๋ฅผ ๊ตฌํํ์๋ค.
front-service์์ ํด๋น ์๋ํฌ์ธํธ๋ก ์์ฒญ์ ๋ณด๋ด๋ฉด,
user-service์์๋ uuid๋ฅผ ๋๋ค์ผ๋ก ์์ฑํ์ฌ ์ธ์ ์ ์ ์ฅํด๋๊ณ , front-service์๊ฒ ์ด uuid๋ฅผ ๋ณด๋ด์ค๋ค.
2. ๊ตฌ๊ธ OAuth ์๋ฒ๋ก๋ถํฐ ์ฝ๋ ์๋ต๋ฐ๋ ๋ถ๋ถ
SignupController.java
@GetMapping("/asdf")
@CrossOrigin(origins = "http://localhost:8080", allowCredentials = "true")
public ResponseEntity<String> handleGoogleOAuthCallback(@RequestParam MultiValueMap<String, String> params,
HttpSession session,
HttpServletResponse httpResponse) {
log.info("Google Oauth ์ธ์ฆ ์๋ฃ, ์๋ต ํ๋ผ๋ฏธํฐ:");
params.forEach((key, value) -> log.info(key + " = " + value));
String originalState = (String) session.getAttribute(KEY_OAUTH_STATE);
log.info("state from front service={}, originalState={}", params.get("state"), originalState);
if (!params.get("state").contains(originalState)) {
throw new IllegalStateException("Invalid state parameter");
}
...
}
๊ทธ๋ฆฌ๊ณ ์ฌ์ฉ์๊ฐ ํ๋ก ํธ ์๋ฒ๋ฅผ ํตํด OAuth ์ธ์ฆ ์๋ฒ๋ก ์ด๋ํ์ฌ ์ธ์ฆ์ ์๋ฃํ๊ณ ๋๋ฉด,
ํด๋น ์๋ํฌ์ธํธ์์ ์๋ต์ ๋ฐ์๋ณผ ์ ์์ ๊ฒ์ด๋ค.
ํ์ฌ user-service์ ์ธ์ ์ ์๋ state๊ฐ์ front-service์ ์์ฒญ ํ๋ผ๋ฏธํฐ์ state๊ฐ๊ณผ ๋์ผํด์ผ ํ๊ธฐ ๋๋ฌธ์ (csrf ๋ฐฉ์ง)
๋ง์ฝ state ๊ฐ์ด ์ผ์นํ์ง ์๋๋ค๋ฉด ์์ธ๋ฅผ ๋ฐ์์์ผ์ฃผ์๋ค.
์ฌ๊ธฐ๊น์ง ์งํ์ด ์๋ฃ๋๋ฉด, ์๋์ ๊ฐ์ ๋ก๊ทธ๊ฐ ์ฐํ๋ค.

3. ๊ตฌ๊ธ OAuth ์๋ฒ์๊ฒ ์ก์ธ์ค ํ ํฐ ์์ฒญํ๊ณ ์๋ต๋ฐ๋ ๋ถ๋ถ
๊ทธ ๋ค์์ ํด๋น ์ฝ๋ ๊ตฌ๊ธ OAuth ์๋ฒ์ ์ ์กํ์ฌ,
์ฌ์ฉ์ ์ ๋ณด์ ์ก์ธ์คํ ์ ์๋ ์ก์ธ์ค ํ ํฐ์ ์๋ต๋ฐ์์ผํ๋ค.
...
private static final String GRANT_TYPE = "authorization_code";
private static final String TOKEN_URL = "https://oauth2.googleapis.com/token";
...
@GetMapping("/asdf")
@CrossOrigin(origins = "http://localhost:8080", allowCredentials = "true")
public ResponseEntity<String> handleGoogleOAuthCallback(@RequestParam MultiValueMap<String, String> params,
HttpSession session,
HttpServletResponse httpResponse) {
...
String code = params.getFirst("code");
String clientId = {ํด๋ผ์ด์ธํธID};
String clientSecret = {ํด๋ผ์ด์ธํธ ๋ณด์ ๋น๋ฐ๋ฒํธ};
String redirectUri = "http://localhost:8090/asdf";
// Google OAuth ์๋ฒ์ Access token ์์ฒญ
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
String body = "code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) +
"&client_id=" + URLEncoder.encode(clientId, StandardCharsets.UTF_8) +
"&client_secret=" + URLEncoder.encode(clientSecret, StandardCharsets.UTF_8) +
"&redirect_uri=" + URLEncoder.encode(redirectUri, StandardCharsets.UTF_8) +
"&grant_type=" + URLEncoder.encode(GRANT_TYPE, StandardCharsets.UTF_8);
HttpEntity<String> entity = new HttpEntity<>(body, headers);
try {
log.info("Google oauth ์๋ฒ์ post ์์ฒญ ์ ์ก : {}", entity);
ResponseEntity<String> response = restTemplate.postForEntity(TOKEN_URL, entity, String.class);
log.info("์๋ต status code: {}", response.getStatusCode());
log.info("์๋ต body: {}", response.getBody());
...
}
RestTemplate์ ์ด์ฉํด์ ๊ตฌ๊ธ OAuth ์๋ฒ์ /token ์๋ํฌ์ธํธ๋ก POST์์ฒญ์ ๋ณด๋๋ค.
์์ฒญ์ ๋ณด๋ผ ๋์๋ ์์ฒญ ๋ฐ๋์ ํ๋ผ๋ฏธํฐ๋ก code, client_id, client_secret, redirect_uri, grant_type์ ๋ฐ๋์ ๋ช ์ํด์ฃผ์ด์ผ ํ๋ค.
์ด๋ code๋ ์์์ ์๋ต๋ฐ์ code๋ฅผ ๊ทธ๋๋ก ๋ฃ์ด์ฃผ๋ฉด ๋๋ค.
redirect_uri๋ ๋ฐ๋์ ๋งจ ์ฒ์ front-service์์ ์ฌ์ฉ์ ์ธ์ฆ์ ์ํ ์์ฒญ์ ๋ณด๋ผ ๋ ์ ์ด์ค uri๋ฅผ ๊ทธ๋๋ก ์ ์ด์ฃผ์ด์ผ ํ๋ค.
(๊ทธ๋ ์ง ์์ผ๋ฉด invalid_grant๋ redirect_uri_mismatch๊ฐ ๋ฐ์ํ๋ค. ์ด๊ฒ ๋๋ฌธ์ ๊ฝค ๋ง์ด ๊ณ ์ํ๋ค..)
๊ทธ๋ฆฌ๊ณ grant_type์ "authorization_code" ๊ณ ์ ์ด๋ค. ๋ฌด์กฐ๊ฑด ์ด๋ ๊ฒ ์ ์ด์ฃผ์ด์ผ ํ๋ค๊ณ ํ๋ค.
์ฌ๊ธฐ๊น์ง ์งํ๋๋ฉด ์๋์ ๊ฐ์ ๋ก๊ทธ๊ฐ ์ฐํ๋ค.

4. ๊ตฌ๊ธ ์ฌ์ฉ์ ์ ๋ณด api์๊ฒ ์ก์ธ์ค ํ ํฐ์ ๋ณด๋ด์ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๋ ๋ถ๋ถ
...
private static final String USER_INFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo";
...
@GetMapping("/asdf")
@CrossOrigin(origins = "http://localhost:8080", allowCredentials = "true")
public ResponseEntity<String> handleGoogleOAuthCallback(@RequestParam MultiValueMap<String, String> params,
HttpSession session,
HttpServletResponse httpResponse) {
...
String accessToken = String.valueOf(objectMapper.readTree(response.getBody()).get("access_token"));
// ์ฌ์ฉ์ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
restTemplate = new RestTemplate();
HttpHeaders userInfoHeaders = new HttpHeaders();
userInfoHeaders.setBearerAuth(accessToken);
HttpEntity<String> userInfoEntity = new HttpEntity<>(userInfoHeaders);
log.info("Google user info api ์ get ์์ฒญ ์ ์ก: {}", userInfoEntity);
ResponseEntity<String> userInfoResponse = restTemplate.exchange(USER_INFO_URL, HttpMethod.GET, userInfoEntity, String.class);
log.info("์๋ต Status Code: {}", userInfoResponse.getStatusCode());
log.info("์๋ต Body: {}", userInfoResponse.getBody());
JsonNode userInfo = objectMapper.readTree(userInfoResponse.getBody());
String sub = userInfo.get("sub").asText();
if (signupService.findUserByUserId("google_" + sub) == null) {
String userId = signupService.signUp(userInfo);
String message = URLEncoder.encode("ํ์๊ฐ์
์ฑ๊ณต!");
String redirectUrl = "http://localhost:8080/?status=success&message=" + message;
if (!httpResponse.isCommitted()) {
httpResponse.sendRedirect(redirectUrl);
}
}
...
์ด๋ ๊ฒ ํ๋ฉด ํ์์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ฌ ์ ์๋ค.
sub๋ ํ์์ pk, id๋ผ๊ณ ์๊ฐํ๋ฉด ๋๋ค๊ณ ํ๋ค.

์๋ต์ด ์ ์์ ์ผ๋ก ์๋ค๋ฉด ์์ ๊ฐ์ ๋ก๊ทธ๊ฐ ์ฐํ๋ค.
5. ํ์๊ฐ์ ๋ก์ง ์ํ ํ front-service์ ์๋ต ๋ณด๋ด๊ธฐ
์ด์ ์ด ํ์์ ์ฐ๋ฆฌ ์๋ฒ์ DB์ ์์๋ก ๊ฐ์ ์ํค๋ฉด ๋๋ค.
๋๋ ์์ด๋=google_{sub}, ๋น๋ฐ๋ฒํธ=asdf_{sub} ๋ก ์ค์ ํ์๊ณ ,
name๊ณผ email ํ๋์๋ ๊ตฌ๊ธ ์ฌ์ฉ์ api๋ก๋ถํฐ ๋ฐ์์จ ์ฌ์ฉ์์ name๊ณผ email ์ ๋ณด๋ฅผ ๋ฃ์ด์ ํ์๊ฐ์ ์ ์์ผฐ๋ค.
๊ตฌ๊ธ OAuth๋ฅผ ํตํด ๊ฐ์ ํ ํ์์ด๋ผ๋ ๊ฒ์ ํ์ํด์ฃผ๊ธฐ ์ํด์ User entity์ provider๋ผ๋ ํ๋๋ ๋์๋ค.
๋ก์ปฌ ํ ์คํธ์ฉ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ด๋ ๊ฒ ํ์์ด ์ ์ฅ๋ ๊ฒ์ ํ์ธํ ์ ์๋ค.

๊ฐ์ ์ด ์๋ฃ๋๋ฉด front-service๋ก redirect์๋ต์ ๋ณด๋ธ๋ค.
์๋ต url ํ๋ผ๋ฏธํฐ์ status ๊ฐ์ ๋ฐ๋ผ ๊ฐ๊ฐ ๋ค๋ฅธ ์๋ฆผ์ฐฝ์ด ๋จ๋ฉด์, index ํ์ด์ง๋ก ๋ฆฌ๋ค์ด์ํธ ๋๋๋ก ์ค์ ํด์ฃผ์๋ค.


์ด์ , OAuth2 Client ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ Spring Security๋ฅผ ๋์ ํด๋ณด๋ ค๊ณ ํ๋ค!
์ฐธ๊ณ ๋ก ๊ตฌํ ์ค๊ฐ์ ํด๋ผ์ด์ธํธ ID๋ฅผ ์๋ชป ์ ์ด์ ํ์๊ฐ์ ๋ 401์ ๋ช์ ๋น ์ก์๋ค.
์ธ์ญ์ด์ค๋น ๊ฐ ํด๋ผ์ด์ธํธID๊ฐ ์๋ชป๋ ๊ฑธ ์ฐพ์์คฌ๋๋ฐ ์ง์ง ๋๋ฌด ๊ณ ๋ง์ ๋ค.
โ References
- https://developers.google.com/identity/protocols/oauth2?hl=ko
- https://developers.google.com/identity/protocols/oauth2/web-server?hl=ko#httprest
โ ์๋ฌ ํด๊ฒฐ์ ๋์์ ์ค ํ์ด์ง๋ค
- https://blog.timekit.io/google-oauth-invalid-grant-nightmare-and-how-to-fix-it-9f4efaf1da35
- https://groups.google.com/g/adwords-api/c/jBeyFbcim60?pli=1
'๊ฐ์ธ ๊ณต๋ถ > WEB-Spring,SpringBoot' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[JPA] ddl-auto ์ต์ (0) | 2024.11.19 |
---|---|
์ฉ๊ตฌํํ๋ ๊ตฌ๊ธ OAuth ์ธ์ฆ ํ๋ก์ ํธ์ Spring Security ์ ์ฉํ๊ธฐ (0) | 2024.11.18 |
์คํ๋ง ์ํ๋ฆฌํฐ & OAuth๋ก ๊ตฌ๊ธ ๊ฐํธ ๋ก๊ทธ์ธ, ํ์๊ฐ์ ์ํค๊ธฐ (2) | 2024.11.12 |
[TIL] SpringSecurity ๊ธฐ๋ณธ ์ค์ ๊ณต๋ถํ๋ฉฐ ์๊ฒ ๋ ์ (1) | 2024.11.10 |
Spring Framework์ ๊ฐ์ฒด์งํฅ (0) | 2024.08.13 |