상세 컨텐츠

본문 제목

Spring Security + JWT + OAuth2 적용하기 (4/4)

Spring/Spring Security

by Chan.94 2024. 12. 23. 08:00

본문

반응형

Intro

OAuth 개념, 동작원리, 네이버/카카오 애플리케이션 등록방법은 이전글에서 정리하였다.

 


dependency

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

 

 

 

application.yml

더보기
spring:
  security:
    oauth2:
      client:
        registration:
          naver:
            client-id: {client-id}
            client-secret: {client-secret}
            redirect-uri: https://localhost:8080/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            scope:
              - name
              - email
            client-name: Naver

          kakao:
            client-id: {client-id}
            client-secret: {client-secret}
            redirect-uri: http://localhost:8080/login/oauth2/code/kakao
            authorization-grant-type: authorization_code
            scope:
              - profile_nickname
              - account_email
            client-name: Kakao
            client-authentication-method: client_secret_post

        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response
            
          kakao:
            authorization_uri: https://kauth.kakao.com/oauth/authorize
            token_uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user_name_attribute: id

 

client - registration

  • spring.security.oauth2.client.registration.naver.client-id
    애플리케이션 등록 시 생성되는 클라이언트 ID
  • spring.security.oauth2.client.registration.naver.client-secret
    애플리케이션 등록 시 생성되는 클라이언트 비밀번호
  • spring.security.oauth2.client.registration.naver.redirect-uri
    애플리케이션 등록 시 입력한 Redirect URI
  • spring.security.oauth2.client.registration.naver.authorization-grant-type
    • 권한의 위임 방법(Grant Type) 설정
    • Authorization Code, Implicit, Client Credentials, Resource Owner Password Credentials
  • spring.security.oauth2.client.registration.naver.scope
    애플리케이션 등록 시 체크한 동의항목

SecurityConfig.java

 

앞선 Config 설정에 OAuth관련 설정을 추가한다.

protected SecurityFilterChain configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
    
        ...
        
        /*추가*/
        .oauth2Login((oauth2) -> oauth2                                
                .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint   
                    .userService(customOauth2UserService()))
                .successHandler(oAuth2AuthenticationSuccessHandler())
                .failureHandler(oAuth2AuthenticationFailureHandler()))
				
        ...
				
    return httpSecurity.build();
}
      
private CustomOauth2UserService customOauth2UserService() {
    return customOauth2UserService;
}
private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler() {
    return oAuth2AuthenticationSuccessHandler;
}
private OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler() {
    return oAuth2AuthenticationFailureHandler;
}
  • userInfoEndpoint
    • OAuth2 로그인 설정에서 사용자가 외부 OAuth2 인증 제공자(Google, GitHub, Facebook 등)로부터 인증을 받은 후, 인증된 사용자 정보를 가져오는 설정을 처리하는 부분
    • 이 설정을 통해 애플리케이션은 사용자 정보(OAuth2 User)를 가져와 애플리케이션의 인증된 사용자로 관리할 수 있다
    • userService
      OAuth2 인 제공자에 받은 사용자 정보를 처리하는 서비스를 지정한다.
      기본적으로 Spring Security는 DefaultOAuth2UserService를 사용하여 사용자 정보를 처리하지만, 필요에 따라 커스텀 OAuth2 UserService를 구현하여 사용자 정보를 변환하거나 확장할 수 있음
  • successHandler
    • 인증 성공 후 사용자가 특정 URL로 리디렉션 되도록 설정하는 기능 
    • 인증이 성공적으로 완료된 후, 사용자를 자동으로 특정 페이지로 보내는 데 사용
  • failureHandler
    • 인증 실패 후 사용자가 특정 페이지로 리디렉션 되도록 설정하는 기능
    • 로그인 또는 인증 과정에서 실패가 발생한 경우, 이 핸들러를 통해 실패 후 처리 로직을 정의하거나 실패 페이지로 리디렉션 할 수 있음

PrincipalDetails.java (OAuth2User 구현체)

더보기
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;

