Spring Security + OAuth 2.0 + JWT

아빠는 외계연·2022년 10월 5일
4

Spring Boot

목록 보기
3/4
post-thumbnail

인증 / 인가

인증(Authentication)

  • 보호된 리소스에 접근한 대상에 대해 이 유저가 누구인지 app 작업을 수행해도 되는 주체인지 확인하는 과정

인가(Authorization)

  • 해당 리소스에 대해 접근 가능한 권한을 가지고 있는지 확인하는 과정

인증관련 아키텍처(Form login)

요청 순서

Servlet Filter가 요청을 DelegatingFilterProxy로 전달 -> DelegatingFilterProxy는 해당 요청을 Spring Container에 생성된 Filter를 구현한 스프링 빈에 위임(=FilterChain Proxy)
=> DelegatingFilterProxy는 FilterChainProxy에게 요청을 위임

FilterChainProxy

  • DelegatingFilterProxy로부터 요청을 위임받고 실제로 보안을 처리
  • 스프링 시큐리티 초기화 시 생성되는 필터들을 관리 및 제어
  • 마지막 필터까지 인증 및 인가 예외처리가 발생하지 않을 경우 보안 통과

객체 설명

[AuthenticationManager]

  • 인증 요청을 받고 인증된 Authentication 객체를 돌려주는 authenticate() 메서드를 구현하도록 하는 인터페이스.
  • 인증 성공 시 isAuthenticated값이 true로 반환

[AuthenticationProvider]

  • 실제 인증이 일어나고 인증 요청 시 Authentication 객체의 authenticated = true로 설정

[Authentication]

  • 사용자의 인증정보를 저장하는 토큰 개념
  • 인증 시 id/password를 담고 인증 검증을 위해 전달되어 사용
  • 인증 후 최종 결과(User 객체)를 담고 Security Context에 저장되어 전역적으로 참조 가능
    ex) Authentication authentication = SecurityContextHolder.getContext().getAuthentication()

[ProviderManager]

  • AuthenticationManager의 구현체. AuthenticationProvider객체가 인증에 성공하면 그 결과를 알려주는 방식

[SecurityContextHolder]

  • SecurityContext 객체를 저장하고 감싸고 있는 클래스로 현재 보안 컨텍스트에 대한 세부 정보가 저장

[SecurityContext]

  • Authentication을 보관하는 역할. SecurityContext를 통해 Authentication객체를 꺼내올 수 있다.
  • ThreadLocal에 저장되어 전역적으로 참조 가능
  • 인증 완료 시 HttpSession에 저장

과정 설명

  1. 로그인 요청
  2. UserPasswordAuthenticationToken 발급
    • AuthenticationFilter에서 아이디/비밀번호를 기반으로 UserPasswordAuthentication Token 발급
      → 현상태는 미검증 Authentication
  3. UsernamePasswordToken을 Authentication Manager에게 전달
    • Authentication Manager은 실제로 인증을 처리할 여러 개의 Authentication Provider을 가짐
  4. 해당 토큰을 Authentication Provider에게 전달
    • Authentication Provider는 실제 인증의 과정을 수행
    • 실제 인증에 대한 부분 -> authentication 함수에 작성
    • username으로 조회 후 password 일치 여부를 검사하는 방식을 사용
  5. UserDetailsService로 조회할 아이디를 전달
    • UserDetailsService(인터페이스)에서 아이디를 기반으로 DB에서 데이터를 조회한다.
    • UserDetails(DB에서 인증에 사용할 사용자 정보) 객체로 전달받는다.
    • 이때 데이터가 조회되지 않았을 경우를 대비해 Exception 클래스를 추가해줘야 한다.
  6. 인증처리 후 인증된 토큰을 AuthenticationManager -> AuthenticationFilter에게 전달
  7. 인증 완료시 사용자 정보를 가진 Authentication객체를 SecurityContextHolder 객체 내부의 SecurityContext에 담은 후 AuthenticationSuccessHandler를 실행시킨다.

sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 옵션

