OAuth + Springboot

SexyWoong·2023년 11월 17일
0

spring

목록 보기
6/11

프로젝트를 진행하면서 SpringSecurty부분을 구현하면서 팀원들과 대화가 안되어 너무 답답한 마음에 공부한것을 작성합니다.

  • 대부분 ChatGPT와 대화 형식으로 궁금한 부분들을 질문하며 공부한 내용입니다.

인증과 인가에 대한 개념은 다른 글에서 작성했으므로 생략.

OAuth 인증 인가 흐름

  1. Resource Owner(사용자) 로그인 요청: 사용자가 애플리케이션을 통해 로그인을 요청합니다.

  2. Client가 Authorization Server에 인증 요청:

  • 클라이언트(애플리케이션)는 사용자를 Authorization Server(예: Google, Facebook 등)의 인증 페이지로 리디렉션합니다.
  • 이 때, 클라이언트 ID, 리디렉션 URI, 응답 타입(response_type, 일반적으로 code), 요청 스코프(scope) 등을 전달합니다.
  1. Resource Owner의 로그인 및 권한 부여:
  • 사용자는 OAuth 제공자의 로그인 페이지에서 자신의 계정으로 로그인하고, 애플리케이션에 대한 접근 권한을 부여합니다.
  1. Authorization Server의 인증 및 권한 코드 발급:
  • 사용자의 인증이 성공하면, Authorization Server는 사용자를 리디렉션 URI로 리디렉션하며, 이때 URL에 Authorization Code가 포함됩니다.
  1. Client Server의 Access Token 요청:
  • 클라이언트 서버는 받은 Authorization Code를 사용하여 Authorization Server의 토큰 엔드포인트로 요청을 보내 Access Token을 받습니다.
  • 이 요청에는 클라이언트 ID, 클라이언트 비밀, Authorization Code, 리디렉션 URI 등이 포함됩니다.
  1. Resource Server에서 사용자 정보 요청:
  • 클라이언트 서버는 획득한 Access Token을 사용하여 Resource Server(예: Google, Facebook 등)로부터 사용자 정보를 요청합니다.
  • 이 때, Access Token은 HTTP 요청의 Authorization 헤더에 포함되어 보내집니다.
  1. 사용자 정보 수신 및 활용:
  • Resource Server는 요청을 검증하고 유효한 경우 사용자 정보를 클라이언트 서버에 전달합니다.
  • 클라이언트 서버는 이 정보를 활용하여 사용자의 세션을 생성하거나, 필요한 추가 작업을 수행합니다.
spring:
  security:
    oauth2:
      client:
        registration:
          kakao:  //OAuth2UserService.java의 registrationId에 해당
            client-id: [client-id]
            redirect-uri: http://localhost:3000/oauth/kakao
            client-authentication-method: POST
            client-secret: [secret-key]
            authorization-grant-type: authorization_code
            scope:
            - profile_nickname
            - profile_image
            - account_email
            - gender
            - birthday
            client_name: kakao
        provider:
          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

jwt:
  token:
    secret-key: [secret-key]
  access-token:
    expire-length: 1800000
  refresh-token:
    expire-length: 1209600000

위와 같이 applicayion-oauth.yaml파일을 설정하고 등록해주면 spring이 4,5,6,7번의 과정을 알아서 해준다.

authorization-uri는 사용자가 카카오 계정으로 로그인 하기와 같은 버튼을 클릭하면 보여지는 페이지이다.

token-uri는 액세스 토큰 및 리프레시 토큰을 요청할 때 사용되는 URI이다.
위 설정파일을 기준으로 로그인을 하게 되면 리디렉션된 서버인localhost:3000이 카카오의 Token Endpoint인 token-uri로 HTTP POST요청을 보내면 카카오 서버로부터 Access Token을 수신할 수 있다.

user-info-uri는 서버가 Access Token을 헤더에 담아서 사용자의 정보를 요청하는 uri이다.

Oauth2ClientProperties