import com.devlog.common.entity.User;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class PrincipalDetails implements UserDetails, OAuth2User {
    private User user;
    private Map<String, Object> attributes;
    
    //일반 로그인
    public PrincipalDetails (User user) {
        this.user = user;
    }
    //OAuth 로그인
    public PrincipalDetails(User user,Map<String,Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }
    
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collectors = new ArrayList<>();
        for (String role : user.getAuthority().split(",")) {
            collectors.add(new SimpleGrantedAuthority(role));
        }
        return collectors;
    }

    
    /**
     * 계정 만료 여부 반환
     * 
     * true -> 만료되지 않았음
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 계정 잠금 여부 반환
     * 
     * true -> 잠금되지 않았음
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 패스워드의 만료 여부 반환
     * 
     * true -> 만료되지 않았음
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 계정 사용 가능 여부 반환
     * 
     * true -> 사용 가능
     */
    @Override
    public boolean isEnabled() {
        return true;
    }

    public String getAuthority() {
        return user.getAuthority();
    }

    public String getEmail() {
        return user.getEmail();
    }

    public long getUserId() {
        return user.getUserId();
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    @Override
    public String getName() {
        return null;
    }

}
  • OAuth2User 인터페이스를 상속받아 구현한 구현체이다.
    인증 후 실행되는 OAuth2UserService에서 반환되는 객체이다.

  • UserDetails와 OAuth2User 두 개의 인터페이스를 상속받도록 하였다.
    UserDetails와 OAuth2User는 Spring Security에서 사용자 인증과 관련된 두 가지 주요 인터페이스이며 서로 다른 인증 방식에서 사용된다.
    UserDetails는 기본적인 폼 로그인,  OAuth2 User는 OAuth2 기반 인증 (예: Google, Naver, Kakao 로그인)에서 사용된다.

주된 이유는 애플리케이션에서 동일한 사용자 객체를 사용하여 다양한 인증 방식을 처리할 수 있게 하기 위함이다.
따라서, 애플리케이션이 전통적인 로그인 방식과 OAuth2 로그인 방식을 모두 지원할 수 있으며, 사용자 정보를 일관되게 관리할 수 있다.
이를 통해 코드 중복을 줄이고, 사용자 관리가 간편해진다.

 


CustomOauth2UserService.java (OAuth2UserService 구현체)

더보기
import java.util.Map;
import java.util.Optional;

import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;

import com.devlog.common.entity.User;
import com.devlog.repository.UserRepository;
import com.devlog.security.PrincipalDetails;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class CustomOauth2UserService extends DefaultOAuth2UserService {
    
    private final UserRepository userRepository;
        
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        log.debug("getClientRegistration: {}", userRequest.getClientRegistration());
        log.debug("getAccessToken: {}", userRequest.getAccessToken());
        log.debug("getAttributes: {}", super.loadUser(userRequest).getAttributes());

        OAuth2User oAuth2User = super.loadUser(userRequest);

        //클라이언트 등록 ID(google, naver, kakao)
        String registrationId  = userRequest.getClientRegistration().getRegistrationId();
        
        // OAuth2User 정보로 OAuth2Attribute 객체를 만든다.
        OAuth2Attribute oAuth2Attribute = OAuth2Attribute.generate(registrationId, oAuth2User.getAttributes());

        // PrincipalDetails(OAuth2User) 객체에 넣어주기 위해서 Map으로 값들을 반환한다.
        Map<String, Object> memberAttribute = oAuth2Attribute.convertToMap();
        
        String email = (String) memberAttribute.get("email");

        Optional<User> user = userRepository.findByEmail(email);

        //이미 소셜로그인을 한적이 있는지 없는지
        if (user.isEmpty()) {
            memberAttribute.put("exist", false);
            
            //회원이 존재하지 않으므로 기본권한인 USER를 넣어준다
            User newUser = new User();
            newUser.setAuthority("USER");
            newUser.setUsername(email); //springsecurity와 동일함 username은 사용자를 식별할 값 null이면 오류남
            return new PrincipalDetails(newUser, memberAttribute);
        } else {
            memberAttribute.put("exist", true);
            return new PrincipalDetails(user.get(), memberAttribute);
            
        }
    }
}
  • loadUser 메서드는 OAuth2 인증 후 사용자 정보를 애플리케이션에서 활용할 수 있는 형태로 변환하는 중요한 역할을 한다.
  • OAuth2UserRequest
    • accessToken : OAuth2 인증 제공자로부터 받은 액세스 토큰
    • clientRegistration : OAuth2 인증 제공자에 대한 클라이언트 정보(구글, 네이버, 카카오 )
    • attributes : OAuth2 제공자와의 통신을 위한 추가적인 메타데이터
