Skip to content

Commit 07329d8

Browse files
authored
Merge pull request #19 from FlipNoteTeam/feat/social-login
Feat: [FN-80] 소셜 로그인
2 parents 1a23f23 + 31a39d6 commit 07329d8

8 files changed

Lines changed: 180 additions & 38 deletions

File tree

src/main/java/project/flipnote/auth/controller/OAuthController.java

Lines changed: 101 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,31 @@
44

55
import org.springframework.http.HttpHeaders;
66
import org.springframework.http.HttpStatus;
7+
import org.springframework.http.ResponseCookie;
78
import org.springframework.http.ResponseEntity;
89
import org.springframework.security.core.annotation.AuthenticationPrincipal;
10+
import org.springframework.util.StringUtils;
911
import org.springframework.web.bind.annotation.CookieValue;
1012
import org.springframework.web.bind.annotation.GetMapping;
1113
import org.springframework.web.bind.annotation.PathVariable;
1214
import org.springframework.web.bind.annotation.RequestParam;
1315
import org.springframework.web.bind.annotation.RestController;
16+
import org.springframework.web.util.UriComponentsBuilder;
1417

1518
import jakarta.servlet.http.HttpServletRequest;
1619
import lombok.RequiredArgsConstructor;
1720
import lombok.extern.slf4j.Slf4j;
1821
import project.flipnote.auth.constants.OAuthConstants;
1922
import project.flipnote.auth.exception.AuthErrorCode;
2023
import project.flipnote.auth.model.AuthorizationRedirect;
24+
import project.flipnote.auth.model.TokenPair;
2125
import project.flipnote.auth.service.OAuthService;
2226
import project.flipnote.common.config.ClientProperties;
2327
import project.flipnote.common.exception.BizException;
2428
import project.flipnote.common.security.dto.UserAuth;
29+
import project.flipnote.common.security.jwt.JwtConstants;
30+
import project.flipnote.common.security.jwt.JwtProperties;
31+
import project.flipnote.common.util.CookieUtil;
2532

2633
@Slf4j
2734
@RequiredArgsConstructor
@@ -30,14 +37,16 @@ public class OAuthController {
3037

3138
private final OAuthService oAuthService;
3239
private final ClientProperties clientProperties;
40+
private final JwtProperties jwtProperties;
41+
private final CookieUtil cookieUtil;
3342

3443
@GetMapping("/oauth2/authorization/{provider}")
3544
public ResponseEntity<Void> redirectToProviderAuthorization(
3645
@PathVariable("provider") String provider,
3746
HttpServletRequest request,
3847
@AuthenticationPrincipal UserAuth userAuth
3948
) {
40-
AuthorizationRedirect authRedirect = oAuthService.getAuthorizationUri(provider, request, userAuth.userId());
49+
AuthorizationRedirect authRedirect = oAuthService.getAuthorizationUri(provider, request, userAuth);
4150

4251
return ResponseEntity.status(HttpStatus.FOUND)
4352
.header(HttpHeaders.SET_COOKIE, authRedirect.cookie().toString())
@@ -49,31 +58,103 @@ public ResponseEntity<Void> redirectToProviderAuthorization(
4958
public ResponseEntity<Void> handleCallback(
5059
@PathVariable("provider") String provider,
5160
@RequestParam("code") String code,
52-
@RequestParam("state") String state,
61+
@RequestParam(name = "state", required = false) String state,
5362
@CookieValue(OAuthConstants.VERIFIER_COOKIE_NAME) String codeVerifier,
5463
HttpServletRequest request
5564
) {
56-
String redirectUri = clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LINK_SUCCESS);
65+
boolean isSocialLinkRequest = StringUtils.hasText(state);
66+
if (isSocialLinkRequest) {
67+
return handleSocialLink(provider, code, state, codeVerifier, request);
68+
}
69+
return handleSocialLogin(provider, code, codeVerifier, request);
70+
}
71+
72+
private ResponseEntity<Void> handleSocialLink(
73+
String provider,
74+
String code,
75+
String state,
76+
String codeVerifier,
77+
HttpServletRequest request
78+
) {
79+
URI location;
5780
try {
5881
oAuthService.linkSocialAccount(provider, code, state, codeVerifier, request);
59-
} catch (BizException exception) {
60-
if (exception.getErrorCode() == AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) {
61-
redirectUri = clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LINK_CONFLICT);
62-
} else {
63-
redirectUri = clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LINK_FAILURE);
64-
}
65-
log.warn("BizException handled: code={}, status={}, message={}",
66-
exception.getErrorCode().getCode(),
67-
exception.getErrorCode().getStatus(),
68-
exception.getErrorCode().getMessage()
69-
);
70-
} catch (Exception exception) {
71-
redirectUri = clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LINK_FAILURE);
72-
log.error("소셜 계정 연동 콜백 처리 중 예상치 못한 오류 발생. provider: {}, state: {}", provider, state, exception);
82+
location = buildRedirectUri(ClientProperties.PathKey.SOCIAL_LINK_SUCCESS);
83+
} catch (BizException ex) {
84+
location = resolveRedirectUrlForSocialLink(ex);
85+
logBizException(ex);
86+
} catch (Exception ex) {
87+
location = buildRedirectUri(ClientProperties.PathKey.SOCIAL_LINK_FAILURE);
88+
log.error("소셜 계정 연동 콜백 처리 중 예상치 못한 오류 발생. provider: {}, state: {}", provider, state, ex);
7389
}
7490