Spring Security에서 OAuth2 클라이언트 설정을 관리하는 클래스이다.
application-oauth.yaml 파일에서 OAuth2 클라이언트 관련 설정을 읽고 Spring 어플리케이션에서 사용할 수 있도록 한다.

클래스 내부를 보면 public static class Registrationpublic static class Provider가 정의되어 있는것을 확인할 수 있다.

각 클래스의 필드를 보면 Registration클래스는 사용자의 정보를 저장하는 것을 알 수 있고, Provider는 OAuth 제공자에 대한 정보를 저장하는 것을 알 수 있다.

Spring Boot는 application-oauth.yaml파일을 자동으로 읽어들여 Registration객체를 생성하고 어플리케이션 내에서 OAuth2인증을 위한 클라이언트 설정을 구성한다.

OAuth2ClientRegistrationRepositoryConfiguration

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(OAuth2ClientProperties.class)
@Conditional(ClientsConfiguredCondition.class)
class OAuth2ClientRegistrationRepositoryConfiguration {

	@Bean
	@ConditionalOnMissingBean(ClientRegistrationRepository.class)
	InMemoryClientRegistrationRepository clientRegistrationRepository(OAuth2ClientProperties properties) {
		List<ClientRegistration> registrations = new ArrayList<>(
				new OAuth2ClientPropertiesMapper(properties).asClientRegistrations().values());
		return new InMemoryClientRegistrationRepository(registrations);
	}

}

OAuth2ClientProperties를 파라미터로 받아서 ClientRegistration객체를 생성한다. 이 객체는 OAuth2 클라이언트(예: 카카오)에 대한 구성 정보를 포함한다.

생성된 ClientRegistration 객체들은 InMemoryClientRegistrationRepository에 저장된다. 이 저장소는 메모리에 OAuth2 클라이언트 정보를 보관하고 애플리케이션이 실행되는 동안 사용된다.

정리

  1. application-oauth.yaml파일을 통해 OAuth2ClientProperties객체를 생성한다.
  2. OAuth2ClientRegistrationRepositoryConfiguration에서 OAuth2ClientPropertiesMapper를 통해 ClientRegistration 객체 생성
  3. ClientRegistration을 InMemoryClientRegistrationRepository에 저장.

토큰

일반적으로 Client에서 Authorization Server로 Authorization Code와 함께 Token을 요청하면 Access Token과 Refresh Token을 함께 받는다. (OAuth Server에 따라 Refresh Token을 제공하지 않을 수 있음)

  • Access Token은 유효 기간이 짧다.
  • Refresh Token은 유효기간이 비교적 길다.

흐름

  1. 초기 토큰 발급
    사용자가 처음 인증을 하면 Access Token과 Refresh Token을 발급받는다.

  2. 토큰 갱신 요청
    Access Token이 만료되었을 경우, 클라이언트는 Refresh Token을 통해 OAuth 서버에 토큰 갱신 요청을 한다.

  3. 새로운 토큰 발급
    OAuth 서버는 요청을 검증한 후, 새로운 Access Token을 발급한다. 경우에 따라 Refresh Token도 발급한다.

Access Token

  • 클라이언트가 리소스 서버에 접근할 때 사용자의 인증 및 권한을 증명하는데 사용된다.
  • 토큰은 발급시 정의한 Scope에 따라 사용된다.
  • 탈취되는것을 방지하기 위해 HTTPS와 같은 보안 프로토콜을 사용하는 것이 중요하다.
  • 인증에 성공한 클라이언트는 모든 요청의 헤더 Authorization에 Access Token을 포함시켜서 요청한다.
    • Authorization: Bearer [Access Token]
  • 서버는 받은 요청의 Authorization 헤더를 확인하여 Access Token이 유효한지 검증한 후 유효한 경우 요청을 처리한다.

Refresh Token

  • 이미 발급된 Access Token이 만료되었을 때 새로운 Access Token을 얻기 위해 사용된다.
    • 사용자가 반복적으로 로그인하는 번거로움을 줄인다.

