Spring Security OAuth, JWT를 활용한 인증 과정 개념 및 구현 총 정리(3)-OAuth, JWT 회원가입 및 로그인 로직 구현.

taehee kim·2023년 4월 2일
0

1. OAuth application.yml 설정

spring:
  security:
    oauth2:
      client:
        registration:
          authclient:
            provider: authclient
            client-id:
            client-secret: 
            authorization-grant-type: authorization_code
            redirect-uri: https://{client 도메인}/login/oauth2/code/authclient
            scope:
              - public
              - profile
        provider:
          authclient:
            authorization-uri: https://{resource_server_domain}/oauth/authorize
            token-uri: https://{resource_server_domain}/oauth/token

            user-info-uri: https://{resource_server_domain}/v2/me
            user-name-attribute: login

provider: kakao, google등의 미리 정의된 provider가 아닌 경우 authclient로 명시하였음.
client-id: Resource Server에 등록했을 때 발급된 client-id
client-secret: 마찬가지
authorization-grant-type: Resource OWner가 승인 시 authorization code발급하는 방식으로 지정.
redirect-uri: code를 보내고 엑세스토큰을 발급받은 후 할 행동을 지정할 콜백, spring security에서는 이를 설정 시 자동으로 구현됨.
scope: 자원 범위
authorization-uri: Resource Server에게 registration.authclient의 정보를 포함하여 요청할 uri이에 요청하면 resource owner에게 authorize를 요청함.
token-uri: code를 가지고 access token을 발급받을 uri
user-info-uri: access token발급 시 Resource server에게 회원 정보 자원을 요청할 uri
user-name-attribute: 회원 정보중 user-name에 해당하는 정보

2. user-info-uri로 부터 회원정보를 가지고 회원가입 혹은 로그인 진행 후 Access token, Refresh Token발급.

WebSecurityConfigurerAdapter

  • configure 에서 Oauth2Login()에 userService, successHandler, failureHandler등록.
// spring security 필터를 스프링 필터체인에 동록
@Configuration
@EnableWebSecurity
//Secured, PrePost 어노테이션 활성화
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true, proxyTargetClass = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final DefaultOAuth2UserService oAuth2UserService;

    private final AuthenticationEntryPoint authenticationEntryPoint;
    private final ObjectMapper objectMapper;

    private final CustomAuthorizationFilter customAuthorizationFilter;

    private final AuthenticationSuccessHandler authenticationSuccessHandler;
    private final AuthenticationFailureHandler authenticationFailureHandler;
    @Value("${cors.frontend}")
    private String corsFrontend;

    @Value("${jwt.access-token-expire}")
    private String accessTokenExpire;

    @Value("${jwt.refresh-token-expire}")
    private String refreshTokenExpire;
    @Value("${jwt.secret}")
    private String jwtSecret;

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        /*
            callback(redirect) URI: /login/oauth2/code/authclient
            login URI: /oauth2/authorization/authclient - 설정을 하면 바꿀 수 있을 것 같음.
        */
        http.oauth2Login()
            .userInfoEndpoint()
            .userService(oAuth2UserService)
            .and()
            .successHandler(authenticationSuccessHandler)
            .failureHandler(authenticationFailureHandler)
            .and()
            .logout()
            .logoutRequestMatcher(new AntPathRequestMatcher("/api/security/logout"))
            .logoutSuccessHandler((request, response, authentication) -> {
                response.setStatus(HttpServletResponse.SC_OK);
            });


}

