Spring Boot

[Spring] Spring security JWT 구현하기

HEY__ 2024. 7. 10. 01:05
728x90

이 글은 공부를 하면서 알게 된 내용들을 기록하는 글 입니다. 오류나 고쳐야 할 사항들이 있다면 지적 부탁드립니다!

0. JWT

JWT는 토큰 인증 방식의 한 가지 종류로서, 클라이언트가 토큰을 가지고 있다가 API 요청을 할 때 JWT(Access token, Refresh token)을 전달하여 인증 & 인가를 진행하는 방식을 이야기 한다.

 

JWT 구현 코드를 설명하기 전에, JWT 구현 방식은 내부 로직을 어떻게 구현하냐에 따라서 달라진다.

눈에 여겨볼만한 특징 몇 가지만 짚고 넘어가자.

 

1️⃣ Access token, Refresh token 두 개의 토큰을 사용한다. Access token을 통해 인가를 진행하며, Refresh token을 통해 Access token을 재발급한다.

2️⃣ Refresh token의 경우 탈취에 대비하여 RTR 전략을 채택한다. Access token을 재발급할 때 Refresh token 또한 재발급한다.

 


1. 프로젝트 환경

🔥 Spring boot, Spring security 

Spring boot 버전: 3.2.1

Spring security 버전: 6.2.1

 

Spring security의 버전이 6으로 올라가면서, 기존에 있던 method chaining 방식은 deprecated되고, lambda 방식이 새로 들어왔다.

SecurityConfig의 `filterChain()`에서 많이 바뀐 것을 느낄 수 있다.

 

🔥 build.gradle 의존성 추가

build.gradle에 Spring security와 JWT와 관련된 의존성을 추가한다.

// Spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

//JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

2. JWT 처리 흐름

🔥 초기 JWT 발급 과정

 

🔥 JWT 발급 이후 

 


3. 코드

🔥 JWT와 관련된 상수 클래스

1️⃣ JwtRule Enum

@RequiredArgsConstructor
@Getter
public enum JwtRule {
    JWT_ISSUE_HEADER("Set-Cookie"),
    JWT_RESOLVE_HEADER("Cookie"),
    ACCESS_PREFIX("access"),
    REFRESH_PREFIX("refresh");

    private final String value;
}

 

2️⃣ TokenStatus

@RequiredArgsConstructor
@Getter
public enum TokenStatus {
    AUTHENTICATED,
    EXPIRED,
    INVALID
}

 


🔥 JwtGenerator

JWT를 생성하는 유틸성 클래스이다.

`Header`와 `Claims`에 들어가야할 정보를 받아서, JWT를 build해서 반환하는 코드를 가지고 있다.

Access 와 Refresh의 시크릿 키, 유효 기간은 노출되면 안되기 때문에 parameter로 전달받아 값을 사용하며, 전달받은 값에 변화가 일어나면 안되므로 `final` 변수로 설정한다.

 

Access token의 경우 사용자의 정보를 담아야하기 때문에 `claims`에 사용자를 식별할 수 있는 `identifier`와 사용자의 권한을 확인 할 수 있는 `role`를 담는다.

 

Refresh token의 경우 인가를 위한 수단이 아니고, Access token을 재발급 받기 위한 수단이므로 claims에 별 다른 값을 넣어주지 않고 생성한다.

@Component
public class JwtGenerator {

    public String generateAccessToken(final Key ACCESS_SECRET, final long ACCESS_EXPIRATION, User user) {
        Long now = System.currentTimeMillis();

        return Jwts.builder()
                .setHeader(createHeader())
                .setClaims(createClaims(user))
                .setSubject(String.valueOf(user.getId()))
                .setExpiration(new Date(now + ACCESS_EXPIRATION))
                .signWith(ACCESS_SECRET, SignatureAlgorithm.HS256)
                .compact();
    }

    public String generateRefreshToken(final Key REFRESH_SECRET, final long REFRESH_EXPIRATION, User user) {
        Long now = System.currentTimeMillis();

        return Jwts.builder()
                .setHeader(createHeader())
                .setSubject(user.getIdentifier())
                .setExpiration(new Date(now + REFRESH_EXPIRATION))
                .signWith(REFRESH_SECRET, SignatureAlgorithm.HS256)
                .compact();
    }