우이삭

SecurityConfig.java

// oauth2 설정
        http
            .oauth2Login(
                loginConfigurer -> loginConfigurer
                    .userInfoEndpoint(uI -> uI.userService(oAuth2UserService))
                    .successHandler(customSuccessHandler)
            )
        ;

Spring Security의 설정 중 OAuth2.0 로그인을 구성하는 부분이다.

  • http.oauth2Login : OAuth2.0 로그인을 활성화 시킨다.

  • UserInfoEndpoint : 사용자 정보를 가져오는 엔드포인트.

    • oAuth2UserService : OAuth2.0 제공자로부터 받은 사용자 정보를 기반으로 사용자 정보 객체를 생성하거나 업데이트 한다.
    • 사용자 정보는 Authorization Server로 부터 받은 토큰을 이용하여 가져온다.
  • customSuccessHandler : 로그인 프로세스가 성공적으로 완료되었을 때 실행될 핸들러이다.

    • 사용자가 성공적으로 로그인한 후 수행할 특정한 로직을 처리한다.

OAuth2UserService.java

@Slf4j
@RequiredArgsConstructor
@Service
public class OAuth2UserService extends DefaultOAuth2UserService {

    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);  // OAuth 서비스(kakao, naver)에서 가져온 유저 정보를 담고있다.
        Map<String, Object> attributes = oAuth2User.getAttributes(); //OAuth 서비스의 유저 정보들

        String registrationId = userRequest.getClientRegistration().getRegistrationId(); // OAuth 서비스 이름 (kakao, naver)
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // OAuth 로그인 시 키(pk)가 되는 값

        OAuthAttributes oAuthAttributes = OAuthAttributes.of(registrationId, userNameAttributeName, attributes);
        Member member = saveOrUpdate(oAuthAttributes);

        return new MyOAuth2Member(Collections.singleton(new SimpleGrantedAuthority(member.getRole().getKey())), oAuthAttributes.nameAttributeKey(), member);
    }


    /*
     * 회원 가입을 하지 않은 유저의 경우 회원 가입이 되며,
     * 회원 가입을 이미 한 유저의 경우, 연령대가 업데이트 됩니다.
     */
    private Member saveOrUpdate(OAuthAttributes attributes) {
        Member member = memberRepository.findByEmail(attributes.email())
            .map(
                entity -> entity.update(attributes.ageRange())
            )
            .orElse(attributes.toEntity());

        return memberRepository.save(member);
    }

}

loadUser 메서드

호출 시점

  • 사용자가 OAuth 2.0 제공자를 통해 로그인을 시도할 때, 사용자의 인증 정보를 가져오는 과정에서 'loadUser' 메서드가 호출된다.
  • 사용자가 OAuth 제공자에게 로그인하고 권한을 부여하면 Client는 Authorization Server로 부터 Access Token을 받는다. 이 Access Token을 통해 사용자의 정보를 요청할 때 loadUser메서드가 실행된다.

역할

  • OAuth 제공자로부터 사용자의 정보를 가져온다.
  • OAuth 제공자로부터 받은 사용자의 속성을 처리하고, 애플리케이션에 필요한 형태로 변환한다.
  • 새로운 사용자라면 데이터베이스에 저장하고, 기존 사용자라면 필요한 정보를 업데이트 한다.

변수 설명

  • Map<String, Object> attributes : OAuth2.0 로그인 절차를 통해 인증된 사용자의 속성을 담은 객체이다. name, email, profile image와 같은 것들을 포함하고 있다.

  • String registrationId

spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: [YOUR_CLIENT_ID]
            client-secret: [YOUR_CLIENT_SECRET]
            ...

application-oauth.yaml파일에 위와 같이 설정해주면 kakao가 registrationId가 된다.

  • String userNameAttributeName : OAuth 2.0이나 OpenID Connect 같은 인증 프로토콜을 사용하는 클라이언트 애플리케이션에서 사용자 정보를 얻기 위해 사용되는 속성의 이름을 나타낸다. 이 속성은 사용자의 고유 식별자, 이름, 이메일 등 사용자에 대한 특정 정보를 가져오는 데 사용된다.