OAuth2UserService

  • OAuth2UserRequest에 Access Token을 가지고 Resource Server로 부터 받아온 유저정보가 있음 이를 통해 회원가입 혹은 로그인을 진행한다.
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private static final String ID_ATTRIBUTE = "id";
    private static final String LOGIN_ATTRIBUTE = "login";
    private static final String EMAIL_ATTRIBUTE = "email";
    private static final String IMAGE_ATTRIBUTE = "image";
    private static final String LINK_ATTRIBUTE = "link";
    private static final String CREATE_FLAG = "create_flag";

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final UserRoleRepository userRoleRepository;
    private final MemberRepository memberRepository;
    private final BCryptPasswordEncoder passwordEncoder;

    /**
     * OAuth2 Code Grant 방식으로 인증을 진행하고, 인증이 완료되고 나서 Resource Server로 부터
     * 유저 정보를 받아오면 OAuth2UserRequest에 담겨 있음.
     * 해당 유저 정보가 DB에 없으면 회원가입을 진행하고 있으면 로그인을 진행.
     * @param userRequest the user request
     * @return
     * @throws OAuth2AuthenticationException
     */
    @Transactional
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        Map<String, Object> attributes = super.loadUser(userRequest).getAttributes();
        //resource Server로 부터 받아온 정보중 필요한 정보 추출.
        String apiId = ((Integer)attributes.get(ID_ATTRIBUTE)).toString();
        //takim
        String login = (String)attributes.get(LOGIN_ATTRIBUTE);
        //takim@student.42seoul.kr
        String email = (String) attributes.get(EMAIL_ATTRIBUTE);

        String imageUrl = "";
        //https://cdn.intra.42.fr/users/09cc1ec9/takim.jpg
        if (attributes.get(IMAGE_ATTRIBUTE) instanceof Map) {
            imageUrl = (String)((Map)(attributes.get(IMAGE_ATTRIBUTE))).get(LINK_ATTRIBUTE) == null ?
                "" : (String)((Map)(attributes.get(IMAGE_ATTRIBUTE))).get(LINK_ATTRIBUTE);
        }

        HashMap<String, Object> necessaryAttributes = createNecessaryAttributes(apiId, login,
            email, imageUrl);

        String username = email;
        Optional<User> userOptional = userRepository.findByUsername(username);
        OAuth2User oAuth2User = signUpOrUpdateUser(login, email, imageUrl, username, userOptional, necessaryAttributes);
        return oAuth2User;
    }

    private HashMap<String, Object> createNecessaryAttributes(String apiId, String login, String email, String imageUrl) {
        HashMap<String, Object> necessaryAttributes = new HashMap<>();
        necessaryAttributes.put(ID_ATTRIBUTE, apiId);
        necessaryAttributes.put(LOGIN_ATTRIBUTE, login);
        necessaryAttributes.put(EMAIL_ATTRIBUTE, email);
        necessaryAttributes.put("image_url", imageUrl);
        return necessaryAttributes;
    }


    private OAuth2User signUpOrUpdateUser(String login, String email, String imageUrl, String username,
        Optional<User> userOptional, Map<String, Object> necessaryAttributes) {
        OAuth2User oAuth2User;
        User user;
        //회원가입, 중복 회원가입 예외 처리 필요할 것으로 보임.
        if (userOptional.isEmpty()) {
            //회원에 필용한 정보 생성 및 조회

            String encodedPassword = passwordEncoder.encode(UUID.randomUUID().toString());

            Member member = Member.of(login);
            memberRepository.save(member);

            Role role = roleRepository.findByValue(RoleEnum.ROLE_USER).orElseThrow(() ->
                new EntityNotFoundException(RoleEnum.ROLE_USER + "에 해당하는 Role이 없습니다."));
            user = User.of(username, encodedPassword, email, login, imageUrl, member);
            UserRole userRole = UserRole.of(role, user);

            userRepository.save(user);
            userRoleRepository.save(userRole);
            necessaryAttributes.put(CREATE_FLAG, true);
            //생성해야할 객체 추가로 더 있을 수 있음.
        } else{
            //회원정보 수정
            user = userOptional.get();
            // 새로 로그인 시 oauth2 기반 데이터로 변경하지않음.
//            user.updateUserBHOAuthIfo(imageUrl);
            necessaryAttributes.put(CREATE_FLAG, false);
        }
        oAuth2User = CustomAuthenticationPrincipal.of(user, necessaryAttributes);
        return oAuth2User;
    }

}

Success Handler

  • OAuth2UserService.loadUser를 통해 회원가입, 로그인 성공 시 Authentication이 등록되면 이정보를 활용하여 Response생성.
  • Access Token은 uri에 포함하여 리다이렉션, Cookie에 RefreshToken저장
  • Cookie는 httpOnly:true(XSS 방지), same-site:none(도메인 달라도 쿠키 생성), secure(https에만 쿠키 담김) 옵션 필수 적용.