getClientRegistration: ClientRegistration{registrationId='naver', clientId='yaml에 설정한 clientId', clientSecret='yaml에 설정한 clientSecret', clientAuthenticationMethod=org.springframework.security.oauth2.core.ClientAuthenticationMethod@4fcef9d3, authorizationGrantType=org.springframework.security.oauth2.core.AuthorizationGrantType@5da5e9f3, redirectUri='http://localhost:28081/login/oauth2/code/naver', scopes=[name, email], providerDetails=org.springframework.security.oauth2.client.registration.ClientRegistration$ProviderDetails@16005d05, clientName='Naver'}
getAccessToken: org.springframework.security.oauth2.core.OAuth2AccessToken@be8051ff
getAttributes: {resultcode=00, message=success, response={id=S9nyJj7tDRSaiCfUeKt6fGCiqlDrOCmRmo3DfRpjIfs, email=xxxxxxx@naver.com, name=아무개}}

 

  • OAuth2User 구현체인 PrincipalDetails을 반환한다.
    • 반환하는 OAuth2User 객체에는 Authorities와  Username이 있어야 한다.
      Username은 SpringSecurity에 적용할 때를 생각해 보면 된다. 이름이 아닌 User를 구분할 수 있는 key값이 들어있어야 한다.

OAuth2Attribute.java

더보기
import java.util.HashMap;
import java.util.Map;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;

@Builder(access = AccessLevel.PRIVATE) // Builder 메서드를 외부에서 사용하지 않으므로, Private 제어자로 지정
@Getter
public class OAuth2Attribute {
    private Map<String, Object> attributes; // 사용자 속성 정보를 담는 Map
    private String oAuthId;                // 
    private String email;                   // 이메일 정보
    private String name;                    // 이름 정보
    private String picture;                 // 프로필 사진 정보
    private String provider;                // 제공자 정보
    
    /**
     * provider (Google, Kakao, Naver 등)에 따라 OAuth2Attribute 객체를 생성
     */
    public static OAuth2Attribute generate(String provider, Map<String, Object> attributes) {
        switch (provider) {
            case "google":
                return generateByGoogle(provider, attributes);
            case "kakao":
                return generateByKakao(provider, attributes);
            case "naver":
                return generateByNaver(provider, attributes);
                
            default: 
                throw new IllegalArgumentException("Invalid Provider Type.");
        }
    }
    
    /**
      *   Google 로그인일 경우 사용하는 메서드, 사용자 정보가 따로 Wrapping 되지 않고 제공되어, 바로 get() 메서드로 접근이 가능하다.
      **/
    private static OAuth2Attribute generateByGoogle(String provider, Map<String, Object> attributes) {
         return OAuth2Attribute.builder()
                 .email((String) attributes.get("email"))
                 .name((String) attributes.get("name"))
                 .oAuthId((String) attributes.get("sub"))
                 .provider(provider)
                 .attributes(attributes)
                 .build();
     }

     /**
       *   Kakao 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 kakaoAccount -> kakaoProfile 두번 감싸져 있어서,
       *   두번 get() 메서드를 이용해 사용자 정보를 담고있는 Map을 꺼내야한다.
       **/
     private static OAuth2Attribute generateByKakao(String provider, Map<String, Object> attributes) {
         Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
         Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");

         return OAuth2Attribute.builder()
                 .email((String) kakaoAccount.get("email"))
                 .name((String) attributes.get("name"))
                 .oAuthId((String) attributes.get("id"))
                 .provider(provider)
                 .attributes(kakaoAccount)
                 .build();
     }

     /**
       *  Naver 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 response Map에 감싸져 있어서,
       *  한번 get() 메서드를 이용해 사용자 정보를 담고있는 Map을 꺼내야한다.
       **/
     private static OAuth2Attribute generateByNaver(String provider, Map<String, Object> attributes) {
         Map<String, Object> response = (Map<String, Object>) attributes.get("response");

         return OAuth2Attribute.builder()
                 .email((String) response.get("email"))
                 .name((String) response.get("name"))
                 .oAuthId((String) response.get("id"))
                 .attributes(response)
                 .provider(provider)
                 .build();
     }