CustomSuccessHandler.java

package com.lovely4k.backend.authentication;

import com.lovely4k.backend.authentication.token.TokenDto;
import com.lovely4k.backend.authentication.token.TokenProvider;
import com.lovely4k.backend.couple.Couple;
import com.lovely4k.backend.couple.repository.CoupleRepository;
import com.lovely4k.backend.member.Member;
import com.lovely4k.backend.member.repository.MemberRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.util.Optional;

@Slf4j
@RequiredArgsConstructor
@Component
@Transactional
public class CustomSuccessHandler implements AuthenticationSuccessHandler {

    @Value("${love.service.redirect-url}")
    private String redirectUrl;

    private final CoupleRepository coupleRepository;
    private final MemberRepository memberRepository;
    private final TokenProvider tokenProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        log.debug("CustomSuccessHandler 호출");
        MyOAuth2Member oAuth2Member = (MyOAuth2Member) authentication.getPrincipal();
        Member member = memberRepository.findById(oAuth2Member.getMemberId()).orElseThrow();

        TokenDto tokenDto = tokenProvider.generateTokenDto(member);

        Long coupleId = oAuth2Member.getCoupleId();

        Optional<Couple> optionalCouple = coupleRepository.findDeletedById(coupleId);
        optionalCouple.ifPresentOrElse(
            couple -> {
                if (couple.isRecoupleReceiver(oAuth2Member.getMemberId())) {
                    log.debug("send code!!");
                    sendRecoupleCode(response, coupleId, tokenDto.accessToken());
                } else {
                    sendCode(response, tokenDto.accessToken());
                }
            }
            , () -> sendCode(response, tokenDto.accessToken())
        );

    }

    private void sendRecoupleCode(HttpServletResponse response, Long coupleId, String accessToken) {
        String recoupleUrl = redirectUrl + "recouple/" + coupleId;
        response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
        response.setHeader("Location", redirectUrl);
        response.setHeader("recouple-url", recoupleUrl);
        try {
            response.sendRedirect(redirectUrl + "?token=" + accessToken + "&recouple-url=" + recoupleUrl);
        } catch (IOException e) {
            throw new IllegalStateException("Something went wrong while generating response message", e);
        }
    }

    private void sendCode(HttpServletResponse response, String accessToken) {
        response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
        response.setHeader("Location", redirectUrl);
        try {
            response.sendRedirect(redirectUrl +"?token=" + accessToken);
        } catch (IOException e) {
            throw new IllegalStateException("Something went wrong while generating response message", e);
        }
    }

}
  • OAuth 인증 성공 후 사용자에 대한 커스텀 처리를 수행하는 Handler이다.
  • 사용자가 인증에 성공하면 onAuthenticationSuccess 메서드가 호출된다.
  • Authentication : 현재 인증된 사용자의 정보를 나타낸다. Principal, Credentials, Authorities등 여러 정보를 포함한다.
  • Principal : 인증된 사용자를 나타낸다. 사용자의 핵심 정보를 담고 있다. -> MyOAuth2Member
  • 인증된 사용자 정보를 통해 Access Token, Refresh Token 생성.
  • 인증된 사용자의 커플 정보를 조회
    • CoupleStatus가 Recopule이고 커플 재결합 요청자의 상대방일 경우 sendRecoupleCode메서드 호출
      • 생성한 access token과 리디렉션될 recouple uri를 쿼리 스트링으로 전달.
    • 그 외의 경우 sendCode메서드 호출
      • 생성한 access token을 쿼리 스트링으로 전달.

TokenProvider.java

package com.lovely4k.backend.authentication.token;

import com.lovely4k.backend.authentication.RefreshToken;
import com.lovely4k.backend.authentication.RefreshTokenRepository;
import com.lovely4k.backend.member.Member;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.security.Key;
import java.util.Date;
import java.util.Optional;