@Slf4j
@Component
public class RedirectAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Value("${cors.frontend}")
    private String corsFrontend;

    @Value("${jwt.access-token-expire}")
    private String accessTokenExpire;

    @Value("${jwt.refresh-token-expire}")
    private String refreshTokenExpire;
    @Value("${jwt.secret}")
    private String jwtSecret;

    /**
     *     oauth 로그인 성공시 JWT Token 생성해서 리다이렉트 응답.
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
        Authentication authentication) throws IOException, ServletException {
        CustomAuthenticationPrincipal user = (CustomAuthenticationPrincipal) authentication.getPrincipal();
        String referer = corsFrontend;
        boolean createFlag = (boolean) (user.getAttributes().get("create_flag"));
        Algorithm algorithm = Algorithm.HMAC256(jwtSecret.getBytes());
        String accessToken = JWTUtil.createToken(request.getRequestURL().toString(),
            user.getUsername(), accessTokenExpire, algorithm, user.getAuthorities().stream()
                .map(SimpleGrantedAuthority::getAuthority).collect(Collectors.toList()));

        String refreshToken = JWTUtil.createToken(request.getRequestURL().toString(),
            user.getUsername(), refreshTokenExpire, algorithm);

        ResponseCookie cookie = ResponseCookie.from(JWTUtil.REFRESH_TOKEN, refreshToken)
            .httpOnly(true)
            .secure(true)
            .path("/")      // path
            .maxAge(Duration.ofDays(15))
            .sameSite("None")  // sameSite
            .build();
        response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());
        response.sendRedirect(
            referer +
                "?access_token=" + accessToken +
                "&create_flag=" + createFlag +
                "&userId=" + user.getApiId());
    }


}
public class JWTUtil {

    public static final String REFRESH_TOKEN = "refresh-token";

    public static String createToken(String requestUrl, String subject,
        String tokenExpire, Algorithm algorithm, Collection<String> authorities) {

        return JWT.create()
            .withSubject(subject)
            .withIssuer(requestUrl)
            .withExpiresAt(
                new Date(System.currentTimeMillis() + Integer.parseInt(tokenExpire)))
            .withClaim("authorities",
                new ArrayList<>(authorities))
            .sign(algorithm);
    }

    public static String createToken(String requestUrl, String subject,
        String tokenExpire, Algorithm algorithm) {

        return JWT.create()
            .withSubject(subject)
            .withIssuer(requestUrl)
            .withExpiresAt(
                new Date(System.currentTimeMillis() + Integer.parseInt(tokenExpire)))
            .sign(algorithm);
    }

    /**
     * 1. 토큰이 정상적인지 검증(위조, 만료 여부) 2. Access Token인지 Refresh Token인지 구분
     *
     * @param algorithm
     * @param token
     * @return
     * @throws JWTVerificationException
     */
    public static JWTInfo decodeToken(Algorithm algorithm, String token)
        throws JWTVerificationException {
        JWTVerifier verifier = JWT.require(algorithm).build();

        /**
         * WT 토큰 검증 실패하면 JWTVerificationException 발생
         * Throws:
         * AlgorithmMismatchException – if the algorithm stated in the token's header is not equal to the one defined in the JWTVerifier.
         * SignatureVerificationException – if the signature is invalid.
         * TokenExpiredException – if the token has expired.
         * InvalidClaimException – if a claim contained a different value than the expected one.
         */
        DecodedJWT decodedJWT = verifier.verify(token);
        String username = decodedJWT.getSubject();
        String[] authoritiesJWT = null;
        authoritiesJWT = decodedJWT.getClaim("authorities")
            .asArray(String.class);
        if (authoritiesJWT != null) {
            Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
            Arrays.stream(authoritiesJWT).forEach(authority -> {
                authorities.add(new SimpleGrantedAuthority(authority));
            });
        }

        return JWTInfo.builder()
            .username(username)
            .authorities(authoritiesJWT)
            .build();
    }

    @Getter
    @Builder
    public static class JWTInfo {

        private final String username;
        private final String[] authorities;
    }
}

