Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NAYB-152] feat: 회원 탈퇴 시 인증 서버 액세스 토큰 만료 기간 검증 #111

Merged
merged 4 commits into from
Sep 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public ResponseEntity<FindUserDetailResponse> findUser(@LoginUser Long userId) {
return ResponseEntity.ok(findUserDetailResponse);
}

@DeleteMapping("/users")
@DeleteMapping("/users/me")
public ResponseEntity<Void> deleteUser(@LoginUser Long userId) {
FindUserCommand findUserDetailCommand = FindUserCommand.from(userId);
FindUserDetailResponse findUserDetailResponse = userService.findUser(findUserDetailCommand);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,44 @@
import com.prgrms.nabmart.domain.user.service.response.FindUserDetailResponse;
import com.prgrms.nabmart.global.auth.exception.OAuthUnlinkFailureException;
import com.prgrms.nabmart.global.auth.oauth.dto.OAuthHttpMessage;
import java.text.MessageFormat;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

@RequiredArgsConstructor
public class KakaoMessageProvider implements OAuthHttpMessageProvider {

private static final String UNLINK_URI = "https://kapi.kakao.com/v1/user/unlink";
private static final String ACCESS_TOKEN_REFRESH_URI = "https://kauth.kakao.com/oauth/token";
private static final String CONTENT_TYPE = "Content-Type";
private static final String AUTHORIZATION = "Authorization";
private static final String TARGET_ID_TYPE = "target_id_type";
private static final String USER_ID = "user_id";
private static final String TARGET_ID = "target_id";
private static final String ID = "id";
private static final String APPLICATION_X_WWW_FORM_URLENCODED_CHARSET_UTF_8
= "application/x-www-form-urlencoded;charset=utf-8";
private static final String GRANT_TYPE = "grant_type";
private static final String REFRESH_TOKEN = "refresh_token";
private static final String CLIENT_ID = "client_id";
private static final String CLIENT_SECRET = "client_secret";
private static final String ACCESS_TOKEN = "access_token";
private static final String EXPIRES_IN = "expires_in";

@Override
public OAuthHttpMessage createUnlinkHttpMessage(
public OAuthHttpMessage createUserUnlinkRequest(
final FindUserDetailResponse userDetailResponse,
final OAuth2AuthorizedClient authorizedClient) {
String accessToken = getAccessToken(authorizedClient);
Expand All @@ -42,22 +63,69 @@ private HttpEntity<MultiValueMap<String, String>> createUnlinkOAuthUserMessage(

private HttpHeaders createHeader(final String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/x-www-form-urlencoded");
headers.set("Authorization", "Bearer " + accessToken);
headers.set(CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
headers.set(AUTHORIZATION, MessageFormat.format("Bearer {0}", accessToken));
return headers;
}

private MultiValueMap<String, String> createUnlinkMessageBody(
final FindUserDetailResponse userDetailResponse) {
MultiValueMap<String, String> multiValueMap = new LinkedMultiValueMap<>();
multiValueMap.add("target_id_type", "user_id");
multiValueMap.add("target_id", String.valueOf(userDetailResponse.providerId()));
multiValueMap.add(TARGET_ID_TYPE, USER_ID);
multiValueMap.add(TARGET_ID, String.valueOf(userDetailResponse.providerId()));
return multiValueMap;
}

@Override
public void checkSuccessUnlinkRequest(Map<String, Object> unlinkResponse) {
Optional.ofNullable(unlinkResponse.get("id"))
Optional.ofNullable(unlinkResponse.get(ID))
.orElseThrow(() -> new OAuthUnlinkFailureException("소셜 로그인 연동 해제가 실패하였습니다."));
}

@Override
public OAuthHttpMessage createRefreshAccessTokenRequest(OAuth2AuthorizedClient authorizedClient) {
return new OAuthHttpMessage(
ACCESS_TOKEN_REFRESH_URI,
createRefreshAccessTokenMessage(authorizedClient),
new HashMap<>());
}

private HttpEntity<MultiValueMap<String, String>> createRefreshAccessTokenMessage(
OAuth2AuthorizedClient authorizedClient) {
return new HttpEntity<>(
createRefreshAccessTokenMessageBody(authorizedClient),
createRefreshAccessTokenMessageHeader());
}

private MultiValueMap<String, String> createRefreshAccessTokenMessageHeader() {
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
headers.add(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED_CHARSET_UTF_8);
return headers;
}

private MultiValueMap<String, String> createRefreshAccessTokenMessageBody(
OAuth2AuthorizedClient authorizedClient) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add(GRANT_TYPE, REFRESH_TOKEN);
params.add(CLIENT_ID, authorizedClient.getClientRegistration().getClientId());
params.add(REFRESH_TOKEN, authorizedClient.getRefreshToken().getTokenValue());
params.add(CLIENT_SECRET, authorizedClient.getClientRegistration().getClientSecret());
return params;
}

@Override
public OAuth2AccessToken extractAccessToken(Map response) {
String accessToken = (String) response.get(ACCESS_TOKEN);
Instant now = Instant.now();
Integer expiresInSeconds = (Integer) response.get(EXPIRES_IN);
Instant expiresIn = now.plusSeconds(expiresInSeconds);
return new OAuth2AccessToken(TokenType.BEARER, accessToken, now, expiresIn);
}

@Override
public Optional<OAuth2RefreshToken> extractRefreshToken(Map response) {
String refreshToken = (String) response.get(REFRESH_TOKEN);
return Optional.ofNullable(refreshToken)
.map(token -> new OAuth2RefreshToken(token, Instant.now()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,46 @@
import com.prgrms.nabmart.domain.user.service.response.FindUserDetailResponse;
import com.prgrms.nabmart.global.auth.exception.OAuthUnlinkFailureException;
import com.prgrms.nabmart.global.auth.oauth.dto.OAuthHttpMessage;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

@Slf4j
public class NaverMessageProvider implements OAuthHttpMessageProvider {

private static final String UNLINK_URI = "https://nid.naver.com/oauth2.0/token?"
+ "client_id={client_id}&client_secret={client_secret}&access_token={access_token}&"
+ "grant_type={grant_type}&service_provider={service_provider}";
private static final String REFRESH_ACCESS_TOKEN_URI = "https://nid.naver.com/oauth2.0/token?"
+ "grant_type=refresh_token&client_id={client_id}&"
+ "client_secret={client_secret}&refresh_token={refresh_token}";
private static final String CONTENT_TYPE = "Content-Type";
private static final String CLIENT_ID = "client_id";
private static final String CLIENT_SECRET = "client_secret";
private static final String ACCESS_TOKEN = "access_token";
private static final String GRANT_TYPE = "grant_type";
private static final String SERVICE_PROVIDER = "service_provider";
private static final String REFRESH_TOKEN = "refresh_token";
private static final String EXPIRES_IN = "expires_in";
private static final String DELETE = "delete";
private static final String NAVER = "NAVER";
private static final String RESULT = "result";
private static final String SUCCESS = "success";

@Override
public OAuthHttpMessage createUnlinkHttpMessage(
public OAuthHttpMessage createUserUnlinkRequest(
final FindUserDetailResponse userDetailResponse,
final OAuth2AuthorizedClient authorizedClient) {
String accessToken = getAccessToken(authorizedClient);
Expand All @@ -40,26 +63,66 @@ private HttpEntity<MultiValueMap<String, String>> createUnlinkOAuthUserMessage()

private HttpHeaders createHeader() {
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/json");
headers.set(CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
return headers;
}

private Map<String, String> createUnlinkUriVariables(
final ClientRegistration clientRegistration,
final String accessToken) {
Map<String, String> urlVariables = new HashMap<>();
urlVariables.put("client_id", clientRegistration.getClientId());
urlVariables.put("client_secret", clientRegistration.getClientSecret());
urlVariables.put("access_token", accessToken);
urlVariables.put("grant_type", "delete");
urlVariables.put("service_provider", "NAVER");
urlVariables.put(CLIENT_ID, clientRegistration.getClientId());
urlVariables.put(CLIENT_SECRET, clientRegistration.getClientSecret());
urlVariables.put(ACCESS_TOKEN, accessToken);
urlVariables.put(GRANT_TYPE, DELETE);
urlVariables.put(SERVICE_PROVIDER, NAVER);
return urlVariables;
}

@Override
public void checkSuccessUnlinkRequest(Map<String, Object> unlinkResponse) {
Optional.ofNullable(unlinkResponse.get("result"))
.filter(result -> result.equals("success"))
Optional.ofNullable(unlinkResponse.get(RESULT))
.filter(result -> result.equals(SUCCESS))
.orElseThrow(() -> new OAuthUnlinkFailureException("소셜 로그인 연동 해제가 실패하였습니다."));
}

@Override
public OAuthHttpMessage createRefreshAccessTokenRequest(
OAuth2AuthorizedClient authorizedClient) {
return new OAuthHttpMessage(
REFRESH_ACCESS_TOKEN_URI,
createEmptyMessage(),
createRefreshAccessTokenUriVariables(authorizedClient));
}

private HttpEntity<MultiValueMap<String, String>> createEmptyMessage() {
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
return new HttpEntity<>(map, map);
}

private Map<String, String> createRefreshAccessTokenUriVariables(
OAuth2AuthorizedClient authorizedClient) {
Map<String, String> variables = new HashMap<>();
variables.put(CLIENT_ID, authorizedClient.getClientRegistration().getClientId());
variables.put(CLIENT_SECRET, authorizedClient.getClientRegistration().getClientSecret());
variables.put(REFRESH_TOKEN, authorizedClient.getRefreshToken().getTokenValue());
variables.put(GRANT_TYPE, REFRESH_TOKEN);
return variables;
}

@Override
public OAuth2AccessToken extractAccessToken(Map response) {
String accessToken = (String) response.get(ACCESS_TOKEN);
String expiresInSeconds = (String) response.get(EXPIRES_IN);
Instant now = Instant.now();
Instant expiresIn = now.plusSeconds(Long.parseLong(expiresInSeconds));
return new OAuth2AccessToken(TokenType.BEARER, accessToken, now, expiresIn);
}

@Override
public Optional<OAuth2RefreshToken> extractRefreshToken(Map response) {
String refreshToken = (String) response.get(REFRESH_TOKEN);
return Optional.ofNullable(refreshToken)
.map(token -> new OAuth2RefreshToken(token, Instant.now()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@
import com.prgrms.nabmart.domain.user.service.response.FindUserDetailResponse;
import com.prgrms.nabmart.global.auth.oauth.dto.OAuthHttpMessage;
import java.util.Map;
import java.util.Optional;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;

public interface OAuthHttpMessageProvider {

OAuthHttpMessage createUnlinkHttpMessage(
OAuthHttpMessage createUserUnlinkRequest(
final FindUserDetailResponse userDetailResponse,
final OAuth2AuthorizedClient authorizedClient);

void checkSuccessUnlinkRequest(Map<String, Object> unlinkResponse);

OAuthHttpMessage createRefreshAccessTokenRequest(OAuth2AuthorizedClient authorizedClient);

OAuth2AccessToken extractAccessToken(Map response);

Optional<OAuth2RefreshToken> extractRefreshToken(Map response);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@
public interface OAuthRestClient {

void callUnlinkOAuthUser(FindUserDetailResponse userDetailResponse);

void refreshAccessToken(FindUserDetailResponse userDetailResponse);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@
import com.prgrms.nabmart.domain.user.service.response.FindUserDetailResponse;
import com.prgrms.nabmart.global.auth.oauth.dto.OAuthHttpMessage;
import com.prgrms.nabmart.global.auth.oauth.handler.OAuthProvider;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

Expand All @@ -29,15 +35,60 @@ public void callUnlinkOAuthUser(final FindUserDetailResponse userDetailResponse)
OAuth2AuthorizedClient oAuth2AuthorizedClient = authorizedClientService.loadAuthorizedClient(
userDetailResponse.provider(),
userDetailResponse.providerId());
OAuthHttpMessage unlinkHttpMessage = oAuthHttpMessageProvider.createUnlinkHttpMessage(

Instant expiresAt = oAuth2AuthorizedClient.getAccessToken().getExpiresAt();
if(expiresAt.isBefore(Instant.now())) {
refreshAccessToken(userDetailResponse);
}

OAuthHttpMessage unlinkHttpMessage = oAuthHttpMessageProvider.createUserUnlinkRequest(
userDetailResponse, oAuth2AuthorizedClient);
Map<String, Object> response = restTemplate.postForObject(
unlinkHttpMessage.uri(),
unlinkHttpMessage.httpMessage(),
Map.class,
unlinkHttpMessage.uriVariables());
Map<String, Object> response = sendPostApiRequest(unlinkHttpMessage);
log.info("회원의 연결이 종료되었습니다. 회원 ID={}", response);
oAuthHttpMessageProvider.checkSuccessUnlinkRequest(response);
}

@Override
public void refreshAccessToken(final FindUserDetailResponse userDetailResponse) {
OAuthProvider oAuthProvider = OAuthProvider.getOAuthProvider(userDetailResponse.provider());
OAuthHttpMessageProvider oAuthHttpMessageProvider = oAuthProvider.getOAuthHttpMessageProvider();
OAuth2AuthorizedClient oAuth2AuthorizedClient = authorizedClientService.loadAuthorizedClient(
userDetailResponse.provider(),
userDetailResponse.providerId());
OAuthHttpMessage refreshAccessTokenRequest
= oAuthHttpMessageProvider.createRefreshAccessTokenRequest(oAuth2AuthorizedClient);

Map response = sendPostApiRequest(refreshAccessTokenRequest);

OAuth2AccessToken refreshedAccessToken
= oAuthHttpMessageProvider.extractAccessToken(response);
OAuth2RefreshToken refreshedRefreshToken
= oAuthHttpMessageProvider.extractRefreshToken(response)
.orElse(oAuth2AuthorizedClient.getRefreshToken());

OAuth2AuthorizedClient updatedAuthorizedClient = new OAuth2AuthorizedClient(
oAuth2AuthorizedClient.getClientRegistration(),
oAuth2AuthorizedClient.getPrincipalName(),
refreshedAccessToken,
refreshedRefreshToken);
String principalName = updatedAuthorizedClient.getPrincipalName();
Authentication authenticationForTokenRefresh
= getAuthenticationForTokenRefresh(principalName);
authorizedClientService.saveAuthorizedClient(
updatedAuthorizedClient,
authenticationForTokenRefresh);
}

private Authentication getAuthenticationForTokenRefresh(String principalName) {
return UsernamePasswordAuthenticationToken.authenticated(
principalName, null, List.of());
}

private Map sendPostApiRequest(OAuthHttpMessage refreshAccessTokenRequest) {
return restTemplate.postForObject(
refreshAccessTokenRequest.uri(),
refreshAccessTokenRequest.httpMessage(),
Map.class,
refreshAccessTokenRequest.uriVariables());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ void DeleteUser() throws Exception {
given(userService.findUser(any())).willReturn(findUserDetailResponse);

//when
ResultActions resultActions = mockMvc.perform(delete("/api/v1/users")
ResultActions resultActions = mockMvc.perform(delete("/api/v1/users/me")
.header("Authorization", accessToken));

//then
Expand Down