@Slf4j
@Component
public class TokenProvider {
    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_PREFIX = "Bearer ";
    private static final int ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            //30분
    private static final int REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;     //7일

    private final Key key;

    private final RefreshTokenRepository refreshTokenRepository;

    public TokenProvider(@Value("${jwt.secret}") String secretKey,
                         RefreshTokenRepository refreshTokenRepository) {
        this.refreshTokenRepository = refreshTokenRepository;
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    @Transactional
    public TokenDto generateTokenDto(Member member) {
        long now = (new Date().getTime());

        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
            .setSubject(member.getEmail())
            .claim(AUTHORITIES_KEY, member.getRole().toString())
            .setExpiration(accessTokenExpiresIn)
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();

        String refreshToken = Jwts.builder()
            .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();

        RefreshToken refreshTokenObject = RefreshToken.builder()
            .id(String.valueOf(member.getId()))
            .member(member)
            .keyValue(refreshToken)
            .build();
        refreshTokenRepository.save(refreshTokenObject);

        return new TokenDto(BEARER_PREFIX, accessToken, refreshToken, accessTokenExpiresIn.getTime());
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

    @Transactional(readOnly = true)
    public RefreshToken isPresentRefreshToken(Member member) {
        Optional<RefreshToken> optionalRefreshToken = refreshTokenRepository.findByMember(member);
        return optionalRefreshToken.orElse(null);
    }

}
String accessToken = Jwts.builder()
            .setSubject(member.getEmail())
            .claim(AUTHORITIES_KEY, member.getRole().toString())
            .setExpiration(accessTokenExpiresIn)
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();
  • setSubject(member.getEmail()) : 토큰의 주체를 식별하는데 사용되는 클레임을 설정. 여기서는 사용자의 Email을 클레임으로 설정하였다.
  • claim : 사용자 정의 클레임을 추가한다.
  • setExpiration : JWT의 만료시간 설정.
  • signWith : JWT생성하는 과정에서 발급받은 비밀키를 통해 HS256알고리즘을 통해 서명한다.

  • refreshTokenObject : 리프레시 토큰과 관련된 메타데이터(사용자 ID, 사용자 객체 등)를 포함하는 객체를 생성한다.
    • 애플리케이션 내에서 사용하고 관리하기 위한 객체이다.

ValidateToken 메서드

Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);

에서 JWT를 파싱하고 검증하는 과정에서 여러 단계를 거친다.

  • Claim에는 expiration time이 포함되어 있으며, 이는 토큰 만료 시간을 나타낸다.
    • 토큰 생성 시 설정해준 만료시간을 통해 만료 되었는지 비교 후 검증.
    • 유효 기간이 지난 경우 Jwts 파서는 ExpiredJwtException을 발생시킨다.

SecurityConfig.java (Filter)

http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
  • addFilterBefore: JWT FilterUsernamePasswordAuthenticationFilter 이전에 배치.
  • new JwtFilter
    • 요청에서 JWT를 추출한다.
    • JWT의 유효성을 검증한다.
    • 유효한 JWT가 있을 경우, 관련 사용자 정보를 로드하여 SecurityContext에 설정한다.

JWT Filter란?

JWT (JSON Web Token) 필터는 웹 애플리케이션에서 인증 및 권한 부여 과정을 관리하기 위해 사용되는 중요한 컴포넌트입니다. 이 필터는 들어오는 요청의 헤더에서 JWT를 추출하고 검증하여 사용자가 요청한 자원에 접근할 수 있는지 여부를 결정합니다.

목적

  • HTTP 요청이 처리되는 동안 JWT 기반 인증을 활성화하고, 인증 매커니즘을 Spring Security의 보안 체인에 통합하는 것이다. 이렇게 하면 JWT를 사용하여 인증된 사용자들만이 특정 리소스에 접근할 수 있도록 할 수 있다.

호출 시점