    private Map<String, Object> createHeader() {
        Map<String, Object> header = new HashMap<>();
        header.put("typ", "JWT");
        header.put("alg", "HS512");
        return header;
    }

    private Map<String, Object> createClaims(User user) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("Identifier", user.getIdentifier());
        claims.put("Role", user.getRole());
        return claims;
    }
}

🔥 JwtUtil

JWT를 활용하는데에 필요한 메서드들을 한 데에 모아놓은 클래스이다.

1️⃣ `getTokenStatus()`

검사하고자하는 token과 secretKey를 전달받아, 해당 토큰의 유효 기간이 지나지 않았고 & 유효한지 여부를 파악한다.

토큰의 시크릿 키, 구조가 유효하고 유효 기간이 지나지 않았다면 AUTHENTICATED를 반환하게 하고,

토큰의 시크릿 키, 구조가 유효하나 유효 기간이 지났다면 EXPIRED를 반환한다.

이 과정에서 토큰 자체가 유효하지 않다면(ex: secret key 유효하지 않음, 구조 자체가 유효하지 않음...) BusinessException이 발생한다.

 

2️⃣ `resolveTokenFromCookie()`

Cookie에서 원하는 토큰을 찾는 역할을 한다. 쿠키의 제목 값을 통해 원하는 쿠키를 찾는다.

 

3️⃣ `getSigningKey()`

특정 토큰의 시크릿 키로 사용하고자할 때, Keys 클래스의 정적 메서드를 이용하여 Key 객체를 반환한다.

 

4️⃣ `resetToken()`

Cookie에서 원하는 토큰을 reset하는 기능이다.

 

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class JwtUtil {

    public TokenStatus getTokenStatus(String token, Key secretKey) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(secretKey)
                    .build()
                    .parseClaimsJws(token);
            return TokenStatus.AUTHENTICATED;
        } catch (ExpiredJwtException | IllegalArgumentException e) {
            log.error(INVALID_EXPIRED_JWT.getMessage());
            return TokenStatus.EXPIRED;
        } catch (JwtException e) {
            throw new BusinessException(INVALID_JWT);
        }
    }

    public String resolveTokenFromCookie(Cookie[] cookies, JwtRule tokenPrefix) {
        return Arrays.stream(cookies)
                .filter(cookie -> cookie.getName().equals(tokenPrefix.getValue()))
                .findFirst()
                .map(Cookie::getValue)
                .orElse("");
    }

    public Key getSigningKey(String secretKey) {
        String encodedKey = encodeToBase64(secretKey);
        return Keys.hmacShaKeyFor(encodedKey.getBytes(StandardCharsets.UTF_8));
    }

    private String encodeToBase64(String secretKey) {
        return Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    public Cookie resetToken(JwtRule tokenPrefix) {
        Cookie cookie = new Cookie(tokenPrefix.getValue(), null);
        cookie.setMaxAge(0);
        cookie.setPath("/");
        return cookie;
    }
}

 

JwtUtil 클래스는 비지니스 로직과 관련이 있는 것이 아니라, JWT를 다루면서 필요한 여러가지 메서드들을 구현해놓은 곳이라고 보면 된다.


🔥 UserPrincipal

Spring security에서는 `SecurityContextHolder`에 `Authentication` 객체를 넣어 관리한다.

`SecurityContext.Holder.getContext().getAuthentication()`을 통해 해당 객체를 다시 받아올 수도 있다.

Authentication 인터페이스에는 `getPrincipal()` 메서드를 통해 인증과 관련된 정보들이 담긴 객체를 받아올 수 있으며, `UserDetails `타입의 객체가 여기에 해당한다.

 

`UserDetails`는 Spring security에서 기본으로 제공하는 인터페이스로서, 인증과 관련된 사용자 정보를 추상화한 인터페이스이다. 

 

🧐 `authorities` 변수에는 어떤 값들이 들어가나요?

Spring security에서는 내부적으로 권한을 설정할 때 `ROLE_` 접두사를 붙여 사용한다. 그렇기 때문에 "ROLE_USER", "ROLE_ADMIN"과 같이 설정하면, Spring security에서는 USER, ADMIN으로 인식한다.

 

UserPrincipal 내에 있는 User 클래스는 Spring security에서 제공하는 User 클래스가 아닌, 직접 생성한 클래스이다.