    /**
      * OAuth2User 객체에 넣어주기 위해서 Map으로 값들을 반환한다.
      **/
    public Map<String, Object> convertToMap() {
         Map<String, Object> map = new HashMap<>();
         map.put("oAuthId", oAuthId);
         map.put("email", email);
         map.put("name", name);
         map.put("provider", provider);

         return map;
     }
}
  • CustomOauth2UserService (DefaultOAuth2UserService를 상속받아 구현)에서 사용한다.
  • OAuth2 인증 제공자(Google, Naver, Kakao, …)로부터 받은 Attributes는 구조가 조금씩 상이하다.
  • Map<String, Object>은 CustomOauth2UserService.loadUser()의 반환값인 PrincipalDetails(OAuth2User)를 생성할 때 사용한다.

OAuth2AuthenticationSuccessHandler.java (AuthenticationSuccessHandler 구현체)

더보기
import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import com.devlog.common.entity.User;
import com.devlog.common.jwt.JwtProvider;
import com.devlog.mapper.UserMapper;
import com.devlog.repository.UserRepository;
import com.devlog.security.PrincipalDetails;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtProvider jwtProvider;
    private final UserRepository userRepository;
    private final UserMapper userMapper;
    private final ApplicationContext applicationContext;
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("OAuth2 Login Success");
        
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        String oAuthId = principalDetails.getAttribute("oAuthId");
        String name = principalDetails.getAttribute("name");
        String email = principalDetails.getAttribute("email");
        String provider = principalDetails.getAttribute("provider");
        
        boolean bExist = principalDetails.getAttribute("exist");

        if (bExist) {
            log.info("OAuth Authentication Success - Exist member");
        } else {
            log.info("OAuth Authentication Success - New Member");
            User newUser = principalDetails.getUser();
            newUser.setUserId(userMapper.getMaxId() + 1);
            newUser.setEmail(email);
            newUser.setPassword(passwordEncoder().encode(RandomStringUtils.randomAlphanumeric(20)));// 20자리 랜덤 문자열
            newUser.setUsername(name);
            newUser.setProviderId(oAuthId);
            newUser.setProvider(provider);

            userRepository.save(newUser);
        }
        
        String sAccessToken = jwtProvider.generateAccessToken(authentication);
        String sRefreshToken = jwtProvider.generateRefreshToken(principalDetails.getUserId());
        log.debug("oauth generate jwtToken = {}", sAccessToken);
        log.debug("oauth generate RefreshToken = {}", sRefreshToken);

        String redirectUrl = "/login-success.html?token=" + sAccessToken;
        response.sendRedirect(redirectUrl);
        response.setCharacterEncoding("UTF-8");
    }

    private PasswordEncoder passwordEncoder() {
        return (PasswordEncoder) applicationContext.getBean("passwordEncoder");
    }
}
  • Spring Security Config에서 설정한 SuccessHandler이다.
  • Authentication객체에서 PrincipalDetails(OAuth2User)를 가지고 온다.
    CustomOauth2UserService.loadUser() 메서드에서 반환했던 PrincipalDetails객체
  • 인증이 성공된 이후 로직이기 때문에 상황에 맞게 구현하면 된다.
    간단하게 Attribute에 회원유무를 체크하여 User정보를 만들고 화면 이동하는 것으로 마무리하겠다.
    OAuth2 인증의 비밀번호는 현재상태에서는 무의미하기에 랜덤으로 설정함.
  • URL로 JWT를 화면에 전달하는 방법을 사용함.
    간단히 구현하기 위해 이 방법을 사용하였지만 상황에 맞게 다른 방법을 적용하면 됨.

OAuth2AuthenticationFailureHandler.java (AuthenticationFailureHandler 구현체)

더보기
import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;

@Component
@Slf4j
public class OAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler {
    
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,AuthenticationException exception) throws IOException, ServletException {
        log.error(exception.getMessage());
        response.sendRedirect("/login.html");
    }

}
  • Spring Security Config에서 설정한 FailureHandler이다.