3. 요청에 담겨온 JWT처리

  • JWT를 해독하고 검증하기위한 Custom Filter를 등록한다.
  • Spring Security Filter가 아니기 때문에 이를 주의한다.
  • Filter적용 위치는 Spring Security Filter chain보다 먼저 적용되도록 한다.

// spring security 필터를 스프링 필터체인에 동록
@Configuration
@EnableWebSecurity
//Secured, PrePost 어노테이션 활성화
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true, proxyTargetClass = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final DefaultOAuth2UserService oAuth2UserService;

    private final AuthenticationEntryPoint authenticationEntryPoint;
    private final ObjectMapper objectMapper;

    private final CustomAuthorizationFilter customAuthorizationFilter;

    private final AuthenticationSuccessHandler authenticationSuccessHandler;
    private final AuthenticationFailureHandler authenticationFailureHandler;
    @Value("${cors.frontend}")
    private String corsFrontend;

    @Value("${jwt.access-token-expire}")
    private String accessTokenExpire;

    @Value("${jwt.refresh-token-expire}")
    private String refreshTokenExpire;
    @Value("${jwt.secret}")
    private String jwtSecret;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.cors().configurationSource(corsConfigurationSource());
        http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint);
        http.addFilterBefore(customAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
        }

}
  • JWT만료와 JWTVerificationException을 구분하여 처리.
/**
 * 1. OncePerRequestFilter를 활용하는 이유
 * Servlet이 다른 Servler을 dispatch하는 경우 FilterChain을 여러번 거치게 되는데
 * OnceOErRequestFilter를 사용하는 경우 무조건 한번만 거치게 된다.
 * https://stackoverflow.com/questions/13152946/what-is-onceperrequestfilter
 */
@Slf4j
@Component
public class CustomAuthorizationFilter extends OncePerRequestFilter {

    @Value("${jwt.secret}")
    private String secret;

    private static final String BEARER = "Bearer ";

    /**
     * 인증 시 Authorization header에 Bearer 토큰을 담아서 보내기 때문에
     * 이를 추출하여 토큰 검증을 진행한다.
     * @param request
     * @param response
     * @param filterChain
     * @throws ServletException
     * @throws IOException
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (authorizationHeader != null && authorizationHeader.startsWith(BEARER)) {
            String token = getToken(authorizationHeader);
            Algorithm algorithm = Algorithm.HMAC256(secret.getBytes());
            JWTInfo jwtInfo = null;
            try {
                //JWT 토큰 검증 실패하면 JWTVerificationException 발생
                jwtInfo = JWTUtil.decodeToken(algorithm, token);
                log.debug(jwtInfo.toString());

                //access token이 만료된 경우 403과 응답 코드를 전달
            } catch(TokenExpiredException tokenExpiredException){
                log.debug(tokenExpiredException.getMessage());
                final ErrorResponse errorResponse = ErrorResponse.of(
                    ErrorCode.ACCESS_TOKEN_EXPIRED);
                setAccessTokenExpiredResponse(response, errorResponse);
                return ;
            } catch (JWTVerificationException jwtException) {
                log.debug("JWT Verification Failure : {}", jwtException.getMessage());
            }
            /**
             * Access Token인 경우 authorities가 존재하므로
             * SecurityContextHoler에 정보 저장.
             * SpringSecurity 에서 Authentication을 등록하지 않고 Custom Filter를 이용하여 등록해서 인지
             * 세션생성을 방지하는 옵션을 사용하였음에도 세션을 생성하여 반환함.
             * 만료된 토큰임에도 로그인이 풀리지 않아 세션을 생성하지 않도록 설정하려고 하였으나 실패함.
             * https://www.baeldung.com/spring-security-session
             * 이미 생성된 세션은 사용하지 않도록 SessionCreationPolicy NEVER->STATELESS로 변경하여 해결.
             */
            if (jwtInfo != null && jwtInfo.getAuthorities() != null) {
                SecurityContextHolder.getContext()
                    .setAuthentication(getAuthenticationTokenFromDecodedJwtInfo(jwtInfo));
            }
            //JWT 토큰이 없는 경우 일단 통과 시킴.
            //Security Filter chain에서 인증, 인가 여부가 필요한지에 따라
            //요청 처리여부가 결정됨..
        }
        //access token 만료를 제외 하면 모두 filter chain호출.
        filterChain.doFilter(request, response);
    }

    private void setAccessTokenExpiredResponse(HttpServletResponse response, ErrorResponse errorResponse)
        throws IOException {
        response.setStatus(errorResponse.getStatus());
        Map<String, String> body = new HashMap<>();
        body.put("message", errorResponse.getMessage());
        body.put("status",Integer.toString(errorResponse.getStatus()));
        body.put("code", errorResponse.getCode());
        response.setContentType(APPLICATION_JSON_VALUE);
        new ObjectMapper().writeValue(response.getOutputStream(), body);
    }

    /**
     * SecurityContextHolder에 Authentication 저장할 Authentication 정보를 저장.
     * @param jwtInfo
     */
    private UsernamePasswordAuthenticationToken getAuthenticationTokenFromDecodedJwtInfo(JWTInfo jwtInfo) {
        return  new UsernamePasswordAuthenticationToken(CustomAuthenticationPrincipal.of(
                User.of(jwtInfo.getUsername(),
                    null, null, null, null, null), null),
                null,
                Arrays.stream(jwtInfo.getAuthorities())
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList()));
    }

    private String getToken(String authorizationHeader) {
        return authorizationHeader.substring("Bearer ".length());
    }
}