User 클래스를 작성할 때 Role enum 클래스를 생성하고, ROLE_USER와 같은 형식으로 값을 저장하게 했다.

@Getter
@RequiredArgsConstructor
public enum Role {
    NOT_REGISTERED("ROLE_NOT_REGISTERED", "회원가입 이전 사용자"),
    USER("ROLE_USER", "일반 사용자"),
    ADMIN("ROLE_ADMIN", "관리자");

    private final String key;
    private final String title;
}

`authorities`에는 `Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getKey()))`를 넣어주면 처리가 될 것이다.

 

반드시 위와 같이 구현하지 않아도, `ROLE_` 접두사가 붙은 값이 들어가게 구현하면 된다.

 

 

⚠️ GitGet 서비스에서는 OAuth와 JWT를 적용했기 때문에, `UserDetails`와 `OAuth2User` 두 인터페이스를 상속받아 `UserPrincipal`에서 한 번에 관리하고 있습니다.
JWT만 적용한다면 `UserDetails` 인터페이스만 상속받은 후, UserDetails method implements에 해당하는 메서드들만 구현해도 무방합니다 :)
@Getter
public class UserPrincipal implements UserDetails, OAuth2User {

    private User user;
    private String nameAttributeKey; // for OAuth
    private Map<String, Object> attributes; // for OAuth
    private Collection<? extends GrantedAuthority> authorities;

    public UserPrincipal(User user) {
        this.user = user;
        this.authorities = Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getKey()));
    }

    public UserPrincipal(User user, Map<String, Object> attributes, String nameAttributeKey) {
        this.user = user;
        this.authorities = Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getKey()));
        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
    }

    /**
     * OAuth2User method implements
     */
    @Override
    public String getName() {
        return user.getIdentifier();
    }

    /**
     * UserDetails method implements
     */
    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        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;
    }
}

 

UserDetails 인터페이스를 상속받으면, 구현해야 하는 메서드가 총 7개이다.

하지만 `getAuthorities()` 메서드가 빠져 총 6개의 메서드들이 있음을 확인할 수 있다.

 

이는 `private Collection<? extends GrantedAuthority> authorities` 변수를 따로 선언한 이후, `@Getter` 어노테이션을 통해 자동으로 구현이 되었기 때문이다.

 

만일 `authorities` 변수를 따로 선언하지 않고 싶다면, 밑과 같이 내용을 구현해주면 된다.

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    return Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getKey()));
}

 


🔥 CustomUserDetailService

Spring security의 Filter chain 과정에서 `UserDetailsService`의 `loadUserByUsername()`을 호출하여 DB에 저장되어 있던 사용자 정보를 가져온다.

그리고 해당 정보를 통해 `UserDetails` 객체로 변환하고 반환한다.

 

`UserDetails` 인터페이스를 구현한 `UserPrincipal` 클래스에 `User` 객체를 받는 생성자를 만들었기 때문에, DB에서 받아온 user를 생성자 파라미터로 넘겨 `UserPrincipal` 객체를 반환하도록 한다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findById(Long.valueOf(username))
                .orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND));

        return new UserPrincipal(user);
    }
}

 

 


🔥 JwtService

JWT와 관련된 제일 핵심 로직들이 이 클래스에서 구현된다. 

 

1️⃣ yml 파일로부터 Access token과 Refresh token에서 사용할 `secret key(시크릿 키)`, `expiration(유효 기간)` 값을 읽어와 final 변수에 저장

JWT 토큰 생성에 필요한 secret key와 expiration은 노출이 되면 안되는 사항이기 때문에 yml 파일에 값을 저장하고, `@Value` 어노테이션을 통해 불러오는 것이 바람직하다.

 

밑과 같이 yml에 시크릿 키와 유효기간을 설정한다. 

(시크릿 키의 경우 당연히 밑과 같이 작성하면 안되고, 유추하기 힘든 값으로 설정해야 한다. 유효기간의 경우 주석으로 계산하는 식을 적어놨으니 원하는 값을 넣어주면 된다.)

jwt:
  access-secret: secretkeysecretkeysecretkeysecretkeysecretkeysecretkey
  refresh-secret: secretkeysecretkeysecretkeysecretkeysecretkeysecretkeysecretkey
  access-expiration: 600000  # 10 min: 10min * 60 sec * 1000 millisecond
  refresh-expiration: 10800000  # 10800000 3 hours: 3hours * 60min * 60sec * 1000 millisecond

 