75-
return ResponseEntity.status(HttpStatus.FOUND)
76-
.location(URI.create(redirectUri))
77-
.build();
91+
return buildRedirectResponse(location, null);
92+
}
93+
94+
private ResponseEntity<Void> handleSocialLogin(
95+
String provider,
96+
String code,
97+
String codeVerifier,
98+
HttpServletRequest request
99+
) {
100+
URI location;
101+
ResponseCookie refreshTokenCookie = null;
102+
try {
103+
TokenPair tokenPair = oAuthService.socialLogin(provider, code, codeVerifier, request);
104+
location = buildLoginSuccessRedirectUri(tokenPair.accessToken());
105+
refreshTokenCookie = createRefreshTokenCookie(tokenPair.refreshToken());
106+
} catch (BizException ex) {
107+
location = buildRedirectUri(ClientProperties.PathKey.SOCIAL_LOGIN_FAILURE);
108+
logBizException(ex);
109+
} catch (Exception ex) {
110+
location = buildRedirectUri(ClientProperties.PathKey.SOCIAL_LOGIN_FAILURE);
111+
log.error("소셜 계정 로그인 콜백 처리 중 예상치 못한 오류 발생. provider: {}", provider, ex);
112+
}
113+
114+
return buildRedirectResponse(location, refreshTokenCookie);
115+
}
116+
117+
private void logBizException(BizException ex) {
118+
log.warn("BizException handled: code={}, status={}, message={}",
119+
ex.getErrorCode().getCode(),
120+
ex.getErrorCode().getStatus(),
121+
ex.getErrorCode().getMessage()
122+
);
123+
}
124+
125+
private ResponseCookie createRefreshTokenCookie(String token) {
126+
long expirationSeconds = jwtProperties.getRefreshTokenExpiration().toSeconds();
127+
return cookieUtil.createCookie(
128+
JwtConstants.REFRESH_TOKEN,
129+
token,
130+
Math.toIntExact(expirationSeconds)
131+
);
132+
}
133+
134+
private URI resolveRedirectUrlForSocialLink(BizException exception) {
135+
if (exception.getErrorCode() == AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) {
136+
return buildRedirectUri(ClientProperties.PathKey.SOCIAL_LINK_CONFLICT);
137+
}
138+
return buildRedirectUri(ClientProperties.PathKey.SOCIAL_LINK_FAILURE);
139+
}
140+
141+
private URI buildRedirectUri(ClientProperties.PathKey pathKey) {
142+
return URI.create(clientProperties.buildUrl(pathKey));
143+
}
144+
145+
private URI buildLoginSuccessRedirectUri(String accessToken) {
146+
return UriComponentsBuilder
147+
.fromUriString(clientProperties.buildUrl(ClientProperties.PathKey.SOCIAL_LOGIN_SUCCESS))
148+
.queryParam("accessToken", accessToken)
149+
.build(true)
150+
.toUri();
151+
}
152+
153+
private ResponseEntity<Void> buildRedirectResponse(URI location, ResponseCookie cookie) {
154+
ResponseEntity.BodyBuilder builder = ResponseEntity.status(HttpStatus.FOUND).location(location);
155+
if (cookie != null) {
156+
builder.header(HttpHeaders.SET_COOKIE, cookie.toString());
157+
}
158+
return builder.build();
78159
}
79160
}

src/main/java/project/flipnote/auth/exception/AuthErrorCode.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ public enum AuthErrorCode implements ErrorCode {
2020
ALREADY_SENT_PASSWORD_RESET_LINK(HttpStatus.CONFLICT, "AUTH_008", "이미 유효한 비밀번호 재설정 링크가 존재합니다. 이메일을 확인해주세요."),
2121
INVALID_PASSWORD_RESET_TOKEN(HttpStatus.NOT_FOUND, "AUTH_009", "비밀번호 재설정 링크가 유효하지 않거나 만료되었습니다."),
2222
INVALID_SOCIAL_LINK_TOKEN(HttpStatus.NOT_FOUND, "AUTH_010", "소셜 계정 연동 토큰이 유효하지 않거나 만료되었습니다."),
23-
ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "AUTH_011", "이미 연동된 소셜 계정입니다.");
23+
ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "AUTH_011", "이미 연동된 소셜 계정입니다."),
24+
NOT_REGISTERED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "AUTH_012", "가입되지 않은 소셜 계정입니다."),
25+
INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_013", "지원하지 않는 소셜 제공자입니다.");
2426