4. Refresh Token으로 Access Token재발급 API

Access Token 재발급 API

  • CustomAuthorizationFilter에서 token 만료시 재발급 API작성.
    • Controller에서 Cookie값 Refresh Token 추출

      @Operation(summary = "Access_token 만료시 요청해서 재발급", description = "Access_token 만료시 요청해서 재발급")
          @GetMapping("/token/refresh")
          public AccessTokenResponse getAccessTokenUsingRefreshToken(HttpServletRequest request,
              HttpServletResponse response) {
      
              if (request.getCookies() == null){
                  throw new InvalidInputException(ErrorCode.REFRESH_TOKEN_NOT_IN_COOKIE);
              }
              String refreshToken = Arrays.stream(request.getCookies())
                  .filter(cookie -> cookie.getName().equals(JWTUtil.REFRESH_TOKEN))
                  .map(Cookie::getValue)
                  .findFirst().orElseThrow(() ->
                      new InvalidInputException(ErrorCode.REFRESH_TOKEN_NOT_IN_COOKIE));
              log.debug(refreshToken);
      
              return userService.validateRefreshTokenAndCreateAccessToken(refreshToken, request.getRequestURL().toString());
          }
  • Service
    • Refresh Token해독하고 해당 유저가 비활성화 되지 않았으면 Access Token 발급
      public AccessTokenResponse validateRefreshTokenAndCreateAccessToken(String refreshToken, String issuer) {
      
              Algorithm algorithm = Algorithm.HMAC256(jwtSecret.getBytes());
              JWTInfo jwtInfo = null;
              try {
                  //JWT 토큰 검증 실패하면 JWTVerificationException 발생
                  jwtInfo = JWTUtil.decodeToken(algorithm, refreshToken);
                  log.debug(jwtInfo.toString());
      
                  //refresh token 이 만료되었거나 적절하지 않은 경우 401
              } catch (JWTVerificationException jwtException) {
                  log.debug("JWT Verification Failure : {}", jwtException.getMessage());
                  throw new InvalidInputException(ErrorCode.INVALID_REFRESH_TOKEN);
              }
              User user = getUserFromJWTInfo(jwtInfo);
              List<String> authorities = user.getUserRoles().stream()
                  .map(UserRole::getRole)
                  .map(Role::getAuthorities)
                  .flatMap(Set::stream)
                  .map(Authority::getPermission)
                  .collect(Collectors.toList());
              String accessToken = JWTUtil.createToken(issuer, user.getUsername(), accessTokenExpire, algorithm,
                  authorities);
              return AccessTokenResponse.builder()
                  .accessToken(accessToken)
                  .build();
          }
profile
Fail Fast

0개의 댓글