@Service
@Transactional(readOnly = true)
@Slf4j
public class JwtService {
    private final CustomUserDetailsService customUserDetailsService;
    private final JwtGenerator jwtGenerator;
    private final JwtUtil jwtUtil;
    private final TokenRepository tokenRepository;

    private final Key ACCESS_SECRET_KEY;
    private final Key REFRESH_SECRET_KEY;
    private final long ACCESS_EXPIRATION;
    private final long REFRESH_EXPIRATION;

    public JwtService(CustomUserDetailsService customUserDetailsService, JwtGenerator jwtGenerator,
                      JwtUtil jwtUtil, TokenRepository tokenRepository,
                      @Value("${jwt.access-secret}") String ACCESS_SECRET_KEY,
                      @Value("${jwt.refresh-secret}") String REFRESH_SECRET_KEY,
                      @Value("${jwt.access-expiration}") long ACCESS_EXPIRATION,
                      @Value("${jwt.refresh-expiration}") long REFRESH_EXPIRATION) {
        this.customUserDetailsService = customUserDetailsService;
        this.jwtGenerator = jwtGenerator;
        this.jwtUtil = jwtUtil;
        this.tokenRepository = tokenRepository;
        this.ACCESS_SECRET_KEY = jwtUtil.getSigningKey(ACCESS_SECRET_KEY);
        this.REFRESH_SECRET_KEY = jwtUtil.getSigningKey(REFRESH_SECRET_KEY);
        this.ACCESS_EXPIRATION = ACCESS_EXPIRATION;
        this.REFRESH_EXPIRATION = REFRESH_EXPIRATION;
    }
    ...

 

 

2️⃣ `JwtGenerator`를 통해 Access token, Refresh token을 생성하고, Cookie에 설정

Access token, Refresh token 모두 `JwtGenerator`를 통해 JWT를 생성하고 → Cookie에 저장하는 것까지는 동일하다.

 

다만, Refresh token의 경우 `RTR`을 채용하고 있기 때문에 제일 최근에 발급된 Refresh token을 저장해두어야 한다.

따라서 NoSQL을 구현한 `tokenRepository`에 `사용자 식별자 - Refresh token`을 쌍으로 저장하도록 한다.

 

한 가지 유의해야 할 점이 바로 Cookie의 `sameSite` 옵션인데 sameSite가 `Lax` 혹은 `Strict`이고, 요청&응답하는 도메인이 일치하지 않는다면 Cookie가 전달되지 않는 문제가 발생할 수 있다.

예를 들어 로컬에서는 http://localhost 로 도메인이 동일(포트 번호는 상관X)해서 Lax 또는 Strict 여도 상관이 없지만,
프론트와 백 모두 배포를 진행하며 프론트의 경우 http://gitget-project.vercel-app를 사용하고,  백의 경우 http://gitget.co.kr를 사용한다면 도메인이 서로 맞지 않아 Cookie가 전달되지 않는다.

 

서로 도메인이 맞지 않는 경우에는 1) 도메인을 적용하여 서로 도메인을 맞춘다   2) sameSite=None, secure=true로 설정한다

위와 같이 두 가지 방법이 있으며, 되도록이면 2번보다는 1번을 적용하는 것을 권장한다. (CSRF 공격과 같이 보안과 관련되어 있음!)

    public String generateAccessToken(HttpServletResponse response, User requestUser) {
        String accessToken = jwtGenerator.generateAccessToken(ACCESS_SECRET_KEY, ACCESS_EXPIRATION, requestUser);
        ResponseCookie cookie = setTokenToCookie(ACCESS_PREFIX.getValue(), accessToken, ACCESS_EXPIRATION / 1000);
        response.addHeader(JWT_ISSUE_HEADER.getValue(), cookie.toString());

        return accessToken;
    }

    @Transactional
    public String generateRefreshToken(HttpServletResponse response, User requestUser) {
        String refreshToken = jwtGenerator.generateRefreshToken(REFRESH_SECRET_KEY, REFRESH_EXPIRATION, requestUser);
        ResponseCookie cookie = setTokenToCookie(REFRESH_PREFIX.getValue(), refreshToken, REFRESH_EXPIRATION / 1000);
        response.addHeader(JWT_ISSUE_HEADER.getValue(), cookie.toString());

        tokenRepository.save(new Token(requestUser.getIdentifier(), refreshToken));
        return refreshToken;
    }

    private ResponseCookie setTokenToCookie(String tokenPrefix, String token, long maxAgeSeconds) {
        return ResponseCookie.from(tokenPrefix, token)
                .path("/")
                .maxAge(maxAgeSeconds)
                .httpOnly(true)
                .sameSite("Lax")
                .secure(true)
                .build();
    }

 

 