클라이언트가 form인증방식으로 인증시도 시 UsernamePasswordAuthenticationFilter가 인증 처리를 하게 되고 앞의 방식대로 Security Context에 Authentication 객체를 저장하고 인증처리를 하게 된다.
하지만 해당 설정을 하게 될 경우 SecurityContextPersistenceFilter가 SecurityContext객체를 세션에 저장하지 않게 되기 때문에 클라이언트가 자원을 요청할 때마다 항상 새로운 SecurityContext객체를 생성하게 된다. 따라서 인증 성공 당시 SecurityContext에 저장했던 Authentication 객체를 더이상 참조할 수 없게 되어 매번 인증을 받아야 하는 상태가 된다.
이를 위해서는 JWT 토큰 내부의 사용자 정보를 JWT 필터에서 SecurityContext에 저장함으로써 인증처리를 해야한다.

Reference


OAUTH2.0

[개념]

  • 제 3의 앱(내가 개발한 앱)이 자원의 소유자인 서비스 이용자를 대신하여 서비스를 요청할 수 있도록 자원 접근 권한을 위임하는 방법. 인증을 위한 프로토콜

[용어]

Resource Owner
개인 정보의 소유자(유저)
Client
제 3의 서비스로부터 인증을 받고자 하는 서버(내가 개발한 서버)
Resource Server
개인 정보를 저장하고 있는 서버(Google, Kakao) -> 자원을 제공해준다.
Authorization Server
OAuth를 통해 인증, 인가를 제공해주는 서버 -> 토큰을 발급해준다.
=> Authorization Server을 통해 받은 토큰을 이용하여 Resource Server로부터 자원을 제공받는다.

OAuth 동작방식

프론트엔드가 웹이냐 안드로이드냐에 따라 0Auth2.0 프로토콜을 이용하는 방식에는 차이가 있다.
총 세가지 방법이 존재하는 데,

  1. [WEB] 백엔드에서 Authentication Server와 Resource Server 모두 통신하여 프론트에게 JWT 토큰만 던져주는 방식
  2. [WEB] Authentication server에서 프론트에게 바로 access token을 주는 것이 아니라 Authorization code만 넘겨주고 해당 code를 받은 백엔드가 access/refresh token을 받는 방식
  3. [ANDROID] 프론트가 Access Token까지 받은 뒤 백엔드에서 해당 토큰을 기반으로 Resource 서버와 통신하는 방식

첫번째 방식

[특징]

  • spring security의 OAuthLogin함수를 사용

SecurityConfig

.and()
.formLogin().disable()
.oauth2Login()
.authorizationEndpoint() // front -> back으로 요청 보내는 URL
.baseUri("/oauth2/authorize") // ex) /oauth2/authorize/google
.authorizationRequestRepository(cookieAuthorizationRequestRepository)

.and()
.redirectionEndpoint() //Authorization code와 함께 리다이렉트할 URL  ex) /login/oauth2/code/google
.baseUri("/login/oauth2/code/*")

.and()
.userInfoEndpoint() // Provider로부터 획득한 유저정보를 다룰 service class를 지정
.userService(customOAuth2UserService)

.and()
.successHandler(successHandler) // OAuth2 로그인 성공 시 호출되는 handler
.failureHandler(failureHandler);
http.exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)// 인증 과정에서 생길 exception 처리
                .accessDeniedHandler(jwtAccessDeniedHandler); // 인가 과정에서 생길 Exception 처리

http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); 
// UsernamePasswordFilter에서 클라이언트가 요청한 리소스의 접근 권한이 없을 때 막는 역할을 하기 때문에 이 필터 전에 jwtAuthenticationFilter실행
  • 프론트에서 API를 GET요청(authorizationEndpoint로)만 하면 백엔드에서 Authentication Server + Resource Server와의 통신 후 얻은 자원을 기반으로 JWT 토큰을 생성하여 프론트에게 던져주는 flow

CustomOAuth2UserService

package com.example.Estate_Twin.auth.service;

import com.example.Estate_Twin.auth.dto.OAuth2UserInfo;
import com.example.Estate_Twin.exception.OAuthProcessingException;
import com.example.Estate_Twin.user.domain.entity.*;
import com.example.Estate_Twin.user.domain.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.oauth2.client.userinfo.*;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import java.util.Optional;