  • Front-end에서 서버로 HTTP 요청을 할 경우 필터 체인을 통해 순차적으로 전달된다. 즉, Front-end로 부터의 모든 HTTP 요청이 있을때.

주요 기능

  1. JWT 추출 및 파싱 : 필터는 일반적으로 HTTP 요청 헤더에서 Authorization 필드를 검사하여 JWT를 추출한다. Bearer [token] 형식으로 제공되는 경우가 많으며 필터는 이 토큰을 파싱하여 JWT로 변환한다.

  2. JWT 검증 : JWT는 디지털 서명을 통해 보호된다. 필터는 JWT의 서명을 검증하여 토큰의 무결성과 유효성을 확인한다. 서명 검증 실패시 요청은 거부된다.

  3. 클레임 처리 : JWT에는 사용자의 식별 정보 및 권한 정보(예: 사용자 ID, 역할)
    가 클레임 형태로 저장되어 있다. 필터는 이 클레임들을 읽고 처리하여 사용자의 인증 및 권한을 결정한다.

JwtFilter.java

package com.lovely4k.backend.authentication.token;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.lovely4k.backend.authentication.RefreshToken;
import com.lovely4k.backend.authentication.exception.InvalidateTokenResponseWriter;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Map;

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

    public static String AUTHORIZATION_HEADER = "Authorization";    // NOSONAR
    public static String BEARER_PREFIX = "Bearer ";     // NOSONAR
    public static String AUTHORITIES_KEY = "auth";      // NOSONAR
    private static final String REFRESH_HEADER = "Refresh-Token";
    private final TokenProvider tokenProvider;
    private final UserDetailsServiceImpl userDetailsService;
    private final ObjectMapper objectMapper;

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

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws IOException, ServletException {

        String refreshKey = resolveRefresh(request);
        if (StringUtils.hasText(refreshKey)) {
            sendAccessToken(response, refreshKey);
            return;
        }

        String jwt = resolveToken(request);

        if (StringUtils.hasText(jwt)) {
            try {
                byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
                Key key = Keys.hmacShaKeyFor(keyBytes);
                InvalidateTokenResponseWriter.write(key, jwt, response, objectMapper); //바디에 잘못된 토큰 쓰기
                log.debug("if 분기문 안 로직 ");
                Claims claims = resolveClaims(jwt); //jwt 토큰으로부터 Claim을 얻어 옴 Claim 내에는 사용자의 email이 들어있음
                updateSecurityContext(claims, jwt); //claim에 들어있는 email로 멤버를 조회해서 SecurityContext에 Member 넣기
            } catch (Exception e) {
                return; //예외는 InvalidateTokenResponseWriter.write(key, jwt, response, objectMapper); 여기서 상세 메시지 바디에 쓰고 예외 던짐
            }
        }
        filterChain.doFilter(request, response);
    }

    private void updateSecurityContext(Claims claims, String jwt) {
        String subject = claims.getSubject();
        Collection<? extends GrantedAuthority> authorities =
            Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .toList();
        log.debug("subject = " + subject);
        UserDetails principal = userDetailsService.loadUserByUsername(subject);
        log.debug("principal = " + principal);
        Authentication authentication = new UsernamePasswordAuthenticationToken(principal, jwt, authorities);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    private Claims resolveClaims(String jwt) {
        byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
        Key key = Keys.hmacShaKeyFor(keyBytes);
        Claims claims;
        try {
            claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwt).getBody();
        } catch (ExpiredJwtException e) {
            log.debug("토큰 만료 예외 clamims: {}", e.getClaims());
            claims = e.getClaims();
        }
        return claims;
    }