3️⃣ `JwtUtil`을 통해 Cookie에서 원하는 토큰 추출

 

    public String resolveTokenFromCookie(HttpServletRequest request, JwtRule tokenPrefix) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            throw new BusinessException(JWT_TOKEN_NOT_FOUND);
        }
        return jwtUtil.resolveTokenFromCookie(cookies, tokenPrefix);
    }

 

 

4️⃣ `JwtUtil`을 통해 전달받은 토큰이 유효한지 여부 확인

`jwtUtil.getTokenStatus()`를 통해 전달 받은 token이 유효한지 확인한다.

Refresh token의 경우 RTR을 사용하고 있으므로, tokenRepository를 통해 유효한 Refresh token인지 확인하는 과정이 추가된다.

    public boolean validateAccessToken(String token) {
        return jwtUtil.getTokenStatus(token, ACCESS_SECRET_KEY) == TokenStatus.AUTHENTICATED;
    }

    public boolean validateRefreshToken(String token, String identifier) {
        boolean isRefreshValid = jwtUtil.getTokenStatus(token, REFRESH_SECRET_KEY) == TokenStatus.AUTHENTICATED;

        Token storedToken = tokenRepository.findByIdentifier(identifier);
        boolean isTokenMatched = storedToken.getToken().equals(token);

        return isRefreshValid && isTokenMatched;
    }

 

 

 

 

5️⃣ Spring SecurityContextHolder에 저장할 Authentication 생성

Spring security Filter chain을 거치면서 SecurityContextHolder에 Authentication 객체를 저장한다.

 

우선 전달받은 토큰(Access token)에서 사용자를 식별할 수 있는 데이터를 찾는다.

이후 위에서 구현한 `CustomUserDetailsService`의 `loadUserByUsername`을 통해 UserDetails 타입의 객체를 반환받는다.

다음 필터인 `UsernamePasswordAuthenticationFilter`에서 사용할 `UsernamePasswordAuethenticationToken`을 생성하여 반환한다.