2527
private final HttpStatus httpStatus;
2628
private final String code;

src/main/java/project/flipnote/auth/service/OAuthService.java

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package project.flipnote.auth.service;
22

33
import java.util.Map;
4+
import java.util.Optional;
45
import java.util.UUID;
56

67
import org.springframework.http.ResponseCookie;
@@ -13,13 +14,16 @@
1314
import project.flipnote.auth.constants.OAuthConstants;
1415
import project.flipnote.auth.exception.AuthErrorCode;
1516
import project.flipnote.auth.model.AuthorizationRedirect;
17+
import project.flipnote.auth.model.TokenPair;
1618
import project.flipnote.auth.repository.SocialLinkTokenRedisRepository;
17-
import project.flipnote.common.exception.BizException;
18-
import project.flipnote.infra.oauth.model.OAuth2UserInfo;
1919
import project.flipnote.common.config.OAuthProperties;
20+
import project.flipnote.common.exception.BizException;
21+
import project.flipnote.common.security.dto.UserAuth;
22+
import project.flipnote.common.security.jwt.JwtComponent;
2023
import project.flipnote.common.util.CookieUtil;
2124
import project.flipnote.common.util.PkceUtil;
2225
import project.flipnote.infra.oauth.OAuthApiClient;
26+
import project.flipnote.infra.oauth.model.OAuth2UserInfo;
2327
import project.flipnote.user.entity.UserOAuthLink;
2428
import project.flipnote.user.repository.UserOAuthLinkRepository;
2529
import project.flipnote.user.repository.UserRepository;
@@ -37,16 +41,19 @@ public class OAuthService {
3741
private final SocialLinkTokenRedisRepository socialLinkTokenRedisRepository;
3842
private final UserRepository userRepository;
3943
private final UserOAuthLinkRepository userOAuthLinkRepository;
44+
private final JwtComponent jwtComponent;
4045

41-
public AuthorizationRedirect getAuthorizationUri(String providerName, HttpServletRequest request, long userId) {
46+
public AuthorizationRedirect getAuthorizationUri(
47+
String providerName,
48+
HttpServletRequest request,
49+
UserAuth userAuth
50+
) {
4251
OAuthProperties.Provider provider = getProvider(providerName);
4352

4453
String codeVerifier = pkceUtil.generateCodeVerifier();
4554
String codeChallenge = pkceUtil.generateCodeChallenge(codeVerifier);
4655

47-
String state = UUID.randomUUID().toString();
48-
socialLinkTokenRedisRepository.saveToken(userId, state);
49-
56+
String state = generateStateForSocialLink(userAuth);
5057
String authorizeUrl = oAuthApiClient.buildAuthorizeUri(request, provider, codeChallenge, state);
5158
ResponseCookie cookie = cookieUtil.createCookie(
5259
OAuthConstants.VERIFIER_COOKIE_NAME,
@@ -69,10 +76,7 @@ public void linkSocialAccount(
6976
.orElseThrow(() -> new BizException(AuthErrorCode.INVALID_SOCIAL_LINK_TOKEN));
7077
socialLinkTokenRedisRepository.deleteToken(state);
7178

72-
OAuthProperties.Provider provider = getProvider(providerName);
73-
String accessToken = oAuthApiClient.requestAccessToken(provider, code, codeVerifier, request);
74-
Map<String, Object> userInfoAttributes = oAuthApiClient.requestUserInfo(provider, accessToken);
75-
OAuth2UserInfo userInfo = oAuthApiClient.createUserInfo(providerName, userInfoAttributes);
79+
OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request);
7680

7781
if (userOAuthLinkRepository.existsByUser_IdAndProviderId(userId, userInfo.getProviderId())) {
7882
throw new BizException(AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT);
@@ -86,7 +90,38 @@ public void linkSocialAccount(
8690
userOAuthLinkRepository.save(userOAuthLink);
8791
}
8892

93+
public TokenPair socialLogin(String providerName, String code, String codeVerifier, HttpServletRequest request) {
94+
OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request);
95+
96+
UserOAuthLink userOAuthLink = userOAuthLinkRepository.findByProviderAndProviderIdWithUser(
97+
providerName, userInfo.getProviderId()
98+
).orElseThrow(() -> new BizException(AuthErrorCode.NOT_REGISTERED_SOCIAL_ACCOUNT));
99+
100+
return jwtComponent.generateTokenPair(userOAuthLink.getUser());
101+
}
102+
103+
private OAuth2UserInfo getOAuth2UserInfo(String providerName, String code, String codeVerifier,
104+
HttpServletRequest request) {
105+
OAuthProperties.Provider provider = getProvider(providerName);
106+
String accessToken = oAuthApiClient.requestAccessToken(provider, code, codeVerifier, request);
107+
Map<String, Object> userInfoAttributes = oAuthApiClient.requestUserInfo(provider, accessToken);
108+
return oAuthApiClient.createUserInfo(providerName, userInfoAttributes);
109+
}
110+
89111
private OAuthProperties.Provider getProvider(String providerName) {
90-
return oauthProperties.getProviders().get(providerName.toLowerCase());
112+
return Optional.ofNullable(oauthProperties.getProviders().get(providerName.toLowerCase()))
113+
.orElseThrow(() -> {
114+
log.warn("지원하지 않는 OAuth Provider 입니다. provider: {}", providerName);
115+
return new BizException(AuthErrorCode.INVALID_OAUTH_PROVIDER);
116+
});
117+
}
118+
119+
private String generateStateForSocialLink(UserAuth userAuth) {
120+
if (userAuth == null) {
121+
return null;
122+
}
123+
String state = UUID.randomUUID().toString();
124+
socialLinkTokenRedisRepository.saveToken(userAuth.userId(), state);
125+
return state;
91126
}
92127
}

src/main/java/project/flipnote/common/config/ClientProperties.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ public enum PathKey {
1818
PASSWORD_RESET,
1919
SOCIAL_LINK_SUCCESS,
2020
SOCIAL_LINK_FAILURE,
21-
SOCIAL_LINK_CONFLICT
21+
SOCIAL_LINK_CONFLICT,
22+
SOCIAL_LOGIN_SUCCESS,
23+
SOCIAL_LOGIN_FAILURE
2224
}
2325

2426
private String url;

src/main/java/project/flipnote/common/security/config/SecurityConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
6666
"/*/auth/login", "/*/auth/email", "/*/auth/email/confirm"
6767
).permitAll()
6868
.requestMatchers(
69-
HttpMethod.GET, "/oauth2/callback/{provider}"
69+
HttpMethod.GET, "/oauth2/authorization/{provider}", "/oauth2/callback/{provider}"
7070
).permitAll()
7171
.requestMatchers(
7272
"/v3/api-docs/**",

src/main/java/project/flipnote/infra/oauth/OAuthApiClient.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,19 @@ public String buildAuthorizeUri(
7878
String codeChallenge,
7979
String state
8080
) {
81-
return UriComponentsBuilder.fromUriString(provider.getAuthorizationUri())
81+
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(provider.getAuthorizationUri())
8282
.queryParam("client_id", provider.getClientId())
8383
.queryParam("redirect_uri", buildRedirectUri(request, provider.getRedirectUri()))
8484
.queryParam("response_type", "code")
8585
.queryParam("scope", String.join(" ", provider.getScope()))
8686
.queryParam("code_challenge", codeChallenge)
87-
.queryParam("code_challenge_method", "S256")
88-
.queryParam("state", state)
89-
.toUriString();
87+
.queryParam("code_challenge_method", "S256");
88+
89+
if (state != null) {
90+
builder.queryParam("state", state);
91+
}
92+
93+
return builder.toUriString();
9094
}
9195

9296
private String buildRedirectUri(HttpServletRequest request, String path) {

src/main/java/project/flipnote/user/repository/UserOAuthLinkRepository.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package project.flipnote.user.repository;
22

33
import java.util.List;
4+
import java.util.Optional;
45

56
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
69

710
import project.flipnote.user.entity.UserOAuthLink;
811

@@ -13,4 +16,17 @@ public interface UserOAuthLinkRepository extends JpaRepository<UserOAuthLink, Lo
1316
List<UserOAuthLink> findByUser_Id(Long userId);
1417

1518
boolean existsByIdAndUser_Id(Long id, Long userId);
19+
20+
@Query("""
21+
SELECT uol
22+
FROM UserOAuthLink uol
23+
JOIN FETCH uol.user
24+
WHERE uol.provider = :provider
25+
AND uol.providerId = :providerId
26+
""")
27+
Optional<UserOAuthLink> findByProviderAndProviderIdWithUser(
28+
@Param("provider") String provider,
29+
@Param("providerId") String providerId
30+
);
31+
1632
}

src/main/resources/application.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ app:
6262
social-link-success: /social-link/success
6363
social-link-failure: /social-link/failure
6464
social-link-conflict: /social-link/conflict
65+
social-login-success: /social-login/success
66+
social-login-failure: /social-login/failure
6567

6668
oauth2:
6769
providers:

0 commit comments

Comments
 (0)