@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
        return process(userRequest,oAuth2User);
    }
    private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
        String registrationId = userRequest.getClientRegistration().getRegistrationId().toUpperCase();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        OAuth2UserInfo attributes = OAuth2UserInfo.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
        if(attributes.getEmail().isEmpty()) {
            throw new OAuthProcessingException("Email not found from OAuth2 provider");
        }


        Optional<User> userOptional = userRepository.findByEmail(attributes.getEmail());
        User user;

        //이미 가입된 경우
        if(userOptional.isPresent()){
            user = userOptional.get();
            if(AuthProvider.valueOf(registrationId) != user.getAuthProvider()) {
                throw new OAuthProcessingException("Wrong Match Auth Provider");
            }
        } else {
            //첫 로그인인 경우
            user = createUser(attributes,AuthProvider.valueOf(registrationId));
        }
        return CustomUserDetails.create(user,oAuth2User.getAttributes());
    }

    private User createUser(OAuth2UserInfo oAuth2UserInfo, AuthProvider authProvider) {
        User user = User.builder()
                .email(oAuth2UserInfo.getEmail())
                .authProvider(authProvider)
                .role(Role.USER)
                .name(oAuth2UserInfo.getName())
                .build();
        return userRepository.save(user);
    }
}
  • loadUser()를 오버라이드 해서 OAuth2UserRequest에 있는 Access Token으로 Resource Server로부터 유저정보를 얻는다.
  • 획득한 유저정보가 DB에 존재할 시 CustomUserDetails객체만 생성하고 (-> Authentication 객체를 커스텀한 클래스), 존재하지 않을 시 User 객체도 생성한다.

[Trouble Shooting]

로컬환경에서는 정상적으로 로그인 과정이 진행되는데 서버에 올린 후에는 자꾸 authorizationEndpoint로 GET API를 날릴 때 404 Error가 발생하였다.
같은 코드인데 도대체 왜그럴까 하면서 엄청나게 삽질을 했는데, 같은 소마 동기의 도움으로 Redirect URI가 로컬로 박혀있었기 때문이라는 것을 알게 되었다~..
계속 authorizationEndpoint가 왜 404가 뜰까라고 여기에만 꽂혀서 고민을 했었는데 개발자 도구로 보니 반환하는 과정에서 에러가 난걸 볼 수 있었다..
개발자 도구의 중요성에 대해 깨달음~ + 동기의 소중함ㅎㅎ

[한계점]

  • OAuthLogin성공 시 successHandler를 호출하는 데, 이때 생성한 JWT 토큰은 프론트엔드가 지정한 RedirectUri로 전송되게 된다.
  • 안드로이드는 URI를 갖고 있지 않기 때문에 JWT 토큰 반환문제에 어려움이 생겼고,
@Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
       if(response.isCommitted()) {
           log.debug("Response has already been committed");
           return;
       }

        Token token = tokenProvider.createToken(authentication);
        writeTokenResponse(response,token);
    }

    //response에다가 token을 담아서 줌
    private void writeTokenResponse(HttpServletResponse response, Token token) throws IOException{
        response.setContentType("text/html;charset=UTF-8");
        response.addHeader("Access",token.getAccessToken());
        response.addHeader("Refresh",token.getRefreshToken());
        response.setContentType("application/json;charset=UTF-8");
        //응답 스트림에 텍스트를 기록하기 위함
        PrintWriter out = response.getWriter();
        //스트림에 텍스트를 기록
        out.println(objectMapper.writeValueAsString(token));
        out.flush();
    }

해당 방식처럼 response에 token을 담아 주려고 했으나 해당 request는 client로부터 온 것이 아니라 security 내부 로직 상의 request였기 때문에 response로 반환한다고 해서 client에게 전달되지 않았다.

두번째로는

@Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        String targetUrl = determineTargetUrl(request, response, authentication);
        if (response.isCommitted()) {
            logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);
            return;
        }
        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        Optional<String> redirectUri = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue);

        String targetUrl = "http://localhost:3000/oauth2/redirect";
        Token token = tokenProvider.createToken(authentication);
        return UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("token", token)
                .build().toUriString();
    }

