Skip to content

Commit 6f586f0

Browse files
authored
[Feat] #10 카카오 및 구글 소셜 로그인 구현
- 카카오 및 구글 OAuth 로그인 구현 - Redis 기반 일회용 loginCode 발급 및 교환 API 구현 - 로그인 코드를 URL fragment로 프론트 콜백에 전달 - Access Token 응답 및 Refresh Token HttpOnly 쿠키 발급 - 토큰 재발급·로그아웃과 선택적 CSRF 보호 적용 - OAuth 사용자 정보와 카카오 이메일 인증 여부 검증 - OAuth 외부 요청 timeout 및 인증 테스트 추가 - SecurityConfig 문서와 설정 경로 오타 수정 - 잘못된 base로 머지된 PR #11을 develop 대상으로 다시 반영
2 parents 348bae2 + 7b59d61 commit 6f586f0

26 files changed

Lines changed: 1690 additions & 6 deletions

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,5 @@ application-prod.yml
4545
.env.*
4646
!.env.example
4747

48-
## Cluade ##
49-
.cluade/
48+
## Claude ##
49+
.claude/

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Package root: `com.leets7th.job_is_be`. The project is early-stage — most doma
3333
- `status``ErrorStatus` / `SuccessStatus` enums implement `BaseStatus`. Add new API result codes here rather than constructing ad-hoc messages.
3434
- `exception``GeneralException(BaseStatus)` is the standard exception to throw from application code. `GeneralExceptionAdvice` (`@RestControllerAdvice`) converts it — plus `IllegalArgumentException`, `NullPointerException`, and validation errors — into `ApiResponse` error bodies.
3535
- `response``ApiResponse<T>` is the single response envelope (`isSuccess`, `code`, `message`, `data`), built via `ApiResponse.success(...)`/`ApiResponse.error(...)`. `PageResponse<T>` wraps Spring `Page<T>` for paginated endpoints.
36-
- `config``SecurityConfig` (stateless, CSRF disabled; all requests currently `permitAll()` since auth isn't wired up yet), `WebConfig` (CORS from `app.cors.allowed-origins`), `SwaggerConfig` (OpenAPI at `/swagger-ui.html`, JWT bearer scheme pre-registered), `JpaConfig` (`@EnableJpaAuditing`, required for `BaseEntity` timestamps to populate).
36+
- `config``SecurityConfig` (stateless JWT authentication; OAuth, Swagger, health, and selected auth endpoints are public, while all other requests require an access token; CSRF protection applies to refresh-cookie endpoints such as token reissue and logout), `WebConfig` (CORS from `app.cors.allowed-origins`), `SwaggerConfig` (OpenAPI at `/swagger-ui.html`, JWT bearer scheme pre-registered), `JpaConfig` (`@EnableJpaAuditing`, required for `BaseEntity` timestamps to populate).
3737
- Entity conventions: extend `BaseEntity`, use `@NoArgsConstructor(access = AccessLevel.PROTECTED)` with a Lombok `@Builder` all-args constructor, and expose state transitions as instance methods (e.g. `Job.expire()`, `Job.markRemoved()`) instead of public setters.
3838

3939
### Configuration
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.leets7th.job_is_be.domain.auth.controller;
2+
3+
import com.leets7th.job_is_be.domain.auth.dto.OAuthExchangeRequest;
4+
import com.leets7th.job_is_be.domain.auth.dto.OAuthExchangeResponse;
5+
import com.leets7th.job_is_be.domain.auth.service.OAuthLoginService;
6+
import com.leets7th.job_is_be.domain.auth.service.RefreshTokenCookieManager;
7+
import com.leets7th.job_is_be.domain.auth.oauth.OAuthStateCookieManager;
8+
import com.leets7th.job_is_be.global.base.BaseStatus;
9+
import com.leets7th.job_is_be.global.exception.GeneralException;
10+
import com.leets7th.job_is_be.global.properties.OAuthProperties;
11+
import com.leets7th.job_is_be.global.response.ApiResponse;
12+
import com.leets7th.job_is_be.global.status.ErrorStatus;
13+
import com.leets7th.job_is_be.global.status.SuccessStatus;
14+
import jakarta.servlet.http.HttpServletRequest;
15+
import org.springframework.http.HttpHeaders;
16+
import org.springframework.http.HttpStatus;
17+
import org.springframework.http.MediaType;
18+
import org.springframework.http.ResponseEntity;
19+
import org.springframework.web.bind.annotation.GetMapping;
20+
import org.springframework.web.bind.annotation.PathVariable;
21+
import org.springframework.web.bind.annotation.PostMapping;
22+
import org.springframework.web.bind.annotation.RequestBody;
23+
import org.springframework.web.bind.annotation.RequestMapping;
24+
import org.springframework.web.bind.annotation.RequestParam;
25+
import org.springframework.web.bind.annotation.RestController;
26+
import org.springframework.web.util.UriComponentsBuilder;
27+
28+
import java.net.URI;
29+
30+
@RestController
31+
@RequestMapping("/api/auth/oauth")
32+
public class OAuthController {
33+
34+
private final OAuthLoginService oauthLoginService;
35+
private final RefreshTokenCookieManager cookieManager;
36+
private final OAuthStateCookieManager stateCookieManager;
37+
private final OAuthProperties properties;
38+
39+
public OAuthController(
40+
OAuthLoginService oauthLoginService,
41+
RefreshTokenCookieManager cookieManager,
42+
OAuthStateCookieManager stateCookieManager,
43+
OAuthProperties properties
44+
) {
45+
this.oauthLoginService = oauthLoginService;
46+
this.cookieManager = cookieManager;
47+
this.stateCookieManager = stateCookieManager;
48+
this.properties = properties;
49+
}
50+
51+
@GetMapping("/{provider}")
52+
public ResponseEntity<Void> authorize(@PathVariable String provider) {
53+
OAuthLoginService.AuthorizationRequest authorization =
54+
oauthLoginService.createAuthorizationRequest(provider);
55+
return ResponseEntity.status(HttpStatus.FOUND)
56+
.header(HttpHeaders.LOCATION, authorization.uri().toString())
57+
.header(HttpHeaders.SET_COOKIE, stateCookieManager.create(authorization.state()).toString())
58+
.build();
59+
}
60+
61+
@GetMapping("/{provider}/callback")
62+
public ResponseEntity<Void> callback(
63+
@PathVariable String provider,
64+
@RequestParam(required = false) String code,
65+
@RequestParam(required = false) String state,
66+
@RequestParam(required = false) String error,
67+
HttpServletRequest request
68+
) {
69+
if (error != null && !error.isBlank()) {
70+
return redirectFailure(ErrorStatus.OAUTH_PROVIDER_ERROR);
71+
}
72+
73+
try {
74+
OAuthLoginService.OAuthLoginResult result = oauthLoginService.login(
75+
provider,
76+
code,
77+
state,
78+
stateCookieManager.extract(request)
79+
);
80+
URI location = UriComponentsBuilder.fromUri(properties.frontendSuccessUri())
81+
.fragment("code=" + result.loginCode())
82+
.build()
83+
.encode()
84+
.toUri();
85+
86+
return ResponseEntity.status(HttpStatus.FOUND)
87+
.header(HttpHeaders.LOCATION, location.toString())
88+
.header(HttpHeaders.SET_COOKIE, stateCookieManager.clear().toString())
89+
.build();
90+
} catch (GeneralException e) {
91+
return redirectFailure(e.getErrorStatus());
92+
}
93+
}
94+
95+
@PostMapping(value = "/exchange", consumes = MediaType.APPLICATION_JSON_VALUE)
96+
public ResponseEntity<ApiResponse<OAuthExchangeResponse>> exchange(
97+
@RequestBody OAuthExchangeRequest request
98+
) {
99+
OAuthLoginService.ExchangeResult result = oauthLoginService.exchange(request.loginCode());
100+
101+
return ResponseEntity
102+
.status(SuccessStatus.OAUTH_EXCHANGE_SUCCESS.getHttpStatus())
103+
.header(HttpHeaders.SET_COOKIE, cookieManager.create(result.refreshToken()).toString())
104+
.body(new ApiResponse<>(
105+
true,
106+
SuccessStatus.OAUTH_EXCHANGE_SUCCESS.getCode(),
107+
SuccessStatus.OAUTH_EXCHANGE_SUCCESS.getMessage(),
108+
result.response()
109+
));
110+
}
111+
112+
private ResponseEntity<Void> redirectFailure(BaseStatus errorStatus) {
113+
URI location = UriComponentsBuilder.fromUri(properties.frontendFailureUri())
114+
.queryParam("error", errorStatus.getCode())
115+
.build()
116+
.encode()
117+
.toUri();
118+
return ResponseEntity.status(HttpStatus.FOUND)
119+
.header(HttpHeaders.LOCATION, location.toString())
120+
.header(HttpHeaders.SET_COOKIE, stateCookieManager.clear().toString())
121+
.build();
122+
}
123+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package com.leets7th.job_is_be.domain.auth.dto;
2+
3+
public record OAuthExchangeRequest(String loginCode) {
4+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.leets7th.job_is_be.domain.auth.dto;
2+
3+
public record OAuthExchangeResponse(
4+
String accessToken,
5+
Long userId,
6+
boolean isNewUser,
7+
boolean onboardingCompleted
8+
) {
9+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.leets7th.job_is_be.domain.auth.oauth;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import com.leets7th.job_is_be.domain.user.enums.SocialType;
5+
import com.leets7th.job_is_be.global.exception.GeneralException;
6+
import com.leets7th.job_is_be.global.properties.OAuthProperties;
7+
import com.leets7th.job_is_be.global.status.ErrorStatus;
8+
import org.springframework.beans.factory.annotation.Qualifier;
9+
import org.springframework.http.MediaType;
10+
import org.springframework.stereotype.Component;
11+
import org.springframework.util.LinkedMultiValueMap;
12+
import org.springframework.util.MultiValueMap;
13+
import org.springframework.web.client.RestClient;
14+
import org.springframework.web.client.RestClientException;
15+
import org.springframework.web.util.UriComponentsBuilder;
16+
17+
import java.net.URI;
18+
19+
@Component
20+
public class GoogleOAuthClient implements SocialOAuthClient {
21+
22+
private final OAuthProperties.Provider properties;
23+
private final RestClient restClient;
24+
25+
public GoogleOAuthClient(
26+
OAuthProperties oauthProperties,
27+
@Qualifier("oauthRestClient") RestClient restClient
28+
) {
29+
this.properties = oauthProperties.google();
30+
this.restClient = restClient;
31+
}
32+
33+
@Override
34+
public SocialType supports() {
35+
return SocialType.GOOGLE;
36+
}
37+
38+
@Override
39+
public URI buildAuthorizationUri(String state) {
40+
validateConfigured();
41+
return UriComponentsBuilder.fromUri(properties.authorizationUri())
42+
.queryParam("response_type", "code")
43+
.queryParam("client_id", properties.clientId())
44+
.queryParam("redirect_uri", properties.redirectUri())
45+
.queryParam("scope", String.join(" ", properties.scopes()))
46+
.queryParam("state", state)
47+
.build()
48+
.encode()
49+
.toUri();
50+
}
51+
52+
@Override
53+
public OAuthUserInfo getUserInfo(String authorizationCode) {
54+
validateConfigured();
55+
try {
56+
GoogleTokenResponse token = requestToken(authorizationCode);
57+
GoogleUserResponse user = restClient.get()
58+
.uri(properties.userInfoUri())
59+
.headers(headers -> headers.setBearerAuth(token.accessToken()))
60+
.retrieve()
61+
.body(GoogleUserResponse.class);
62+
63+
if (user == null || user.sub() == null || user.sub().isBlank()) {
64+
throw new GeneralException(ErrorStatus.OAUTH_PROVIDER_ERROR);
65+
}
66+
if (user.email() == null || user.email().isBlank() || !Boolean.TRUE.equals(user.emailVerified())) {
67+
throw new GeneralException(ErrorStatus.OAUTH_EMAIL_REQUIRED);
68+
}
69+
return new OAuthUserInfo(user.sub(), SocialType.GOOGLE, user.email());
70+
} catch (GeneralException e) {
71+
throw e;
72+
} catch (RestClientException e) {
73+
throw new GeneralException(ErrorStatus.OAUTH_PROVIDER_ERROR);
74+
}
75+
}
76+
77+
private GoogleTokenResponse requestToken(String authorizationCode) {
78+
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
79+
form.add("grant_type", "authorization_code");
80+
form.add("client_id", properties.clientId());
81+
form.add("client_secret", properties.clientSecret());
82+
form.add("redirect_uri", properties.redirectUri().toString());
83+
form.add("code", authorizationCode);
84+
85+
GoogleTokenResponse token = restClient.post()
86+
.uri(properties.tokenUri())
87+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
88+
.body(form)
89+
.retrieve()
90+
.body(GoogleTokenResponse.class);
91+
if (token == null || token.accessToken() == null || token.accessToken().isBlank()) {
92+
throw new GeneralException(ErrorStatus.OAUTH_PROVIDER_ERROR);
93+
}
94+
return token;
95+
}
96+
97+
private void validateConfigured() {
98+
if (!properties.isConfigured()) {
99+
throw new GeneralException(ErrorStatus.OAUTH_NOT_CONFIGURED);
100+
}
101+
}
102+
103+
private record GoogleTokenResponse(
104+
@JsonProperty("access_token") String accessToken
105+
) {
106+
}
107+
108+
private record GoogleUserResponse(
109+
String sub,
110+
String email,
111+
@JsonProperty("email_verified") Boolean emailVerified
112+
) {
113+
}
114+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package com.leets7th.job_is_be.domain.auth.oauth;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import com.leets7th.job_is_be.domain.user.enums.SocialType;
5+
import com.leets7th.job_is_be.global.exception.GeneralException;
6+
import com.leets7th.job_is_be.global.properties.OAuthProperties;
7+
import com.leets7th.job_is_be.global.status.ErrorStatus;
8+
import org.springframework.beans.factory.annotation.Qualifier;
9+
import org.springframework.http.MediaType;
10+
import org.springframework.stereotype.Component;
11+
import org.springframework.util.LinkedMultiValueMap;
12+
import org.springframework.util.MultiValueMap;
13+
import org.springframework.web.client.RestClient;
14+
import org.springframework.web.client.RestClientException;
15+
import org.springframework.web.util.UriComponentsBuilder;
16+
17+
import java.net.URI;
18+
19+
@Component
20+
public class KakaoOAuthClient implements SocialOAuthClient {
21+
22+
private final OAuthProperties.Provider properties;
23+
private final RestClient restClient;
24+
25+
public KakaoOAuthClient(
26+
OAuthProperties oauthProperties,
27+
@Qualifier("oauthRestClient") RestClient restClient
28+
) {
29+
this.properties = oauthProperties.kakao();
30+
this.restClient = restClient;
31+
}
32+
33+
@Override
34+
public SocialType supports() {
35+
return SocialType.KAKAO;
36+
}
37+
38+
@Override
39+
public URI buildAuthorizationUri(String state) {
40+
validateConfigured();
41+
return UriComponentsBuilder.fromUri(properties.authorizationUri())
42+
.queryParam("response_type", "code")
43+
.queryParam("client_id", properties.clientId())
44+
.queryParam("redirect_uri", properties.redirectUri())
45+
.queryParam("scope", String.join(",", properties.scopes()))
46+
.queryParam("state", state)
47+
.build()
48+
.encode()
49+
.toUri();
50+
}
51+
52+
@Override
53+
public OAuthUserInfo getUserInfo(String authorizationCode) {
54+
validateConfigured();
55+
try {
56+
KakaoTokenResponse token = requestToken(authorizationCode);
57+
KakaoUserResponse user = restClient.get()
58+
.uri(properties.userInfoUri())
59+
.headers(headers -> headers.setBearerAuth(token.accessToken()))
60+
.retrieve()
61+
.body(KakaoUserResponse.class);
62+
63+
if (user == null || user.id() == null) {
64+
throw new GeneralException(ErrorStatus.OAUTH_PROVIDER_ERROR);
65+
}
66+
KakaoAccount account = user.kakaoAccount();
67+
String email = account == null ? null : account.email();
68+
validateEmail(email, account == null ? null : account.emailVerified());
69+
return new OAuthUserInfo(user.id().toString(), SocialType.KAKAO, email);
70+
} catch (GeneralException e) {
71+
throw e;
72+
} catch (RestClientException e) {
73+
throw new GeneralException(ErrorStatus.OAUTH_PROVIDER_ERROR);
74+
}
75+
}
76+
77+
private KakaoTokenResponse requestToken(String authorizationCode) {
78+
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
79+
form.add("grant_type", "authorization_code");
80+
form.add("client_id", properties.clientId());
81+
form.add("client_secret", properties.clientSecret());
82+
form.add("redirect_uri", properties.redirectUri().toString());
83+
form.add("code", authorizationCode);
84+
85+
KakaoTokenResponse token = restClient.post()
86+
.uri(properties.tokenUri())
87+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
88+
.body(form)
89+
.retrieve()
90+
.body(KakaoTokenResponse.class);
91+
if (token == null || token.accessToken() == null || token.accessToken().isBlank()) {
92+
throw new GeneralException(ErrorStatus.OAUTH_PROVIDER_ERROR);
93+
}
94+
return token;
95+
}
96+
97+
private void validateConfigured() {
98+
if (!properties.isConfigured()) {
99+
throw new GeneralException(ErrorStatus.OAUTH_NOT_CONFIGURED);
100+
}
101+
}
102+
103+
private void validateEmail(String email, Boolean emailVerified) {
104+
if (email == null || email.isBlank() || !Boolean.TRUE.equals(emailVerified)) {
105+
throw new GeneralException(ErrorStatus.OAUTH_EMAIL_REQUIRED);
106+
}
107+
}
108+
109+
private record KakaoTokenResponse(
110+
@JsonProperty("access_token") String accessToken
111+
) {
112+
}
113+
114+
private record KakaoUserResponse(
115+
Long id,
116+
@JsonProperty("kakao_account") KakaoAccount kakaoAccount
117+
) {
118+
}
119+
120+
private record KakaoAccount(
121+
String email,
122+
@JsonProperty("is_email_verified") Boolean emailVerified
123+
) {
124+
}
125+
}

0 commit comments

Comments
 (0)