    private void sendAccessToken(HttpServletResponse response, String refreshKey) throws IOException {
        RefreshToken refreshToken = tokenProvider.findRefreshTokenByKeyValue(refreshKey);
        String jwt = tokenProvider.generateAccessToken(refreshToken.getMember());
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_CREATED);
        response.getWriter().println(
            objectMapper.writeValueAsString(
                Map.of("accessToken", jwt)
            )
        );
        log.debug("업데이트 된 jwt: {}", jwt);
    }

    private String resolveToken(HttpServletRequest request) {
        log.debug("인증 헤더: {}", request.getHeader(AUTHORIZATION_HEADER));
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }

    private String resolveRefresh(HttpServletRequest request) {
        log.debug("리프레시 토큰 헤더: {}", request.getHeader(REFRESH_HEADER));
        String refreshToken = request.getHeader(REFRESH_HEADER);
        if (StringUtils.hasText(refreshToken)) {
            return refreshToken;
        }
        return null;
    }
}

try {
                claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwt).getBody();
            } catch (ExpiredJwtException e) {
                claims = e.getClaims();
            }
  • ExpiredJwtException 예외가 발생할 경우, claims변수는 예외 객체에서 제공하는 claims를 담게 된다.
  • JWT가 만료되었을 경우에도 토큰이 만료되기 전까지 유효했던 JWT의 클레임들을 여전히 포함하고 있으므로 만료된 토큰의 클레임을 반환한다.
  • claims.getSubject() : JWT 클레임에서 'subject'를 추출한다. 'subject'는 일반적으로 사용자를 식별하는 고유한 값으로 설정. 여기서는 email.
  • authorities : 'auth'클레임에서 사용자의 권한 정보를 추출하고 이를 SimpleGrantedAuthority객체 컬렉션으로 변환.
  • userDetailsService.loadUserByUsername(subject): subject값을 사용하여 해당 사용자의 세부 정보를 로드한다. 사용자의 이름, 패스워드, 권한 등의 정보를 담고있는 UserDetails 객체를 반환한다.
  • Authentication authentication = new UsernamePasswordAuthenticationToken(principal, jwt, authorities); : UsernamePasswordAuthenticationToken은 인증 정보를 담는 객체이다. 사용자 세부 정보, JWT, 사용자 권한을 이용하여 객체를 생성한다.
  • SecurityContextHolder.getContext().setAuthentication(authentication); : SecurityContxtHolder의 컨텍스트에 authentication 객체를 설정한다. 이렇게 하면 현재 요청을 수행중인 사용자가 인증된 것으로 처리된다. Spring Security는 이 정보를 사용하여 사용자의 권한을 확인하고, 해당 사용자가 요청하는 리소스에 접근할 수 있는지 결정한다.

비밀키 설정

byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
Key key = Keys.hmacShaKeyFor(keyBytes);

위 코드는 JWT 생성 및 검증에 사용되는 비밀 키를 설정하는 과정이다. 이 과정은 JWT의 디지털 서명을 생성하고 검증하는 데 필요하다.

  • SECRET_KEY를 디코딩하여 바이트 배열로 변환한다. JWT의 서명에 사용되는 키는 바이트 배열로 제공되어야 한다.

  • Keys.hmacShaKeyFor(keyBytes); : 주어진 바이트 배열을 사용하여 HMAC SHA 알고리즘을 위한 Key객체를 생성한다.

위 과정을 통해 생성된 Key 객체는 JWT를 생성하거나 파싱할 때 signWith 메서드에 전달되어, 토큰의 서명 생성 및 검증에 사용된다. 예를 들어, JJWT 라이브러리를 사용하여 JWT를 생성할 때, 이 키를 사용하여 서명을 생성하고, 토큰을 검증할 때도 동일한 키를 사용하여 서명의 유효성을 확인한다.


질문

  1. tokenProvider에서 발급한 accessToken이 만료될 경우 재발급을 어떻게 하는지??
  2. refresh token이 만료될 경우에는 새로 로그인을 해야하는가?
  3. JwtFilter는 Front-end로 부터 HTTP 요청이 왔을때 예외가 발생하지 않으면 인증된 사용자로 간주하고 예외가 발생하면 인증되지 않은 사용자로 간주하는것이 주 역할인가??
profile
함께 있고 싶은 사람, 함께 일하고 싶은 개발자

0개의 댓글