이처럼 웹사이트처럼 redirectURI를 설정하여 해당 URI의 Parameter로 전달하려 했으나 이와 같이 할 경우 안드로이드 앱에서 크롬창이 열리게 되고, 해당 창에서는 아무런 정보를 얻어올 수 없는 문제를 마주했다.
여러가지 시도 끝에 해당 방법은 안드로이드 개발자와 협업 시 사용할 수 없다는 것을 알게 되었고, 한 기능을 개발하기 전 로직을 회의하는 것에 대한 중요성에 대해 깨닫게 되었다.

이 로직을 이해하는 데 꽤나 오랜 시간이 걸렸고, 이렇게 아름다운 코드를 내 포트폴리오에 추가할 수 있을 거란 기쁨에 다른 방법을 시도해야 한다는 사실을 계속 외면해 왔다. 이러한 내 욕심 때문에 프론트 개발자는 이에 억지로라도 끼워 맞추기 위해 다양한 방법을 시도했고, 불필요한 시간낭비만 이어졌다.
따라서 아무리 오랜 시간을 걸려서 이해한 로직이라도 상황에 따라 맞지 않으면 유연하게 바꿀 수 있는 마음가짐을 가져야 한다는 것을 깨달았다. 공부한 것은 나중에 언제든지 활용할 기회가 온다고 생각한다. 또한 내 의견이 무조건적으로 맞다는 생각도 버려야 하며, 남의 의견도 수용할 수 있는 자세를 가져야 한다는 자기 반성을 하게 되었다.

두번째 방식


프론트에서 Authorization Server로부터 발급받은 Authorization code를 백엔드에게 넘겨주면 이를 기반으로 백엔드에서 Authorization Server로부터 Access Token을 발급받고, 이를 기반으로 Resource Server로부터 유저 자원을 제공받는 방식이다.
해당 방식 장점은 Access Token 자체가 백엔드에만 존재하게 되므로 중간에 탈취당하는 것을 막을 수 있다.

[특징]

  • Controller의 파라미터로 Authorization Code를 받는다.
  • Authorization Server와 Resource Server와는 WebClient를 통해 통신한다.

[동작과정]

  1. Front -> Authorization Server로 로그인 요청
  2. Authorization Server -> Front로 Authorization code 발급
  3. Front -> Back으로 Authorization Code 넘기기
  4. Back -> Authorization Server로 토큰 요청
  5. Authorization Server -> Back으로 Access Token & Refresh Token 발급
  6. Back -> Resource Server로 Access Token을 이용하여 자원 요청
  7. 자원(유저정보) 응답
  8. 유저정보에 따라 JWT 토큰(Access & Refresh Token)발급
  9. Back -> Front로 유저정보 & JWT 토큰 전달

=> 해당 방식은 프론트엔드 개발자가 인가 코드만 받아올 방법이 없다고 하여 적용하지 못하게 되었다.

UserController

@Tag(name = "User", description = "유저 API")
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/user")
public class UserController {
    private final UserService userService;
    private final OAuthService oAuthService;

    @Operation(summary = "login of user", description = "로그인")
    @ApiResponses({@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = Token.class)))})
    @Parameters({@Parameter(name = "provider", description = "Name of provider", example = "kakao, naver, google")})
    @GetMapping("/login/oauth/{provider}")
    public ResponseEntity<Token> login(@PathVariable String provider, @RequestBody String code) { // 인가 코드
        Token token = oAuthService.login(provider, code);
        return ResponseEntity.status(HttpStatus.OK).body(token);
    }

}

OAuthService