(해당 클래스는 Authentication 인터페이스를 상속받아 구현한 클래스이다)

 

    public Authentication getAuthentication(String token) {
        UserDetails principal = customUserDetailsService.loadUserByUsername(getUserPk(token, ACCESS_SECRET_KEY));
        return new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities());
    }

    private String getUserPk(String token, Key secretKey) {
        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

 

 

 

[전체 코드]

@Service
@Transactional(readOnly = true)
@Slf4j
public class JwtService {
    private final CustomUserDetailsService customUserDetailsService;
    private final JwtGenerator jwtGenerator;
    private final JwtUtil jwtUtil;
    private final TokenRepository tokenRepository;

    private final Key ACCESS_SECRET_KEY;
    private final Key REFRESH_SECRET_KEY;
    private final long ACCESS_EXPIRATION;
    private final long REFRESH_EXPIRATION;

    public JwtService(CustomUserDetailsService customUserDetailsService, JwtGenerator jwtGenerator,
                      JwtUtil jwtUtil, TokenRepository tokenRepository,
                      @Value("${jwt.access-secret}") String ACCESS_SECRET_KEY,
                      @Value("${jwt.refresh-secret}") String REFRESH_SECRET_KEY,
                      @Value("${jwt.access-expiration}") long ACCESS_EXPIRATION,
                      @Value("${jwt.refresh-expiration}") long REFRESH_EXPIRATION) {
        this.customUserDetailsService = customUserDetailsService;
        this.jwtGenerator = jwtGenerator;
        this.jwtUtil = jwtUtil;
        this.tokenRepository = tokenRepository;
        this.ACCESS_SECRET_KEY = jwtUtil.getSigningKey(ACCESS_SECRET_KEY);
        this.REFRESH_SECRET_KEY = jwtUtil.getSigningKey(REFRESH_SECRET_KEY);
        this.ACCESS_EXPIRATION = ACCESS_EXPIRATION;
        this.REFRESH_EXPIRATION = REFRESH_EXPIRATION;
    }

    public void validateUser(User requestUser) {
        if (requestUser.getRole() == Role.NOT_REGISTERED) {
            throw new BusinessException(NOT_AUTHENTICATED_USER);
        }
    }

    public String generateAccessToken(HttpServletResponse response, User requestUser) {
        String accessToken = jwtGenerator.generateAccessToken(ACCESS_SECRET_KEY, ACCESS_EXPIRATION, requestUser);
        ResponseCookie cookie = setTokenToCookie(ACCESS_PREFIX.getValue(), accessToken, ACCESS_EXPIRATION / 1000);
        response.addHeader(JWT_ISSUE_HEADER.getValue(), cookie.toString());

        return accessToken;
    }

    @Transactional
    public String generateRefreshToken(HttpServletResponse response, User requestUser) {
        String refreshToken = jwtGenerator.generateRefreshToken(REFRESH_SECRET_KEY, REFRESH_EXPIRATION, requestUser);
        ResponseCookie cookie = setTokenToCookie(REFRESH_PREFIX.getValue(), refreshToken, REFRESH_EXPIRATION / 1000);
        response.addHeader(JWT_ISSUE_HEADER.getValue(), cookie.toString());

        tokenRepository.save(new Token(requestUser.getIdentifier(), refreshToken));
        return refreshToken;
    }

    private ResponseCookie setTokenToCookie(String tokenPrefix, String token, long maxAgeSeconds) {
        return ResponseCookie.from(tokenPrefix, token)
                .path("/")
                .maxAge(maxAgeSeconds)
                .httpOnly(true)
                .sameSite("None")
                .secure(true)
                .build();
    }

    public boolean validateAccessToken(String token) {
        return jwtUtil.getTokenStatus(token, ACCESS_SECRET_KEY) == TokenStatus.AUTHENTICATED;
    }

    public boolean validateRefreshToken(String token, String identifier) {
        boolean isRefreshValid = jwtUtil.getTokenStatus(token, REFRESH_SECRET_KEY) == TokenStatus.AUTHENTICATED;

        Token storedToken = tokenRepository.findByIdentifier(identifier);
        boolean isTokenMatched = storedToken.getToken().equals(token);

        return isRefreshValid && isTokenMatched;
    }

    public String resolveTokenFromCookie(HttpServletRequest request, JwtRule tokenPrefix) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            throw new BusinessException(JWT_TOKEN_NOT_FOUND);
        }
        return jwtUtil.resolveTokenFromCookie(cookies, tokenPrefix);
    }

    public Authentication getAuthentication(String token) {
        UserDetails principal = customUserDetailsService.loadUserByUsername(getUserPk(token, ACCESS_SECRET_KEY));
        return new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities());
    }

    private String getUserPk(String token, Key secretKey) {
        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public String getIdentifierFromRefresh(String refreshToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(REFRESH_SECRET_KEY)
                    .build()
                    .parseClaimsJws(refreshToken)
                    .getBody()
                    .getSubject();
        } catch (Exception e) {
            throw new BusinessException(ErrorCode.INVALID_JWT);
        }
    }

    public void logout(User requestUser, HttpServletResponse response) {
        tokenRepository.deleteById(requestUser.getIdentifier());

        Cookie accessCookie = jwtUtil.resetToken(ACCESS_PREFIX);
        Cookie refreshCookie = jwtUtil.resetToken(REFRESH_PREFIX);

        response.addCookie(accessCookie);
        response.addCookie(refreshCookie);
    }
}

🔥 JwtAuthenticationFilter 🔥

JWT 발급 이후, API를 요청할 때마다 처리하는 부분이 바로 `JwtAuthenticationFilter`이다.

`OncePerRequestFilter`를 상속받아 `doFilterInternal()`을 구현한 후, `SecurityConfig`에서 필터의 위치를 지정해주면 Filter chain에 포함되게 된다.

 

