기존 세션 로그인 방식과 달리 토큰 로그인 방식은 백엔드 서버에서 클라이언트 로그인 상태를 세션에 저장하지 않는다. 서버는 JWT 토큰만으로 사용자 인증 상태를 알 수 있기 때문에 확장성이 좋다.
하지만 보안 측면에서 해당 토큰이 탈취되었을 경우를 대비하는 다중 토큰 발행 로직이 필요하다.
만약에 Refresh token이 탈취된다면?
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws AuthenticationException {
RequestLogin requestLogin = readByJson(request, response);
if (requestLogin == null) {
return null;
}
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(requestLogin.email(), requestLogin.password());
return authenticationManager.authenticate(authToken);
}
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmailAndIsExist(email, true).orElseThrow(
UserNotExistException::new
);
return new CustomUserDetails(user);
}
}
public class CustomUserDetails implements UserDetails {
private final User user;
public CustomUserDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add((GrantedAuthority) () -> user.getRole().name());
return collection;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
public String getId() {
return String.valueOf(user.getId());
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult
) throws IOException, ServletException {
CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal();
String id = customUserDetails.getId();
String role = customUserDetails.getAuthorities().iterator().next().getAuthority();
ResponseCreateTokens tokens = createTokens(id, role);
response.setHeader("access", tokens.accessToken());
response.addCookie(
cookieUtil.createCookie("refresh", tokens.refreshToken(),
CookieUtil.REFRESH_TOKEN_EXPIRATION_TIME));
response.setStatus(HttpStatus.OK.value());
}
http
.addFilterAt(
new LoginFilter(authenticationManager(authenticationConfiguration),
jwtUtil, refreshRepository, cookieUtil),
UsernamePasswordAuthenticationFilter.class);
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String accessToken = request.getHeader("access");
if (accessToken == null) {
filterChain.doFilter(request, response);
return;
}
if (isInvalidAccessToken(accessToken, response)) {
return;
}
setAuthenticationFromToken(jwtUtil, accessToken);
filterChain.doFilter(request, response);
}
private void setAuthenticationFromToken(JwtUtil jwtUtil, String accessToken) {
String id = jwtUtil.getId(accessToken);
String role = jwtUtil.getRole(accessToken).replace("ROLE_", "");
User user = new User(id, Role.valueOf(role));
CustomUserDetails customUserDetails = new CustomUserDetails(user);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
customUserDetails, null, customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
}
...
}
jwt 인증 성공했을 때 Security Filter Chain은 어떻게 login filter에서 attempAtthentication()을 건너뛰는 것일까?
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
try {
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
// return immediately as subclass has indicated that it hasn't completed
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
// Authentication success
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authenticationResult);
}
}
http
.addFilterBefore(
new CustomLogoutFilter(jwtUtil, refreshRepository, cookieUtil), LogoutFilter.class);
지금까지 작성한 코드 개략도를 살펴보면 아래와 같다.
http://localhost:8080/oauth2/authorization/google, 302 Found
https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=15623728038-co2cuhlfdt91missna6pvmlf56gc9fbq.apps.googleusercontent.com&scope=profile email&state=zDNWdpabKhsObwqWt81-b2DLief7xSxStXQmGD04F2Y%3D&redirect_uri=http://localhost:8080/login/oauth2/code/google
state: zDNW2pxbKhxOfwqWt81-b2DL5ef7xSwatXQmGD04FjY=
code: 4/0AeaYSHBxs5NBkt5Ze2I8Yvbv27Wnbe5n9g-EbgK82F3sBwX5mE_VO5nNAPOtfEB972HWj0A
scope: email profile https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid
authuser: 0
prompt: none
위 과정을 그림으로 나타내면 아래와 같다.
@Component
public class SocialClientRegistration {
public ClientRegistration googleClientRegistration() {
return ClientRegistration.withRegistrationId("google")
.clientId("아이디")
.clientSecret("비밀번호")
.redirectUri("http://localhost:8080/login/oauth2/code/google")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.scope("profile", "email")
.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
.tokenUri("https://www.googleapis.com/oauth2/v4/token")
.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
.issuerUri("https://accounts.google.com")
.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
.userNameAttributeName(IdTokenClaimNames.SUB)
.build();
}
}
@Configuration
public class CustomClientRegistrationRepo {
private final SocialClientRegistration socialClientRegistration;
public CustomClientRegistrationRepo(SocialClientRegistration socialClientRegistration) {
this.socialClientRegistration = socialClientRegistration;
}
public ClientRegistrationRepository clientRegistrationRepository() {
return new InMemoryClientRegistrationRepository(socialClientRegistration.naverClientRegistration(), socialClientRegistration.googleClientRegistration());
}
}
http
.oauth2Login((oauth2) -> oauth2
.clientRegistrationRepository(customClientRegistrationRepo.clientRegistrationRepository())
.userInfoEndpoint((userInfoEndpointConfig) ->
userInfoEndpointConfig.userService(customOAuth2UserService)));
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
//DefaultOAuth2UserService OAuth2UserService의 구현체
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
System.out.println(oAuth2User.getAttributes());
String registrationId = userRequest.getClientRegistration().getRegistrationId();
if (registrationId.equals("google")) {
oAuth2Response = new GoogleReponse(oAuth2User.getAttributes());
}
else {
return null;
}
***
}
}
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
//DefaultOAuth2UserService OAuth2UserService의 구현체
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
...
// DB에 소셜 로그인 유저 정보 저장하기
// DB에 유저 정보가 없다면 신규 추가, 있다면 갱신하는 로직
return new CustomOAuth2User(oAuth2Response, role);
}
}
public class CustomOAuth2User implements OAuth2User {
private final OAuth2Response oAuth2Response;
private final String role;
public CustomOAuth2User(OAuth2Response oAuth2Response, String role) {
this.oAuth2Response = oAuth2Response;
this.role = role;
}
@Override
public Map<String, Object> getAttributes() {
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return role;
}
});
return collection;
}
@Override
public String getName() {
return oAuth2Response.getName();
}
public String getUsername() {
return oAuth2Response.getProvider()+" "oAuth2Response.getProviderId();
}
}