@Service
@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OAuthService {
    private final InMemoryClientRegistrationRepository inMemoryRepository;
    private final UserRepository userRepository;
    private final JwtTokenProvider tokenProvider;
	
    @Transactional
    public Token login(String providerName, String code) { //로그인 로직 모두 처리하는 메서드
        ClientRegistration provider = inMemoryRepository.findByRegistrationId(providerName);

        //kakao로부터 access, refresh토큰 받아옴
        OAuth2AccessTokenResponse tokenResponse = getToken(code, provider);

        //kakao로부터 유저정보 받아서 db에 저장
        User user = getUserProfile(providerName, tokenResponse, provider);

        //jwt token 발급
        String accessToken = tokenProvider.createAccessToken(user);
        String refreshToken = tokenProvider.createRefreshToken(user);
        Token token = new Token(accessToken,refreshToken);
        return token;
    }
    
    private MultiValueMap<String, String> tokenRequest(String code, ClientRegistration provider) {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("code", code);
        formData.add("grant_type", "authorization_code");
        formData.add("redirect_uri", provider.getRedirectUri());
        formData.add("client_secret", provider.getClientSecret());
        formData.add("client_id",provider.getClientId());
        return formData;
    }
    
    //kakao로부터 access token, refresh token 전달 받음
    private OAuth2AccessTokenResponse getToken(String code, ClientRegistration provider) {
        return WebClient.create()
                .post()
                .uri(provider.getProviderDetails().getTokenUri())
                .headers(header -> {
                    header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
                    header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
                })
                .bodyValue(tokenRequest(code, provider))
                .retrieve()
                .bodyToMono(OAuth2AccessTokenResponse.class)
                .block();
    }

	//kakao로부터 User Resource를 전달받음
    private Map<String, Object> getUserAttributes(ClientRegistration provider, OAuth2AccessTokenResponse tokenResponse) {
        return WebClient.create()
                .get()
                .uri(provider.getProviderDetails().getUserInfoEndpoint().getUri())
                .headers(header -> header.setBearerAuth(tokenResponse.getAccessToken().getTokenValue()))
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
                .block();
    }

    private User createUser(OAuth2UserInfo oAuth2UserInfo, AuthProvider authProvider) {
        User user = User.builder()
                .email(oAuth2UserInfo.getEmail())
                .authProvider(authProvider)
                .role(Role.USER)
                .name(oAuth2UserInfo.getName())
                .build();
        return userRepository.save(user);
    }

    private User getUserProfile(String providerName, OAuth2AccessTokenResponse tokenResponse, ClientRegistration provider) {
        Map<String, Object> userAttributes = getUserAttributes(provider, tokenResponse);
        String userNameAttributeName = provider.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        OAuth2UserInfo attributes = OAuth2UserInfo.of(providerName.toUpperCase(), userNameAttributeName, userAttributes);
        if(attributes.getEmail().isEmpty()) {
            throw new OAuthProcessingException("Email not found from OAuth2 provider");
        }

        Optional<User> userOptional = userRepository.findByEmail(attributes.getEmail());
        User user;

        //이미 가입된 경우
        if(userOptional.isPresent()){
            user = userOptional.get();
            if(AuthProvider.valueOf(providerName) != user.getAuthProvider()) {
                throw new OAuthProcessingException("Wrong Match Auth Provider");
            }
        } else {
            //첫 로그인인 경우
            user = createUser(attributes,AuthProvider.valueOf(providerName));
        }

        CustomUserDetails.create(user,userAttributes);
        return user;
    }

}

세번째 방식

[특징]

  • 두번째 방식에서 Authorization Server와 통신하는 과정이 Front-End 측에서 처리하는 것 빼고는 모두 동일하다.

[Access Token]

  • 사용자 정보에 직접 접근할 수 있도록 해주는 정보만을 소유
  • 짧은 만료시간, 세션에 담아 관리
  • 해당 토큰이 유효한지 매번 검증 절차 필요

[Refresh Token]

  • 새로운 Access Token을 받기 위한 정보를 가짐
  • 외부에 노출되지 않도록 DB에 저장

JWT 토큰

  • 장점
    여러 서버가 돌아가는 상황(MSA 아키텍처)라면 각 서버마다 세션 저장소를 두거나 공통 세션 저장소를 둬야함 -> 비용 증가.
    사용자의 상태를 포함하는 토큰으로 구성 -> Stateless하기 때문에 확장에 용이
    해당 토큰을 가지고 있는 것만으로도 로그인을 했다는 것이 증명됨
  • 단점
    한번 발급되면 강제로 만료시킬 수 없다. -> 만료 시간이 짧은 access token과 만료시간이 긴 refresh token을 나눠서 사용
  • 특징
    access token을 요청 때마다 보내는 방식 -> 로그아웃이 되더라도 해당 access token만 가지고 있다면 로그인된 상태처럼 행동 가능
  • 과정
    사용자가 Auth Server에 인증 -> JWT 토큰 전달 받음
    서버에 리소스 요청때마다 JWT 토큰을 Authorization Header에 넣어 전달 -> 서버가 토큰을 통해 사용자 인증 가능