1️⃣ API endpoint가 JWT 없이도 요청이 가능하다면 JWT와 관련된 별 다른 처리 없이 `doFilter()` 호출

        if (isPermittedURI(request.getRequestURI())) {
            SecurityContextHolder.getContext().setAuthentication(null);
            filterChain.doFilter(request, response);
            return;
        }

 

2️⃣ Access token가 유효한지 확인

1. Cookie에서 Access token 추출

2. Access token의 시크릿 키, 구조가 유효하고 & 유효 기간 만료 X 인지 확인

2-1. Access token에 문제가 없다면 `true` 반환.  Authentication 객체 생성 후, SecurityContextHolder에 저장 → doFilter 호출

2-2. Access token의 유효 기간이 만료되었다면 `false` 반환 → if문 pass

2-2. Access token의 시크릿 키, 구조에 문제가 발생했다면 BusinessException 발생

        String accessToken = jwtService.resolveTokenFromCookie(request, JwtRule.ACCESS_PREFIX);
        if (jwtService.validateAccessToken(accessToken)) {
            setAuthenticationToContext(accessToken);
            filterChain.doFilter(request, response);
            return;
        }

 

 

3️⃣ Refresh token이 유효한지 확인

1. Cookie에서 Refresh token 추출

2. RTR 적용으로 인해 NoSQL에 유효한 Refresh token이 저장되어 있을 것 이므로, Refresh token을 통해 User 객체를 받아옴

3. Refresh token이 유효한지 확인 (토큰의 시크릿 키, 구조, 유효기간 만료 X && NoSQL에 저장되어 있는 Refresh token과 일치하는지 확인)

3-1. 모든 조건에 부합하지 않는다면 logout 처리 (Cookie에 있던 모든 token들 파기, NoSQL에 저장되어 있던 정보 삭제)

4. Refresh token이 유효하다면

4-1. Access token 새로 발급

4-2. RTR 전략에 의해 Refresh token 또한 다시 발급 && NoSQL에 Refresh token 정보 갱신 (- jwtService에 구현되어 있음)

5. 새로 발급한 Access token을 통해 Authentication 객체를 생성하고, SecurityContextHolder에 저장

6. `doFilter()` 호출을 통해 다음 필터 호출

        ...
        String refreshToken = jwtService.resolveTokenFromCookie(request, JwtRule.REFRESH_PREFIX);
        User user = findUserByRefreshToken(refreshToken);

        if (jwtService.validateRefreshToken(refreshToken, user.getIdentifier())) {
            String reissuedAccessToken = jwtService.generateAccessToken(response, user);
            jwtService.generateRefreshToken(response, user);

            setAuthenticationToContext(reissuedAccessToken);
            filterChain.doFilter(request, response);
            return;
        }

        jwtService.logout(user, response);
    }
    
    private User findUserByRefreshToken(String refreshToken) {
        String identifier = jwtService.getIdentifierFromRefresh(refreshToken);
        return userService.findUserByIdentifier(identifier);
    }

 

 

 

 

[전체 코드]

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtService jwtService;
    private final UserService userService;

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

        if (isPermittedURI(request.getRequestURI())) {
            SecurityContextHolder.getContext().setAuthentication(null);
            filterChain.doFilter(request, response);
            return;
        }

        String accessToken = jwtService.resolveTokenFromCookie(request, JwtRule.ACCESS_PREFIX);
        if (jwtService.validateAccessToken(accessToken)) {
            setAuthenticationToContext(accessToken);
            filterChain.doFilter(request, response);
            return;
        }

        String refreshToken = jwtService.resolveTokenFromCookie(request, JwtRule.REFRESH_PREFIX);
        User user = findUserByRefreshToken(refreshToken);

        if (jwtService.validateRefreshToken(refreshToken, user.getIdentifier())) {
            String reissuedAccessToken = jwtService.generateAccessToken(response, user);
            jwtService.generateRefreshToken(response, user);

            setAuthenticationToContext(reissuedAccessToken);
            filterChain.doFilter(request, response);
            return;
        }

        jwtService.logout(user, response);
    }

    private boolean isPermittedURI(String requestURI) {
        return Arrays.stream(PERMITTED_URI)
                .anyMatch(permitted -> {
                    String replace = permitted.replace("*", "");
                    return requestURI.contains(replace) || replace.contains(requestURI);
                });
    }

    private User findUserByRefreshToken(String refreshToken) {
        String identifier = jwtService.getIdentifierFromRefresh(refreshToken);
        return userService.findUserByIdentifier(identifier);
    }

    private void setAuthenticationToContext(String accessToken) {
        Authentication authentication = jwtService.getAuthentication(accessToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

 

 

 

 


🔥AuthController

프론트측에서 JWT 발급 요청할 때 호출할 API를 생성하자.

 

`POST /api/auth`로 요청하게 하며, Request body에 사용자의 식별자(깃허브의 경우 아이디)를 담아 JWT 발급을 요청한다.

`JwtService`의 JWT 발급 메서드들을 호출하여 토큰들을 발급한 후 응답한다.

public record TokenRequest(
        String identifier
) { }
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class AuthController {
    private final UserService userService;
    private final JwtService jwtService;

    @PostMapping("/auth")
    public ResponseEntity<SingleResponse<AuthResponse>> generateToken(HttpServletResponse response,
                                                                      @RequestBody TokenRequest tokenRequest) {
        User requestUser = userService.findUserByIdentifier(tokenRequest.identifier());
        jwtService.validateUser(requestUser);

        jwtService.generateAccessToken(response, requestUser);
        jwtService.generateRefreshToken(response, requestUser);

        AuthResponse authResponse = userService.getUserAuthInfo(requestUser.getIdentifier());

        return ResponseEntity.ok().body(
                new SingleResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), authResponse)
        );
    }
}

 


