+) 2024.02.25 yomankum 프로젝트 기준으로 수정!
정말 정말 정말 x 1000
고생하고 오래 걸린 SNS 로그인 !!!
그냥 정석으로 하는 SNS 로그인 자체는 어렵지 않은데
+ JWT 로그인
+ 리다이렉트
하는 게 조금 힘들었다...ㅎㅎ
뭐 코드 자체는 어렵지 않은데
예시가 많이 없어서 / 이해가 힘들어서 그랬나 ?
알겠는데. 그래서 어쩌라고 ? 하는 것들이 많았당.
내가 어떻게 구현했는지 적어둬야지~
그냥 로그인은
서버에서 검증을 다 처리했다면
OAuth2 로그인이란
서버 - 클라이언트 - 서버가 서로 소통하면서 일처리를 하는 건데
서비스클라이언트A : 나 로그인 할래!
서버B : 클라이언트야, A가 로그인한다는데?
클라이언트 : 너 서버B 구나? (A 확인 후) 응응 얘 우리가 정보를 갖고 있는 클라이언트A 맞네~. 로그인 시켜~
뭐 이런 식으로 티키타카한다.
인증이 필요한 서버에서 직접 인증,인가 하는 것이 아니라 권한 부여 서버를 따로 두는 것이라고 이해하면 된다.
어떤 서비스가 권한 부여 서버를 따로 둔다면 OAuth2 인증이기 때문에
꼭 다른 서비스(SNS)를 통한 로그인이 아니더라도
자체 서비스 내에 권한 부여 서버가 따로 있다면, OAuth2 인증을 진행한다고 봐도 될 거 같다
쉽게 말해, 인증과 자격증명에 대한 책임을 나누는 것이다.
어쨌든 내가 구현한 OAuth2 인증의 클래스 구성은!
+ SecurityConfig 까지 !
SecurityConfig
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
@RequiredArgsConstructor
class SecurityConfig {
private final CustomOAuth2AuthorizationRequestResolver authorizationRequestResolver;
private final CustomOAuth2AuthorizationCodeGrantFilter oAuth2AuthorizationCodeGrantFilter;
private final CustomDefaultOAuth2UserService oAuth2UserService;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final CustomAccessDeniedHandler accessDeniedHandler;
private final JwtFilter jwtFilter;
private final OAuth2JwtFilter oAuth2JwtFilter;
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public AuthenticationConfiguration authenticationConfiguration() {
return new AuthenticationConfiguration();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(request -> request
// .requestMatchers("/").hasRole("USER")
.anyRequest().permitAll())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.logout(logout -> logout.logoutSuccessUrl("/api/v1/login").permitAll())
// OAuth2
.addFilterBefore(oAuth2AuthorizationCodeGrantFilter, OAuth2LoginAuthenticationFilter.class)
.addFilterAfter(oAuth2JwtFilter, OAuth2LoginAuthenticationFilter.class)
.exceptionHandling(exception -> exception
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
)
.oauth2Login(oauth -> oauth
.authorizationEndpoint(end -> end.authorizationRequestResolver(authorizationRequestResolver))
.userInfoEndpoint(userInfo -> userInfo.userService(oAuth2UserService))
)
.build();
}
}
.oauth2Login(oauth -> oauth
.authorizationEndpoint(end -> end.authorizationRequestResolver(authorizationRequestResolver))
.userInfoEndpoint(userInfo -> userInfo.userService(oAuth2UserService))
)
나는 Resolver 와 userService 를 직접 구현했기 때문에 넣어주었다
리졸버를 만들었다는 건
서버가 클라이언트에게 요청을 하는 로직을 직접 만들었다는 건데
이건 내가 redirect 기능을 !!!반드시!!! 넣고 싶었기 때문에 어쩔 수 없이 구현해야만 했다....^/^
- 2024.02.25
예를 들어 리디렉션해야하는 페이지가 존재한다면
어떤 페이지를 요청했을 때 권한이 없어서 로그인 페이지로 리디렉션 됐을 것이다. 그렇다면
1. 일단 클라이언트에서 로그인 요청할 때까지 redirectUri 를 가지고 있다가 서버한테 파라미터로 보내주어야 함.
2. 그걸 resolver 에게 보내주고 요청해야 함.
-> 요런 프로세스
UserService 는 그냥 Security 로그인에서는 패스워드인코더 덕분에 필수구현이라면
여기서는 회원가입 로직 때문에 반드시 UserService 를 구현해야만 한다...
.addFilterBefore(oAuth2AuthorizationCodeGrantFilter, OAuth2LoginAuthenticationFilter.class)
.addFilterAfter(oAuth2JwtFilter, OAuth2LoginAuthenticationFilter.class)
.exceptionHandling(exception -> exception
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
)
GrantFilter 는 클라이언트에게 "토큰"을 요구하는 필터라고 보면 된다
TokenFilter 는 말 그대로 받아온 "그 토큰"을 가지고 jwt 를 만드는 필터
exception : 에러 상황 핸들러
accessDenied : 로그인은 했지만 알고보니 페이지에 대한 권한이 없을 경우
SnsInfo
package com.account.yomankum.security.oauth.user;
import com.account.yomankum.security.oauth.type.Sns;
import com.account.yomankum.security.oauth.type.TokenProp;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
@PropertySource("classpath:application.yml")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SnsInfo {
// SNS Info interface
// GoogleInfo
// NaverInfo
// KakaoInfo
@Getter
private String clientId;
@Getter
private String clientSecret;
@Getter
private String redirectUri;
@Getter
private String authUri;
@Getter
private String tokenUri;
@Getter
private List<String> scope = new ArrayList<>();
@Value("${spring.security.oauth2.client.registration.google.client-id}")
private String googleClientId;
@Value("${spring.security.oauth2.client.registration.google.client-secret}")
private String googleClientSecret;
@Value("${spring.security.oauth2.client.registration.google.redirect-uri}")
private String googleRedirectUri;
@Value("${spring.security.oauth2.client.provider.google.authorization-uri}")
private String googleAuthUri;
@Value("${spring.security.oauth2.client.provider.google.token-uri}")
private String googleTokenUri;
@Value("${spring.security.oauth2.client.registration.naver.client-id}")
private String naverClientId;
@Value("${spring.security.oauth2.client.registration.naver.client-secret}")
private String naverClientSecret;
@Value("${spring.security.oauth2.client.registration.naver.redirect-uri}")
private String naverRedirectUri;
@Value("${spring.security.oauth2.client.provider.naver.authorization-uri}")
private String naverAuthUri;
@Value("${spring.security.oauth2.client.provider.naver.token-uri}")
private String naverTokenUri;
@Getter
@Value("${spring.security.oauth2.client.provider.naver.user-info-uri}")
private String naverProfileApiUri;
@Value("${spring.security.oauth2.client.registration.kakao.client-id}")
private String kakaoClientId;
@Value("${spring.security.oauth2.client.registration.kakao.client-secret}")
private String kakaoClientSecret;
@Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
private String kakaoRedirectUri;
@Value("${spring.security.oauth2.client.provider.kakao.authorization-uri}")
private String kakaoAuthUri;
@Value("${spring.security.oauth2.client.provider.kakao.token-uri}")
private String kakaoTokenUri;
public String responseType() {
return TokenProp.CODE.getName();
}
public SnsInfo(String sns) {
if (sns.equals(Sns.GOOGLE.name())) {
this.clientId = googleClientId;
this.clientSecret = googleClientSecret;
this.redirectUri = googleRedirectUri;
this.authUri = googleAuthUri;
this.tokenUri = googleTokenUri;
scope.add("email");
scope.add("profile");
}
if (sns.equals(Sns.KAKAO.name())) {
this.clientId = kakaoClientId;
this.clientSecret = kakaoClientSecret;
this.redirectUri = kakaoRedirectUri;
this.authUri = kakaoAuthUri;
this.tokenUri = kakaoTokenUri;
scope.add("profile_nickname");
scope.add("account_email");
scope.add("openid");
}
if (sns.equals(Sns.NAVER.name())) {
this.clientId = naverClientId;
this.clientSecret = naverClientSecret;
this.redirectUri = naverRedirectUri;
this.authUri = naverAuthUri;
this.tokenUri = naverTokenUri;
scope.add("email");
}
}
}
OAuth2 인증에서는 application.yml(properties) 에 설정할 것들이 아주 많다
여기에 쓰는 정보들은 정말 중요하고 노출되면 안 되기 때문에
이렇게 코드가 아닌 파일에다가 따로 저장을 해놓는데
이걸 그대로 코드에 복사 붙여넣기를 해버리면 보안이슈가 있잖아?
그래서 @Value 를 이용해 application.yml 에 있는 값들을 가져오게 만들었다
snsInfo 인터페이스를 만들고
GoogleInfo , KakaoInfo , NaverInfo 이렇게 만들어서
각각 따로 값을 넣고
sns 명을 검증해서 해당 sns에 맞는 클래스를 snsInfo 객체에 넣을까도 생각해봤는데
그렇게 되면 각각 application.yml 에서 값을 가져오기 위해
각 클래스를 전부 빈으로 등록해야하는데
뭔가 굳이...?싶어서 그냥 한번에 집어넣었음
보통 sns로그인이 확장이 엄청 많이 필요하지 않잖아? (아님말구)
+) 2024.02.25
고민을 좀 많이 해봤다..
어차피 네이버만 가지고 있는 값은 ProfileUri 값이어서
인터페이스에 getProfileUri() 를 넣고
kakao, googleInfo 에서 getProfileUri() 를 호출하면 UnsupportedOperationException 를 던질까 생각했다.
실제로 저렇게 만들어보기도 했는데
그러면 SnsInfo 객체를 가져올 때마다 어떤 Info 객체를 사용해야하는지 일일히 검증해야되고
계속 캐스팅해줘야하고 또 그 코드가 또 추가되고...record 추가하고...
수정할 가능성이 "거의" 없는 코드기도 하고
또 kakaoInfo 등에서 profileUri 를 호출하면 예외를 던지게 만들어야한다는 게 좀 그렇기두 하구..
개발자는 만들면서 잘 모를 수도 있잖아? 나중에 누가 kakaoInfo 등이 profileUri 호출하는 코드를 짤 수도 있는 거구.
그래서 그냥 클래스를 이대로 두었다.
복잡해보이지만 지금 내 수준에선 이대로 두는 게 제일 나아보였다.
어쨌든 이 SnsInfo 를 사용한
CustomOAuth2AuthorizationRequestResolver
@Slf4j
@Component
public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
private final String SNS_REQUEST_URI = "/oauth2/authorization/";
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
String requestURI = request.getRequestURI();
if (requestURI.startsWith(SNS_REQUEST_URI)) {
String sns = request.getRequestURI().substring(SNS_REQUEST_URI.length());
return this.resolve(request, sns);
}
return null;
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
String sns = clientRegistrationId.toUpperCase();
String state = UUID.randomUUID().toString();
setStateSession(request, state);
setClientSession(request, state, sns);
SnsInfo snsInfo = new SnsInfo(sns);
String clientId = snsInfo.getClientId();
String authUri = snsInfo.getAuthUri();
String redirectUri = "/";
String responseType = snsInfo.responseType(); // code
String[] scopes = snsInfo.getScope().toArray(String[]::new);
Map<String, String> clientMap = new HashMap<>();
clientMap.put(OAuth2ParameterNames.RESPONSE_TYPE, responseType);
clientMap.put(TokenProp.CLIENT.getName(), sns);
return OAuth2AuthorizationRequest
.authorizationCode()
.clientId(clientId)
.authorizationUri(authUri)
.scope(scopes)
.redirectUri(redirectUri)
.state(state)
.parameters(params -> params.putAll(clientMap))
.build();
}
private void setClientSession(HttpServletRequest request, String state, String sns) {
HttpSession session = request.getSession();
session.setAttribute(state, sns);
}
private void setStateSession(HttpServletRequest request, String state) {
HttpSession session = request.getSession();
session.setAttribute(TokenProp.STATE.getName(), state);
}
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
String requestURI = request.getRequestURI();
if (requestURI.startsWith(SNS_REQUEST_URI)) {
String sns = request.getRequestURI().substring(SNS_REQUEST_URI.length());
return this.resolve(request, sns);
}
return null;
}
나는 클라이언트에 요청하는 uri.
그러니까, sns 로그인 버튼을 클릭하고 로그인을 동의하면 나오는 요청 uri를
/oauth2/authorization/{sns} 로 만들었기 때문에
해당 요청일 때만 클라이언트에게 요청을 보내도록 리졸버가 동작하도록 만들었다
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
String sns = clientRegistrationId.toUpperCase();
String state = UUID.randomUUID().toString();
setStateSession(request, state);
setClientSession(request, state, sns);
SnsInfo snsInfo = new SnsInfo(sns);
String clientId = snsInfo.getClientId();
String authUri = snsInfo.getAuthUri();
String redirectUri = "/";
String responseType = snsInfo.responseType(); // code
String[] scopes = snsInfo.getScope().toArray(String[]::new);
Map<String, String> clientMap = new HashMap<>();
clientMap.put(OAuth2ParameterNames.RESPONSE_TYPE, responseType);
clientMap.put(TokenProp.CLIENT.getName(), sns);
return OAuth2AuthorizationRequest
.authorizationCode()
.clientId(clientId)
.authorizationUri(authUri)
.scope(scopes)
.redirectUri(redirectUri)
.state(state)
.parameters(params -> params.putAll(clientMap))
.build();
}
이건 말 그대로 클라이언트에게
나 서버 누구누군데
이거랑 이거 정보 좀 주라
redirectUri 는 이거고, 우리끼리 아는 암호는 이거고~~블라블라하는 건데
사실 이게 application.yml 에 세팅 된 거라 알아서 다 들어가서 안 만들어도 되는데
난 오직 redirectUri 때문에 만들었다...
이렇게 보내야 redirectUri 를 해당 OAuth2 클라이언트에 보낼 수 있거든 ㅜㅜ..
기본 동작으로는 리디렉션이 안 됨
private String getRedirectUri(HttpServletRequest request, String sns) {
String redirectUrl = request.getParameter("redirectUrl");
if (!StringUtils.hasText(redirectUrl)) {
redirectUrl = snsInfo.redirectUri(sns.toUpperCase());
} else {
if (redirectUrl.startsWith("/fanLetter")) {
redirectUrl = "/fanLetter";
}
if (redirectUrl.startsWith("/market/buy")) {
redirectUrl = "/market/buy";
}
if (redirectUrl.startsWith("/market/sell")) {
redirectUrl = "/market/sell";
}
redirectUrl = BASE_URL + redirectUrl;
}
return redirectUrl;
}
나는 /loginForm 에서 Oauth2 인증 요청시에
redirectUrl 이라는 파라미터가 존재할 경우, redirectUrl 을 기본uri 가 아닌
내가 파라미터로 받은 uri 로 보내도록 변경했다
근데 꼭 내가 OAuth 클라이언트에 정한 리디렉션 uri 와 정확히 일치해야지만 가능하더라구..?
그래서 뒤에 잡다한 문자열이 붙어도 그냥 떼어버렸다
그래서 저기에서 오직 redirectUrl 요청을 받기 위해......이 고생을 한 것ㅡ.ㅡㅠ
이거 코드 자체는 별 거 아닌데 고생 좀 했다..
+) 2024.02.25
요만큼 기준으로 클라이언트 서버와 통신을 하기 때문에 위의 코드가 수정됐다.
이후에 프론트 개발자님과 얘기해보아야하긴 하겠지만, 이제는 직접 getRedirectUri() 를 파싱해서 넣어주지 않고
redirectUri 를 그대로 클라이언트에게 전달하면 클라이언트가 어떤 api 요청이 왔었는지 확인하고
그 api 로 redirection 해주면 되지 않을까 싶다.
그래서 요만큼에서는 getRedirectUri() 메서드는 삭제하고
redirectUri 는 "/" 로 두었다.
그리고 참고로,
state 이랑 sns 를 넘겨줄 때는 request 가 아니라 session 에 저장해야한다
왜냐고?
내가 보낸 request 는 클라이언트에게 요청하는 거고
클라이언트의 새 응답이 오면 GrantFilter 에서는 다시 새 요청을 하는 거라 새로운 request 객체를 사용하기 때문에
request 에 넣으면 state 과 sns 가 사라져버림 ㅠㅠ
세션에 넣읍시다요!
CustomOAuth2AuthorizationCodeGrantFilter
@Slf4j
@Component
public class CustomOAuth2AuthorizationCodeGrantFilter extends OAuth2AuthorizationCodeGrantFilter {
private final SnsInfo snsInfo;
public CustomOAuth2AuthorizationCodeGrantFilter(final ClientRegistrationRepository clientRegistrationRepository, final OAuth2AuthorizedClientRepository authorizedClientRepository, final AuthenticationManager authenticationManager, final SnsInfo snsInfo) {
super(clientRegistrationRepository, authorizedClientRepository, authenticationManager);
this.snsInfo = snsInfo;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// https://localhost:8080?code={code}&state={state}
String code = request.getParameter(TokenProp.CODE.getName());
if (!StringUtils.hasText(code)) {
log.error("코드 없음");
filterChain.doFilter(request, response);
return;
}
String requestURI = request.getRequestURL().toString();
HttpSession session = request.getSession();
String myState = String.valueOf(
session.getAttribute(TokenProp.STATE.getName())
);
String snsSendState = request.getParameter(TokenProp.STATE.getName());
if (myState.equals("null") | snsSendState == null) {
log.error("{} 없음.", TokenProp.STATE.getName());
filterChain.doFilter(request, response);
return;
}
if (snsSendState.equals(myState) && StringUtils.hasText(code)) {
// state 값이 key 고 value 가 sns 명
String sns = String.valueOf(session.getAttribute(snsSendState));
String clientId = snsInfo.getClientId();
String clientSecret = snsInfo.getClientSecret();
String tokenUri = snsInfo.getTokenUri();
removeSessionAttributeState(session, myState);
HttpEntity<MultiValueMap<String, String>> httpEntity = setHttpEntity
(code, requestURI, sns, clientId, clientSecret);
// https://kauth.kakao.com/oauth/token 으로 토큰 요청 보내기
ResponseEntity<TokenResponse> responseEntity = sendTokenRequest(tokenUri, httpEntity);
TokenResponse tokenResponse = responseEntity.getBody();
request.setAttribute(TokenProp.TOKEN_RESPONSE.name(), tokenResponse);
request.setAttribute(TokenProp.SNS.getName(), sns);
}
filterChain.doFilter(request, response);
}
private HttpEntity<MultiValueMap<String, String>> setHttpEntity(String code, String requestURI, String sns, String clientId, String clientSecret) {
MultiValueMap<String, String> parameters =
setParameters(code, clientId, clientSecret, requestURI);
HttpHeaders headers = setHeaders(sns, clientId, clientSecret);
return new HttpEntity<>(parameters, headers);
}
private ResponseEntity<TokenResponse> sendTokenRequest(String tokenUri, HttpEntity<MultiValueMap<String, String>> httpEntity) {
RestTemplate restTemplate = new RestTemplate();
return restTemplate.exchange(tokenUri, HttpMethod.POST, httpEntity, TokenResponse.class);
}
private HttpHeaders setHeaders(String sns, String clientId, String clientSecret) {
HttpHeaders headers = new HttpHeaders();
MediaType contentType = valueOf(APPLICATION_FORM_URLENCODED_VALUE);
headers.setBasicAuth(sns, snsInfo.getClientSecret());
headers.setContentType(contentType);
if (sns.equals(Sns.GOOGLE.name())) {
String authValue =
Base64.getEncoder().encodeToString(
(clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
headers.set(HttpHeaders.AUTHORIZATION, "Basic "+authValue);
}
return headers;
}
private void removeSessionAttributeState(HttpSession session, String originState) {
session.removeAttribute(TokenProp.STATE.getName());
session.removeAttribute(originState);
}
private MultiValueMap<String, String> setParameters(String code, String clientId, String clientSecret, String requestURI) {
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
parameters.add(TokenProp.GRANT_TYPE.getName(), TokenProp.AUTHORIZATION_CODE.getName());
parameters.add(TokenProp.CODE.getName(), code);
parameters.add(TokenProp.CLIENT_ID.getName(), clientId);
parameters.add(TokenProp.CLIENT_SECRET.getName(), clientSecret);
parameters.add(TokenProp.REDIRECT_URI.getName(), requestURI);
return parameters;
}
}
리졸버에서 요청을 제대로 보냈고 정상 응답이 왔다면
파라미터에 code 와 state 이 올 것이다
우리는 우리가 보냈던 state 으로 클라이언트가 해당 요청에 대한 답변을 보냈는지 확인하고
클라이언트에게 요청을 보낼 코드도 저장한다
정상 응답이라면.. 토큰을 받기 위해 다시 클라이언트에게 요청을 보낸다
String myState = String.valueOf(
session.getAttribute(TokenProp.STATE.getName())
);
String snsSendState = request.getParameter(TokenProp.STATE.getName());
if (myState.equals("null") | snsSendState == null) {
log.error("{} 없음.", TokenProp.STATE.getName());
filterChain.doFilter(request, response);
return;
}
if (snsSendState.equals(myState) && StringUtils.hasText(code)) {
// state 값이 key 고 value 가 sns 명
String sns = String.valueOf(session.getAttribute(snsSendState));
String clientId = snsInfo.getClientId();
String clientSecret = snsInfo.getClientSecret();
String tokenUri = snsInfo.getTokenUri();
removeSessionAttributeState(session, myState);
HttpEntity<MultiValueMap<String, String>> httpEntity = setHttpEntity
(code, requestURI, sns, clientId, clientSecret);
// https://kauth.kakao.com/oauth/token 으로 토큰 요청 보내기
ResponseEntity<TokenResponse> responseEntity = sendTokenRequest(tokenUri, httpEntity);
TokenResponse tokenResponse = responseEntity.getBody();
request.setAttribute(TokenProp.TOKEN_RESPONSE.name(), tokenResponse);
request.setAttribute(TokenProp.SNS.getName(), sns);
}
filterChain.doFilter(request, response);
removeSessionAttributeState(session, myState);
HttpEntity<MultiValueMap<String, String>> httpEntity = setHttpEntity(code, requestURI, sns, clientId, clientSecret);
// https://kauth.kakao.com/oauth/token 으로 토큰 요청 보내기
ResponseEntity<TokenResponse> responseEntity = sendTokenRequest(tokenUri, httpEntity);
TokenResponse tokenResponse = responseEntity.getBody();
request.setAttribute(TokenProp.TOKEN_RESPONSE.name(), tokenResponse);
request.setAttribute(TokenProp.SNS.getName(), sns);
파라미터와 헤더를 세팅했다면,
해당 sns 마다 주어진 토큰 요청 uri 로 요청하고,
응답은 json 으로 오기 때문에, 응답을 받을 형식에 맞는 클래스도 같이 보내준다
응답을 받았다면
다음 작업을 위해 request 객체에
tokenResponse 객체와 sns 객체를 집어넣는다
- 그리고 아까 넣었던 세션은 지워주고 ^0^/ 세션엔 최대한 데이터가 없어야 과부하가 덜 오니까!
참고로 TokenResponse 클래스는
@Getter
@ToString
public class TokenResponse {
@JsonProperty("token_type")
private String tokenType;
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("id_token")
private String idToken;
@JsonProperty("expires_in")
private long expiresIn;
@JsonProperty("refresh_token")
private String refreshToken;
@JsonProperty("refresh_token_expires_in")
private long refreshTokenExpiresIn;
@JsonProperty("error")
private String error;
@JsonProperty("error_description")
private String errorDescription;
@JsonProperty("scope")
private String scope; // 여러개 올 경우, 공백으로 구분 "aaa bbb ccc"
}
이렇게 되어있다
이건 OAuth2 클라이언트들 공통. 모두 약속 된 이름으로 보냅니다.
OAuth2JwtTokenFilter
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2JwtFilter extends OncePerRequestFilter {
private final SnsInfo snsInfo;
private final TokenService tokenService;
private final SnsUserService snsUserService;
private final ClientRegistrationRepository clientRegistrationRepository;
private final CustomDefaultOAuth2UserService defaultOAuth2UserService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
TokenResponse tokenResponse =
(TokenResponse) request.getAttribute(TokenProp.TOKEN_RESPONSE.name());
String sns = String.valueOf(
request.getAttribute(TokenProp.SNS.getName())
);
Sns snsEnum = null;
String snsUuidKey = "";
if (tokenResponse != null && StringUtils.hasText(sns)) {
/**
* - 토큰 파싱을 위해서는 발급자(--sns) 가 필요
* iss : sns (발급자)
* sub : 식별자
* KAKAO : nickname , email(필요한데 서비스 오픈해야 받을 수 있음..)
* NAVER : email
* GOOGLE : email , name
*/
// 네이버는 프로필 정보를 요청해야 합니다.
if (sns.equals(Sns.NAVER.name())) {
snsUuidKey = getNaverUuidkey(tokenResponse);
snsEnum = Sns.NAVER;
}
else if (sns.equals(Sns.KAKAO.name())) {
String token = tokenResponse.getIdToken();
snsUuidKey = tokenService.getSnsUUID(sns, token);
// 카카오는 서비스 오픈 안 하면 이메일은 가져올 수 없음
snsEnum = Sns.KAKAO;
}
}
// 토큰 만들기
SnsUser snsUser = snsUserService.login(snsEnum, snsUuidKey); // throws UserNotFoundException
String accessToken = tokenService.creatToken(snsUser.getId(), snsUser.getNickname(), snsUser.getRole().getRoleName());
String refreshToken = tokenService.createRefreshToken();
setAuthenticationInSpringContext(sns, tokenResponse, accessToken);
setTokensAtReponse(response, accessToken, refreshToken);
setIdAndNicknameAtSession(request, snsUser);
response.sendRedirect("/");
filterChain.doFilter(request, response);
}
private String getNaverUuidkey(TokenResponse tokenResponse) {
// 헤더 세팅
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, TokenProp.BEARER.getName() + " " + tokenResponse.getAccessToken());
HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(headers);
// https://openapi.naver.com/v1/nid/me 으로 프로필 정보 요청 보내기
RestTemplate restTemplate = new RestTemplate();
String naverProfileApiUri = snsInfo.getNaverProfileApiUri();
ResponseEntity<NaverProfileApiResponse> responseEntity =
restTemplate.exchange(naverProfileApiUri, HttpMethod.GET,
httpEntity, NaverProfileApiResponse.class);
NaverProfileApiResponse profileResponse = responseEntity.getBody();
return profileResponse.getResponse().getId();
}
private void setTokensAtReponse(HttpServletResponse response, String accessToken, String refreshToken) {
response.setHeader(HttpHeaders.AUTHORIZATION, TokenProp.BEARER.getName() + " " + accessToken);
response.addCookie(new Cookie(Tokens.REFRESH_TOKEN.name(), refreshToken));
}
private void setIdAndNicknameAtSession(HttpServletRequest request, SnsUser snsUser) {
request.getSession().setAttribute(TokenProp.ID.getName(), snsUser.getId());
request.getSession().setAttribute(TokenProp.NICKNAME.getName(), snsUser.getNickname());
}
private void setAuthenticationInSpringContext(String sns, TokenResponse tokenResponse, String accessToken) {
ClientRegistration clientRegistration =
clientRegistrationRepository.findByRegistrationId(sns.toLowerCase());
OAuth2AccessToken oAuth2AccessToken = getOAuth2AccessToken(tokenResponse);
OAuth2UserRequest oAuth2UserRequest = new OAuth2UserRequest(clientRegistration, oAuth2AccessToken);
OAuth2User oAuth2User = defaultOAuth2UserService.loadUser(oAuth2UserRequest);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(oAuth2User, accessToken, oAuth2User.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private OAuth2AccessToken getOAuth2AccessToken(TokenResponse tokenResponse) {
String accessToken = tokenResponse.getAccessToken();
long expiresIn = tokenResponse.getExpiresIn();
Instant expiresAt = Instant.now().plusSeconds(expiresIn);
return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, accessToken, Instant.now(), expiresAt);
}
}
말 그대로 jwt 토큰을 생성하는 필터다^.^/
주어진 토큰값을 가지고 authentication 을 생성 후 SpringContext 에 저장하는 중요한 작업을 해야한다. ^0^/
private void setTokensAtResponse(HttpServletResponse response, String accessToken, String refreshToken) {
response.setHeader(HttpHeaders.AUTHORIZATION, TokenProp.BEARER.getName() + " " + accessToken);
response.addCookie(new Cookie(Tokens.REFRESH_TOKEN.name(), refreshToken));
}
private void setIdAndNicknameAtSession(HttpServletRequest request, SnsUser snsUser) {
request.getSession().setAttribute(TokenProp.ID.getName(), snsUser.getId());
request.getSession().setAttribute(TokenProp.NICKNAME.getName(), snsUser.getNickname());
}
private void setAuthenticationInSpringContext(String sns, TokenResponse tokenResponse, String accessToken) {
ClientRegistration clientRegistration =
clientRegistrationRepository.findByRegistrationId(sns.toLowerCase());
OAuth2AccessToken oAuth2AccessToken = getOAuth2AccessToken(tokenResponse);
OAuth2UserRequest oAuth2UserRequest = new OAuth2UserRequest(clientRegistration, oAuth2AccessToken);
OAuth2User oAuth2User = defaultOAuth2UserService.loadUser(oAuth2UserRequest);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(oAuth2User, accessToken, oAuth2User.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private OAuth2AccessToken getOAuth2AccessToken(TokenResponse tokenResponse) {
String accessToken = tokenResponse.getAccessToken();
long expiresIn = tokenResponse.getExpiresIn();
Instant expiresAt = Instant.now().plusSeconds(expiresIn);
return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, accessToken, Instant.now(), expiresAt);
}
해당 클라이언트가 보낸 토큰으로 Request 객체를 만들어서
authentication 을 만들고 Context 에 넣으면 된다 ^.^/
참고로 저 access토큰은 내가 만든 jwt 가 아니라 클라이언트가 준 jwt 를 넣어야된다고 한다
클라이언트 인증을 위해서~
customDefaultOAuth2UserService 가 돌아가야 회원가입이나 로그인 작업이 되고
UserDetails 를 토해내기 때문에 꼮꼮 중요하게 해야하는 작업이다
- 난 마지막에 이 코드를 추가해서 완성시켰다...(흑)
네이버 같은 경우는 프로필 정보 uri를 보내면 필요한 정보를 보내주는데
카카오나 구글 같은 경우는 공개 키를 받아 토큰을 검증하고
토큰에서 sub 값을 가져와서 DB에서 멤버를 가져오고 토큰을 만든다
그리고 꼭! 마지막에 redirectUri 를 추가해주고
requestUri 와 token 값을 추가해줘야 한다
그래야 다시 권한이 필요한 페이지로 이동할 때, 시큐리티가 uri에 담긴 토큰 값을 확인해서 검증한다
다시 프론트에 돌아가면 토큰값을 받아도 헤더에 담아서 요청할 수 없으니
이런 경우엔 걍 uri에 파라미터로 토큰 값을 넣어버리면 된다
+) 2024.02.25
요만큼 기준으로는 구글이랑 카카오에 요청할 때 필요한 공개 키 요청 클래스는 따로 만들었는데
public interface JwtValue {
String getKid();
String getKty();
String getAlg();
String getUse();
String getN();
String getE();
}
인터페이스 하나 만들어주고
카카오, 구글 둘 다 두 가지의 공개키를 사용하니까 두 개의 클래스를 만들고나서
@Getter
@Component
@PropertySource("classpath:application.yml")
@NoArgsConstructor
public class KakaoFirstJwt implements JwtValue {
@Value("${token.keys.kakao.kid.first}")
private String kid;
@Value("${token.keys.kakao.kty.first}")
private String kty;
@Value("${token.keys.kakao.alg.first}")
private String alg;
@Value("${token.keys.kakao.use.first}")
private String use;
@Value("${token.keys.kakao.n.first}")
private String n;
@Value("${token.keys.kakao.e.first}")
private String e;
@Override
public String getKid() {
return this.kid;
}
@Override
public String getKty() {
return this.kty;
}
@Override
public String getAlg() {
return this.alg;
}
@Override
public String getUse() {
return this.use;
}
@Override
public String getN() {
return this.n;
}
@Override
public String getE() {
return this.e;
}
}
어떤 공개키를 사용하는지 알아내서 그에 맞는 클래스를 이용한다. ^_^
TokenService 를 보면 좀 더 자세히 알 수 있는데
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenServiceImpl implements TokenService {
@Value("${token.keys.google.kid.first}")
private String googleFirstKid;
@Value("${token.keys.google.kid.second}")
private String googleSecondKid;
@Value("${token.keys.kakao.kid.first}")
private String kakaoFirstKid;
@Value("${token.keys.kakao.kid.second}")
private String kakaoSecondKid;
private final KakaoFirstJwt kakaoFirstJwt;
private final KakaoSecondJwt kakaoSecondJwt;
private final GoogleFirstJwt googleFirstJwt;
private final GoogleSecondJwt googleSecondJwt;
private final TokenProvider tokenProvider;
private final TokenParser tokenParser;
@Override
public String creatToken(Long id, String nickname, RoleName name) {
return tokenProvider.createToken(id, nickname, name);
}
@Override
public String createRefreshToken() {
return tokenProvider.createRefreshToken();
}
@Override
public String reCreateToken(String token) {
Long id = tokenParser.getId(token);
String nickname = tokenParser.getNickname(token);
String role = tokenParser.getRole(token);
RoleName name = RoleName.ROLE_USER;
if (role.equals(RoleName.ROLE_ADMIN.name())) {
name = RoleName.ROLE_ADMIN;
}
return tokenProvider.createToken(id, nickname, name);
}
@Override
public boolean tokenValid(String token) {
return tokenParser.isValid(token);
}
@Override
public Long getIdByToken(String token) {
return tokenParser.getId(token);
}
@Override
public String getSnsUUID(String sns, String token) {
String kid = tokenParser.getSnsTokenSecret(token, "header", "kid");
JwtValue jwtValue = getJwtValue(sns, kid);
return tokenParser.getSnsUUID(jwtValue, token);
}
private JwtValue getJwtValue(String sns, String kid) {
if (sns.equals(Sns.KAKAO.name())) {
return getKakaoJwtValue(kid);
} else if (sns.equals(Sns.GOOGLE.name())) {
return getGoogleJwtValue(kid);
} else {
log.error("해당 SNS 찾을 수 없음 없음. : {}", sns);
throw new InternalErrorException(Exception.SERVER_ERROR);
}
}
private JwtValue getGoogleJwtValue(String kid) {
if (kid.equals(googleFirstKid)) {
return googleFirstJwt;
} else if (kid.equals(googleSecondKid)) {
return googleSecondJwt;
} else {
log.error("OAuth2 공개 키가 맞지 않음. : {}", Sns.GOOGLE);
throw new InternalErrorException(Exception.SERVER_ERROR);
}
}
private JwtValue getKakaoJwtValue(String kid) {
if (kid.equals(kakaoFirstKid)) {
return kakaoFirstJwt;
} else if (kid.equals(kakaoSecondKid)) {
return kakaoSecondJwt;
} else {
log.error("OAuth2 공개 키가 맞지 않음. : {}", Sns.KAKAO);
throw new InternalErrorException(Exception.SERVER_ERROR);
}
}
}
private JwtValue getGoogleJwtValue(String kid) {
if (kid.equals(googleFirstKid)) {
return googleFirstJwt;
} else if (kid.equals(googleSecondKid)) {
return googleSecondJwt;
} else {
log.error("OAuth2 공개 키가 맞지 않음. : {}", Sns.GOOGLE);
throw new InternalErrorException(Exception.SERVER_ERROR);
}
}
kid 값을 가져와서 첫번째 공개키의 kid 값과 동일하면 firstJwt 객체를 사용하고
두 번째 공개키와 맞으면 secondJwt 를 사용한다.
요즘 헤드퍼스트 디자인 패턴 책을 읽고 있는데 거기서 배웠던 걸 좀 이용해보았달까?^^훗
전략패턴을 사용했당.
네이버는
@Getter
public class NaverProfileApiResponse {
@JsonProperty("resultcode")
private String resultCode;
@JsonProperty("message")
private String message;
@JsonProperty("response")
private NaverProfileResponse response;
@Getter
public static class NaverProfileResponse {
@JsonProperty("id")
private String id;
@JsonProperty("email")
private String email;
@JsonProperty("mobile")
private String mobile;
@JsonProperty("mobile_e164")
private String mobileE164;
@JsonProperty("name")
private String name;
@JsonProperty("nickname")
private String nickname;
@JsonProperty("gender")
private String gender;
@JsonProperty("age")
private String age;
@JsonProperty("birthday")
private String birthday;
@JsonProperty("profile_image")
private String profileImage;
@JsonProperty("birthyear")
private String birthYear;
}
}
응답 값을 받는 클래스 !
이렇게 해두면,
리디렉션 uri가 존재할 경우 OAuth2 로그인을 성공하면?
/fanLetter/write 페이지는 권한이 필요한 페이지라서 로그인폼으로 리디렉션 됨
여기에서 sns 버튼을 클릭하여 로그인한다면?
해당 uri로 무사히 리디렉션 되는 것을 볼 수 있음 ㅎ_ㅎ
uri에 토큰 보이시죵?
여기에서 글쓰기 버튼을 누르면?
토큰이 무사히 저장되었고 글쓰기(권한 필요 페이지)도 안전하게 도착하는 것을 알 수 있다
아! 참고로 redirectUri는
각 OAuth2 설정 페이지에서 추가할 수 있는데
보안 때문인지 보통 최대 갯수가 정해져있음..(네이버 5개 제한 실화냐?ㅋㅋ)
-
길었던 OAuth2 대장정...
jwt 사용을 안 했다면 이렇게까지 걸리진 않았을 텐데....ㅋㅋ
내가 어느정도 이해하고 구현했던 거여서 더 뿌듯함^_^*
처음에 코드 써놓고 안 되는데 어디서부터 잘못된지 몰라서 헤맬 때가 제일 자괴감 들고 미치겠더라...
그래두 해냈당!!!!!!!!!!!!!!!!!!!끼욧
플젝 미완성이긴한데
로그인/회원가입/게시판은 만들었더니
그래도 슬슬 이력서라도 써보라구..주변에서 그래서
오늘부터는 이력서도 함께 써볼 예정...
넘떨린닷...ㅠㅠ내가 갈 수 있는 회사가 잇을가..?
+)
2024.02.25
오랜만에 다시 구현해보는 거라 사실 멍ㅡ청하게 까먹은 게 많았는데
블로그에 정리해둔 거 보고 복기하는 기분이라 좋았다.
그래서 새로운 버전(?)을 다시 블로그에 업데이트해둔다.
역시 늙을 수록 기록이 좋아 기록이..
'혼자서 개발새발' 카테고리의 다른 글
JWT ) 프론트 서버와 통신하는데에 사용할 JWT 인증을 구현해보자 (0) | 2023.08.18 |
---|---|
intelliJ ) POSTMAN 보다 편리하게 HTTP Test 하기 (0) | 2023.06.22 |
Pageable 로 게시판 페이징을 해야 하는데 특정 컬럼이 존재한다면 안 보여주고 싶다 (0) | 2023.05.05 |
Spring Security ) JWT 토큰 로그인 구현을 해보았다 (0) | 2023.03.25 |
thymeleaf 템플릿을 사용해서 메일로 코드 발송하기! (0) | 2023.03.12 |