[JwtTokenProvider]

@Component
@Log4j2
@Configuration
@RequiredArgsConstructor
@PropertySource("classpath:application-oauth.properties")
public class JwtTokenProvider {
    @Value("${app.auth.token.secret-key}")
    private String SECRET_KEY;
    private Long ACCESS_TOKEN_EXPIRE_LENGTH = 1000L*60*60000;
    private Long REFRESH_TOKEN_EXPIRE_LENGTH = 1000L*60*60*24*7000;
    private final CustomUserDetailService userDetailsService;
    @PostConstruct
    protected void init() {
        this.SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
    }

    public String createAccessToken(User user) {
        return createToken(user, ACCESS_TOKEN_EXPIRE_LENGTH);
    }

    public String createRefreshToken(User user) {
        return createToken(user, REFRESH_TOKEN_EXPIRE_LENGTH);
    }

    public String createToken(User user, long expireLength) {
        Claims claims = Jwts.claims().setSubject(user.getEmail()); // payload부분에 들어갈 정보 조각
        claims.put("username", user.getEmail());
        Date now = new Date();
        Date validity = new Date(now.getTime() + expireLength);
        Key key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8));
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(key,SignatureAlgorithm.HS512)
                .compact();

    }

    public boolean validateToken(String token) { // 토큰 유효성 검사
        try {
            Jws<Claims> claimsJws = Jwts.parserBuilder()
                    .setSigningKey(SECRET_KEY.getBytes())
                    .build()
                    .parseClaimsJws(token);
            return !claimsJws.getBody().getExpiration().before(new Date());
        } catch (JwtException | IllegalArgumentException exception) {
            return false;
        }
    }

    public Authentication getAuthentication(String token) { // 토큰을 파싱하여 Authentication 객체 생성
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserIdentifier(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getUserIdentifier(String token){
        return Jwts.parserBuilder()
                .setSigningKey(SECRET_KEY.getBytes())
                .build()
                .parseClaimsJws(token).getBody().getSubject();
    }

    public String resolveToken(HttpServletRequest request) { // 헤더로 부터 토큰 얻어옴
        return request.getHeader("X-AUTH-TOKEN");
    }
}

[JwtAuthenticationFilter]

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest servletRequest, HttpServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {
        String token = tokenProvider.resolveToken(servletRequest);

        if (tokenProvider.validateToken(token)) {
            try {
                setAuthToSecurityContextHolder(token);
            } catch (Exception e) {
                log.error("토큰에 해당하는 사용자가 없습니다.", e);
            }
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

    private void setAuthToSecurityContextHolder(String token) {
        Authentication auth = tokenProvider.getAuthentication(token);
        SecurityContextHolder.getContext().setAuthentication(auth);
    }
}

[설명]

JWTAuthenticationFilter에서는 요청 Header에 담겨온 JWT Access Token을 validate하여 해당 토큰이 유효할 경우 토큰 내부의 사용자 정보를 기반으로 CustomUserDetails 객체를 생성하여 Security Context에 넣는다.
CustomUserDetails 객체는 DB에서 인증에 사용할 사용자 정보를 가진 UserDetails 클래스를 상속받았으며 CustomUserDetailService는 UserDetailsService를 상속받았다.
해당 클래스 내부에서 loadUserByUsername 메서드를 재정의 하였고, 필자는 identifier을 email로 설정하였기 때문에 해당 identifier을 기반으로 DB에서 사용자 정보를 가져온다.
이렇게 가져온 사용자 정보를 기반으로 UsernamePasswordAuthenticationToken을 발급받아 Authentication객체를 생성한다.


Reference

https://velog.io/@tmdgh0221/Spring-Security-%EC%99%80-OAuth-2.0-%EC%99%80-JWT-%EC%9D%98-%EC%BD%9C%EB%9D%BC%EB%B3%B4
https://europani.github.io/spring/2022/01/15/036-oauth2-jwt.html#h-oauth2--jwt-flow
https://velog.io/@do-hoon/Oauth-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-Spring-Boot-JWT-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%841
https://velog.io/@yoon_s_whan/Springboot-Oauth2-jwt-Kakao-Android

profile
Backend Developer

0개의 댓글