🔥 SecurityConfig

Spring security에는 다양한 Filter들로 구성이 되어 있으며, API Request가 들어왔을 때 다양한 Filter를 거쳐 인증/인가 과정을 거치게 된다.

 

🎯 뒤에서 API 요청이 들어올 때마다 JWT의 유효 여부를 확인하는 필터인 `JwtAuthenticationFilter`를 구현할텐데, 해당 필터를 `UsernamePasswordAuthenticationFilter`의 앞에 배치한다.

🎯 Spring security의 Filter chain에서 발생하는 Exception의 경우 `@RestControllerAdvice`로 처리가 불가능하다. 따라서 Exception을 처리하는 ExceptionHandlerFilter를 구현하고, `JwtAuthenticationFilter`의 앞 부분에 배치한다.

 

@Configuration
@Order(1)
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
    public static final String PERMITTED_URI[] = {"/api/auth/**", "/login"};
    private static final String PERMITTED_ROLES[] = {"USER", "ADMIN"};
    private final CustomCorsConfigurationSource customCorsConfigurationSource;
    private final CustomOAuth2UserService customOAuthService; // OAuth 관련
    private final JwtService jwtService;
    private final UserService userService;
    private final OAuth2SuccessHandler successHandler; // OAuth 관련
    private final OAuth2FailureHandler failureHandler; // OAuth 관련

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.cors(corsCustomizer -> corsCustomizer
                        .configurationSource(customCorsConfigurationSource)
                )
                .csrf(CsrfConfigurer::disable)
                .httpBasic(HttpBasicConfigurer::disable)
                // OAuth 사용으로 인한 form login 비활성화
                .formLogin(FormLoginConfigurer::disable)
                .authorizeHttpRequests(request -> request
                        // 특정 권한이 있어야만 특정 API에 접근할 수 있도록 설정
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                        // 특정 API들은 별도의 인증/인가 과정 없이도 접근이 가능하도록 설정
                        .requestMatchers(PERMITTED_URI).permitAll()
                        // 그 외의 요청들은 PERMITTED_ROLES 중 하나라도 가지고 있어야 접근이 가능하도록 설정
                        .anyRequest().hasAnyRole(PERMITTED_ROLES))

                // JWT 사용으로 인한 세션 미사용
                .sessionManagement(configurer -> configurer
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))

                // JWT 검증 필터 추가
                .addFilterBefore(new JwtAuthenticationFilter(jwtService, userService),
                        UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new ExceptionHandlerFilter(), JwtAuthenticationFilter.class)

                // OAuth 로그인 설정
                .oauth2Login(customConfigurer -> customConfigurer
                        .successHandler(successHandler)
                        .failureHandler(failureHandler)
                        .userInfoEndpoint(endpointConfig -> endpointConfig.userService(customOAuthService))
                );

        return http.build();
    }
}

 

 

 


✅  참고 자료 & 링크

 

 

728x90