OAuth Login URL이 ‘{서버 경로}/oauth2/authorization/*’ 패턴 인 이유

'/oauth2/authorization/*' 패턴은 Spring Security에서 OAuth2 인증 프로세스를 시작하기 위한 표준화된 URL 패턴이다.
이 패턴을 사용함으로써 개발자는 복잡한 OAuth2 프로토콜 구현을 직접 처리할 필요 없이 Spring Security의 자동화된 기능을 활용할 수 있다.
  1. 표준화
    • Spring Security에서 기본적으로 제공하는 패턴으로, OAuth2 인증 요청의 시작점을 명확히 정의한다.
  2. 자동 구성
    • Spring Security의 자동 구성 기능이 이 패턴을 인식하여 OAuth2 관련 필터와 핸들러를 자동으로 설정한다.
  3. 다중 제공자 지원
    • '*' 부분에 각 OAuth2 제공자의 registrationId를 포함시켜 여러 OAuth2 제공자를 쉽게 지원할 수 있다.
  4. 필터 동작 트리거
    • 이 패턴으로 요청이 들어오면 OAuth2AuthorizationRequestRedirectFilter가 동작하여 OAuth2 인증 프로세스를 시작한다.

redirect-uri가 ‘{서버 경로}/login/oauth2/code/*’ 패턴 인 이유

OAuth2LoginAuthenticationFilter는 기본적으로 '/login/oauth2/code/*' 패턴의 URL만을 처리하도록 설계되어 있다.
이 필터는 OAuth2 인증 프로세스에서 중요한 역할을 한다.
Spring Security의 기본 설정을 그대로 활용할 수 있어 개발자가 복잡한 OAuth2 인증 로직을 직접 구현할 필요가 없다.

 

리다이렉트 후 Spring Security 동작 순서

  1. 리다이렉트 수신
    • OAuth2 제공자로부터 /login/oauth2/code/* 패턴의 URL로 리다이렉트된다.
    • 이 URL에는 인증 코드(Authorization Code)가 포함되어 있다.
  2. OAuth2LoginAuthenticationFilter 동작
    • OAuth2LoginAuthenticationFilter가 이를 가로챈다.
  3. 인증 코드 추출
    • 리다이렉트 URL에서 인증 코드(Authorization Code)를 추출한다.
  4. OAuth2LoginAuthenticationToken 생성
    • 추출된 인증 코드로 OAuth2LoginAuthenticationToken을 생성한다.
  5. AuthenticationManager 위임
    • 생성된 토큰을 AuthenticationManager에 전달하여 인증 처리를 위임한다.
    • AuthenticationManager는 적절한 AuthenticationProvider를 찾아 인증을 위임한다. (OAuth2LoginAuthenticationProvider 사용)
  6. OAuth2LoginAuthenticationProvider 실행
    • OAuth2LoginAuthenticationToken을 처리한다.
    • 인증 코드를 사용하여 액세스 토큰을 요청한다.
  7. 액세스 토큰 교환
    • OAuth2 제공자에게 인증 코드를 전달하고 액세스 토큰을 받아온다.
  8. OAuth2UserService 호출
    • DefaultOAuth2UserService의 loadUser() 메서드가 호출한다.
    • 사용자 정보를 바탕으로 OAuth2User 객체를 생성한다.
  9. 사용자 정보 요청
    • 받은 액세스 토큰으로 OAuth2 제공자의 사용자 정보 엔드포인트에 요청을 보낸다.
    • 이 과정은 DefaultOAuth2UserService의 loadUser() 메서드 내에서 이루어진다.
  10. Authentication 객체 생성
    • OAuth2LoginAuthenticationProvider가 OAuth2User 정보로 OAuth2AuthenticationToken을 생성한다.
    • 이 토큰에는 인증된 사용자 정보, 액세스 토큰, 리프레시 토큰 등이 포함된다.
  11. SecurityContext 업데이트
    • 생성된 Authentication 객체를 SecurityContextHolder에 저장한다.
  12. AuthenticationSuccessHandler 실행
    • 기본적으로 SimpleUrlAuthenticationSuccessHandler가 실행된다.
    • 설정된 대상 URL로 사용자를 리다이렉트한다.

OAuth 이전글

Spring Security, JWT 이전글

 

반응형

관련글 더보기

댓글 영역

>