🗂️ 개인프로젝트/프로그

[개인프로젝트/프로그] 4. JWT로 로그인/로그아웃 구현하기

케로⸝⸝◜࿀◝ ⸝⸝ 2024. 6. 8. 23:00

1. 개념

 

[스프링] 토큰 기반 인증과 JWT

프로젝트 생성 토큰 기반 인증이란?사용자가 서버에 접근할 때 이 사용자가 인증된 사용자인지 확인하는 방법은 다양하다.대표적인 사용자 인증 확인 방법은 2가지세션 기반 인증토큰 기반 인

progfrog.tistory.com

 

2. JWT 서비스 구현하기

실제로 JWT를 생성하고, 검증하는 서비스를 구현해 보자. 의존성과 토큰 제공자를 추가하고 나서, 리프레시 토큰 도메인과 토큰 필터를 구현하면 JWT 서비스를 사용할 준비가 된 것이다! 여기서 만든 클래스들은 OAuth 서비스에서 사용한다.

 

의존성 추가하기

implementation 'io.jsonwebtoken:jjwt:0.9.1' // 자바 JWT 라이브러리
implementation 'javax.xml.bind:jaxb-api:2.3.1' // XML 문서와 Java 객체 간 매핑 자동화

 

application-secret.properties

  • resources에 application-secret.properties를 생성해서, 이슈 발급자(issuer)와 비밀 키(secret_key)를 설정해 준다.
  • 이 두 값은 JWT 토큰을 만들기 위한 필수 설정 값이다.
  • 깃허브에 업로드되어 노출되지 않도록 .gitignore 에도 등록해 둔다. 자세한 설명은 아래 링크 참고!
 

[Git] application-secret.properties 관리하기

토큰을 만들 때 사용할 비밀 키나, OAuth 클라이언트 Id, 보안 비밀번호 등 깃허브 같은 외부 사이트에 업로드하지 않아야하는 프로퍼티들을 관리해야할 때 .gitignore 파일을 사용하는 것이 효과적

progfrog.tistory.com

jwt.issuer=a@email.com
jwt.secret_key=123456789!@#

 

JwtProperties

해당 값들을 변수로 접근하는 데 사용할 클래스를 만든다.

package me.progfrog.flog.config.jwt;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties("jwt")
@Getter
@Setter
public class JwtProperties {

    private String issuer;
    private String secretKey;
}
@ConfigurationProperties("jwt")
  • 자바 클래스에 프로퍼티 값을 가져와서 사용하는 애노테이션

 

TokenProvider

  • 토큰 생성
  • 올바른 토큰인지 유효성 검사
  • 토큰에 필요한 정보 가져오기
package me.progfrog.flog.config.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import me.progfrog.flog.domain.User;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Collections;
import java.util.Date;
import java.util.Set;

@Service
@RequiredArgsConstructor
public class TokenProvider {

    private final JwtProperties jwtProperties;

    /**
     * JWT 토큰 생성 메서드
     */
    public String generateToken(User user, Duration expiredAt) {
        Date now = new Date();
        return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
    }

    private String makeToken(Date expiry, User user) {
        Date now = new Date();

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer(jwtProperties.getIssuer())
                .setIssuedAt(now)
                .setExpiration(expiry)
                .setSubject(user.getEmail())
                .claim("id", user.getId())
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }

    /**
     * JWT 토큰 유효성 검증 메서드
     */
    public boolean validToken(String token) {
        try {
            // 비밀키로 복호화
            Jwts.parser()
                    .setSigningKey(jwtProperties.getSecretKey())
                    .parseClaimsJws(token);

            return true;
        } catch (Exception e) {
            // 복호화 과정에서 에러가 나면 유효하지 않은 토큰
            return false;
        }
    }

    /**
     * 토큰 기반으로 인증 정보를 가져오는 메서드
     */
    public Authentication getAuthentication(String token) {
        Claims claims = getClaims(token);
        Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));

        return new UsernamePasswordAuthenticationToken(
                new org.springframework.security.core.userdetails.User(
                claims.getSubject(), "", authorities), token, authorities);
    }

    /**
     * 토큰 기반으로 유저 ID를 가져오는 메서드
     */
    public Long getUserId(String token) {
        Claims claims = getClaims(token);
        return claims.get("id", Long.class);
    }

    /**
     * 클레임 조회
     */
    private Claims getClaims(String token) {
        return Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey())
                .parseClaimsJws(token)
                .getBody();
    }
}

 

JWT 토큰 생성 메서드

  • 만료 시간, 유저 정보를 받아 토큰을 생성하는 메서드
  • 헤더는 typ(타입)
  • 내용은 iss(발급자), iat(발급일시), exp(만료일시), sub(토큰 제목)
  • 클레임에는 유저 ID를 지정
  • 토큰을 만들 때는 프로퍼티즈 파일에서 선언해 둔 비밀키와 함께 HS256 방식으로 암호화
public String generateToken(User user, Duration expiredAt) {
    Date now = new Date();
    return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
}

private String makeToken(Date expiry, User user) {
    Date now = new Date();

    return Jwts.builder()
            .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
            .setIssuer(jwtProperties.getIssuer())
            .setIssuedAt(now)
            .setExpiration(expiry)
            .setSubject(user.getEmail())
            .claim("id", user.getId())
            .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
            .compact();
}

 

JWT 토큰 유효성 검증 메서드

  • 프로퍼티즈 파일에 선언한 비밀키와 함께 토큰 복호화를 진행
  • 만약 복호화 과정에서 에러가 발생하면, 유효하지 않은 토큰이므로 false를 반환하고 에러가 발생하지 않으면 true를 반환
public boolean validToken(String token) {
    try {
        // 비밀키로 복호화
        Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey())
                .parseClaimsJws(token);

        return true;
    } catch (Exception e) {
        // 복호화 과정에서 에러가 나면 유효하지 않은 토큰
        return false;
    }
}

 

토큰 기반으로 인증 정보를 가져오는 메서드

  • 토큰을 받아 인증 정보를 담은 객체 Authentication을 반환하는 메서드
  • 프로퍼티즈 파일에 저장한 비밀키로 토큰을 복호화한 뒤, 클레임 정보를 반환받아 사용자 이메일이 들어있는 토큰 제목 sub와 토큰 기반으로 인증 정보를 생성
  • 이때 UsernamePasswordAuthenticationToken의 첫 인자로 들어가는 User는 스프링 시큐리티에서 제공하는 객체인 User 클래스를 임포트 해야 한다.
public Authentication getAuthentication(String token) {
    Claims claims = getClaims(token);
    Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));

    return new UsernamePasswordAuthenticationToken(
            new org.springframework.security.core.userdetails.User(
            claims.getSubject(), "", authorities), token, authorities);
}
private Claims getClaims(String token) {
    return Jwts.parser()
            .setSigningKey(jwtProperties.getSecretKey())
            .parseClaimsJws(token)
            .getBody();
}

 

토큰 기반으로 유저 ID를 가져오는 메서드

  • 토큰 기반으로 사용자 ID를 가져오는 메서드
  • 프로퍼티즈 파일에 저장한 비밀키로 토큰을 복호화한 뒤, 클레임 정보를 반환받아 클레임에서 id 키로 저장된 값을 가져와 반환
public Long getUserId(String token) {
    Claims claims = getClaims(token);
    return claims.get("id", Long.class);
}

 

 

테스트 코드

테스트에서 롬복을 사용할 수 있도록 의존성 추가

testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'

 

테스트에서 application-secret.properties를 사용할 수 있도록 test용 application.properties 파일 수정

# JPA 설정
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.type.descriptor.sql=trace

spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.highlight_sql=true

# profile
spring.profiles.include=secret

 

JwtFactory

JWT 토큰 서비스를 테스트하는 데 사용할 모킹(mocking)*용 객체

*모킹: 테스트를 실행할 때 객체를 대신할 가짜 객체

package me.progfrog.flog.config.jwt;

import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Builder;
import lombok.Getter;

import java.time.Duration;
import java.util.Date;
import java.util.Map;

import static java.util.Collections.emptyMap;

@Getter
public class JwtFactory {
    private String subject = "test@email.com";
    private Date issuedAt = new Date();
    private Date expiration = new Date(new Date().getTime() + Duration.ofDays(14).toMillis());
    private Map<String, Object> claims = emptyMap();

    /**
     * 빌더 패턴을 사용해 설정이 필요한 데이터만 선택 정렬
     * 빌더 패턴을 사용하지 않으면 필드 기본값을 사용
     */
    @Builder
    public JwtFactory(String subject, Date issuedAt, Date expiration, Map<String, Object> claims) {
        this.subject = subject != null ? subject : this.subject;
        this.issuedAt = issuedAt != null ? issuedAt : this.issuedAt;
        this.expiration = expiration != null ? expiration : this.expiration;
        this.claims = claims != null ? claims : this.claims;
    }

    public static JwtFactory withDefaultValues() {
        return JwtFactory.builder().build();
    }

    /**
     * jjwt 라이브러리를 사용해 JWT 토큰 생성
     */
    public String createToken(JwtProperties jwtProperties) {
        return Jwts.builder()
                .setSubject(subject)
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer(jwtProperties.getIssuer())
                .setIssuedAt(issuedAt)
                .setExpiration(expiration)
                .addClaims(claims)
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }
}

 

TokenProviderTest

package me.progfrog.flog.config.jwt;

import io.jsonwebtoken.Jwts;
import me.progfrog.flog.domain.User;
import me.progfrog.flog.repository.UserRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;

import java.time.Duration;
import java.util.Date;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class TokenProviderTest {
    @Autowired
    private TokenProvider tokenProvider;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private JwtProperties jwtProperties;

    @DisplayName("generateToken(): 유저 정보와 만료 기간을 전달해 토큰을 만들 수 있다.")
    @Test
    void generateToken() {
        // given
        User testUser = userRepository.save(new User("user@email.com", "test"));

        // when
        String token = tokenProvider.generateToken(testUser, Duration.ofDays(14));

        // then
        // jjwt 라이브러리를 사용해 토큰을 복호화하고, 토큰을 만들 때 클레임으로 넣어둔 id 값이 given 절에서 만든 유저 ID와 동일한지 확인
        Long userId = Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey())
                .parseClaimsJws(token)
                .getBody()
                .get("id", Long.class);

        assertThat(userId).isEqualTo(testUser.getId());
    }

    @DisplayName("validToken(): 만료된 토큰이면 유효성 검증에 실패한다.")
    @Test
    void validToken_invalidToken() {
        // given
        // 현재 시간보다 7일 이전에 만료된 JWT 토큰을 생성
        String token = JwtFactory.builder()
                .expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis()))
                .build()
                .createToken(jwtProperties);

        // when
        boolean result = tokenProvider.validToken(token);

        // then
        assertThat(result).isFalse();
    }

    @DisplayName("validToken(): 유효한 토큰이면 유효성 검증에 성공한다.")
    @Test
    void validToken_validToken() {
        // given
        String token = JwtFactory.withDefaultValues().createToken(jwtProperties);

        // when
        boolean result = tokenProvider.validToken(token);

        // then
        assertThat(result).isTrue();
    }

    @DisplayName("getAuthentication(): 검증 테스트")
    @Test
    void getAuthentication() {
        // given
        String userEmail = "user@email.com";
        String token = JwtFactory.builder()
                .subject(userEmail)
                .build()
                .createToken(jwtProperties);

        // when
        Authentication authentication = tokenProvider.getAuthentication(token);

        // then
        // 반환받은 인증 객체의 유저 이름을 가져와 given 절에서 설정한 subject 값인 user@email.com과 같은지 확인
        assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail);
    }

    @DisplayName("getUserId(): 토큰으로 유저 ID를 가져올 수 있다.")
    @Test
    void getUserId() {
        // given
        Long userId = 1L;
        String token = JwtFactory.builder()
                .claims(Map.of("id", userId))
                .build()
                .createToken(jwtProperties);

        // when
        Long userIdByToken = tokenProvider.getUserId(token);

        // then
        assertThat(userIdByToken).isEqualTo(userId);
    }
}

 

리프레시 토큰 도메인 구현하기

RefreshToken, RefreshTokenRepository

리프레시 토큰은 데이터베이스에 저장하는 정보이므로, 엔티티와 리포지터리를 추가해야 한다.

package me.progfrog.flog.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", updatable = false)
    private Long id;

    @Column(name = "user_id", nullable = false, unique = true)
    private Long userId;

    @Column(name = "refresh_token", nullable = false)
    private String refreshToken;

    public RefreshToken(Long userId, String refreshToken) {
        this.userId = userId;
        this.refreshToken = refreshToken;
    }

    public RefreshToken update(String newRefreshToken) {
        this.refreshToken = newRefreshToken;
        return this;
    }
}
package me.progfrog.flog.repository;

import me.progfrog.flog.domain.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByUserId(Long userId);
    Optional<RefreshToken> findByRefreshToken(String refreshToken);
}

 

토큰 필터 구현하기

  • 필터는 실제로 각종 요청을 처리하기 위한 로직으로 전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
  • 요청이 오면 헤더값을 비교해서 토큰이 있는지 확인하고, 유효 토큰이라면 시큐리티 컨텍스트 홀더(security context holder)에 인증 정보를 저장
  • 시큐리티 컨텍스트는 인증 객체가 저장되는 보관소로, 여기서 인증 정보가 필요할 때 언제든지 인증 객체를 꺼내 사용할 수 있다.
    • 이 클래스는 스레드마다 공간을 할당하는 스레드 로컬(thread local)에 저장되므로, 코드의 아무 곳에서나 참조할 수 있고, 다른 스레드와 공유하지 않으므로 독립적으로 사용할 수 있다.
  • 이러한 시큐리티 컨텍스트 객체를 저장하는 객체가 시큐리티 컨텍스트 홀더이다.

 

TokenAuthenticationFilter

  • 이 필터는 액세스 토큰값이 담긴 Authorization 헤더값을 가져온 뒤, 액세스 토큰이 유효하다면 인증 정보를 생성
  • 요청 헤더에서 키가 Authorization인 필드의 값을 가져온 다음, 토큰의 접두사 Bearer를 제외한 값을 얻는다.
    • 만약 값이 null이거나 Bearer로 시작하지 않으면 null을 반환
  • 가져온 토큰이 유효한지 확인하고, 유효하다면 인증 정보를 관리하는 시큐리티 컨텍스트에 인증 정보를 설정한다.
  • 인증 정보가 설정된 이후에 컨텍스트 홀더에서 getAuthentication() 메서드를 사용해 인증 정보를 가져오면 유저 객체가 반환되며, 유저 객체에는 유저 이름(username)과 권한 목록(authorities)과 같은 인증 정보가 포함된다.
package me.progfrog.flog.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import me.progfrog.flog.config.jwt.TokenProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    private final TokenProvider tokenProvider;
    private final static String HEADER_AUTHORIZATION = "Authorization";
    private final static String TOKEN_PREFIX = "Bearer ";

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

        // 요청 헤더의 Authorization 키의 값 조회
        String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);

        // 가져온 값에서 접두사 제거
        String token = getAccessToken(authorizationHeader);

        // 가져온 토큰이 유효한지 확인하고, 유효한 때는 인증 정보 설정
        if (tokenProvider.validToken(token)) {
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String getAccessToken(String authorizationHeader) {
        if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
            return authorizationHeader.substring(TOKEN_PREFIX.length());
        }

        return null;
    }
}

3. 토큰 API 구현하기

  • 여기서는 리프레시 토큰을 전달받아 검증하고, 유효한 리프레시 토큰이라면 새로운 액세스 토큰을 생성하는 토큰 API를 구현
  • 토큰 서비스, 컨트롤러를 차례대로 구현

 

토큰 서비스 추가하기

리프레시 토큰을 전달받아 토큰 제공자를 사용해 새로운 액세스 토큰을 만드는 토큰 서비스 클래스를 생성하자!

 

 

UserService

  • 유저 ID로 유저를 검색해서 전달하는 findById() 메서드를 추가로 구현
package me.progfrog.flog.service;

import lombok.RequiredArgsConstructor;
import me.progfrog.flog.domain.User;
import me.progfrog.flog.dto.user.UserReqAddDto;
import me.progfrog.flog.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public void save(UserReqAddDto reqAddDto) {
        userRepository.save(new User(
                reqAddDto.email(),
                bCryptPasswordEncoder.encode(reqAddDto.password()))
        );
    }

    public User findById(Long userId) {
        return userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
    }
}

 

RefreshTokenService

  • 전달받은 리프레시 토큰으로 리프레시 토큰 객체를 검색해서 전달
package me.progfrog.flog.service;

import lombok.RequiredArgsConstructor;
import me.progfrog.flog.domain.RefreshToken;
import me.progfrog.flog.repository.RefreshTokenRepository;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class RefreshTokenService {

    private final RefreshTokenRepository refreshTokenRepository;

    public RefreshToken findByRefreshToken(String refreshToken) {
        return refreshTokenRepository.findByRefreshToken(refreshToken)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected token"));
    }
}

 

TokenService

  • 전달받은 리프레시 토큰으로 토큰 유효성 검사를 진행하고, 유효한 토큰이면 리프레시 토큰으로 사용자 ID를 찾는다.
  • 사용자 ID로 사용자를 찾은 후에, 토큰 제공자의 generateToken() 메서드를 호출해서 새로운 액세스 토큰을 생성한다.
package me.progfrog.flog.service;

import lombok.RequiredArgsConstructor;
import me.progfrog.flog.config.jwt.TokenProvider;
import me.progfrog.flog.domain.User;
import org.springframework.stereotype.Service;

import java.time.Duration;

@Service
@RequiredArgsConstructor
public class TokenService {

    private final TokenProvider tokenProvider;
    private final RefreshTokenService refreshTokenService;
    private final UserService userService;

    private static final int DURATION_HOUR = 2;

    public String createNewAccessToken(String refreshToken) {
        // 토큰 유효성 검사에 실패하면 예외 발생
        if (!tokenProvider.validToken(refreshToken)) {
            throw new IllegalArgumentException("Unexpected token");
        }

        Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
        User user = userService.findById(userId);

        return tokenProvider.generateToken(user, Duration.ofHours(DURATION_HOUR));
    }
}

 

컨트롤러 추가하기

AccessTokenReqCreateDto, AccessTokenResCreateDto

토큰 생성 요청 및 응답을 담당할 dto 생성

package me.progfrog.flog.dto.accesstoken;

public record AccessTokenReqCreateDto(
        String refreshToken
) {
}
package me.progfrog.flog.dto.accesstoken;

public record AccessTokenResCreateDto(
        String accessToken
) {
}

 

TokenApiController

  • 실제로 요청을 받고 처리할 컨트롤러를 생성
  • /api/token POST 요청이 오면 토큰 서비스에서 리프레시 토큰을 기반으로 새로운 액세스 토큰을 만들어주면 된다.
package me.progfrog.flog.api;

import lombok.RequiredArgsConstructor;
import me.progfrog.flog.dto.accesstoken.AccessTokenReqCreateDto;
import me.progfrog.flog.dto.accesstoken.AccessTokenResCreateDto;
import me.progfrog.flog.service.TokenService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class TokenApiController {

    private final TokenService tokenService;

    @PostMapping("/api/token")
    public ResponseEntity<AccessTokenResCreateDto> createNewAccessToken(@RequestBody AccessTokenReqCreateDto reqCreateDto) {
        String newAccessToken = tokenService.createNewAccessToken(reqCreateDto.refreshToken());

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(new AccessTokenResCreateDto(newAccessToken));
    }
}

 

테스트

  • given: 테스트 유저를 생성하고, jjwt 라이브러리를 이용해 리프레시 토큰을 만들어 데이터베이스에 저장한다. 토큰 생성 API의 요청 본문에 리프레시 토큰을 포함하여 요청 객체를 생성한다.
  • when: 토큰 추가 API에 요청을 보낸다. 이때 요청 타입은 JSON이며, given 절에서 미리 만들어둔 객체를 요청 본문으로 함께 보낸다.
  • Then: 응답 코드가 201 Created 인지 확인하고, 응답으로 온 액세스 토큰이 비어 있지 않은지 확인한다.
package me.progfrog.flog.api;

import com.fasterxml.jackson.databind.ObjectMapper;
import me.progfrog.flog.config.jwt.JwtFactory;
import me.progfrog.flog.config.jwt.JwtProperties;
import me.progfrog.flog.domain.RefreshToken;
import me.progfrog.flog.domain.User;
import me.progfrog.flog.dto.accesstoken.AccessTokenReqCreateDto;
import me.progfrog.flog.repository.RefreshTokenRepository;
import me.progfrog.flog.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.WebApplicationContext;

import java.util.Map;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class TokenApiControllerTest {
    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @Autowired
    private WebApplicationContext context;

    @Autowired
    JwtProperties jwtProperties;

    @Autowired
    UserRepository userRepository;

    @Autowired
    RefreshTokenRepository refreshTokenRepository;

    @BeforeEach
    public void setUp() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
    }

    @DisplayName("createNewAccessToken(): 새로운 액세스 토큰을 발급한다.")
    @Test
    public void createNewAccessToken() throws Exception {
        // given
        final String url = "/api/token";

        User testUser = userRepository.save(
                new User("user@email.com", "test"));

        String refreshToken = JwtFactory.builder()
                .claims(Map.of("id", testUser.getId()))
                .build()
                .createToken(jwtProperties);

        refreshTokenRepository.save(new RefreshToken(testUser.getId(), refreshToken));

        AccessTokenReqCreateDto reqCreateDto = new AccessTokenReqCreateDto(refreshToken);

        final String requestBody = objectMapper.writeValueAsString(reqCreateDto);

        // when
        ResultActions resultActions = mockMvc.perform(post(url)
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(requestBody));

        // then
        resultActions
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accessToken").isNotEmpty());
    }
}

 

4. 정리

  • 토큰 기반 인증은 인증에 토큰을 사용하는 방식
    • 토큰은 클라이언트를 구분하는 데 사용하는 유일한 값
    • 서버에서 생성해서 클라이언트에게 제공한 뒤, 클라이언트는 서버에 요청할 때마다 요청 내용과 함께 토큰을 전송
    • 서버는 토큰으로 유효한 사용자인지 검증
  • JWT는 토큰 기반 인증에서 주로 사용하는 토큰
    • JSON 형식으로 사용자(클라이언트)의 정보를 저장
    • JWT는 헤더, 페이로드, 서명의 구조로 이루어져 있음
      • 헤더는 토큰의 타입과 해싱 알고리즘을 지정하는 정보를 포함
      • 페이로드는 토큰에 담을 정보가 들어감
      • 서명은 해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용
  • 리프레시 토큰은 액세스 토큰과 별개의 토큰
    • 액세스 토큰이 만료되었을 때, 새로운 액세스 토큰을 발급받는 용도로 사용
  • 필터는 실제로 요청이 전달되기 전과 후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공
  • 시큐리티 컨텍스트는 인증 객체가 저장되는 보관소로, 인증 정보가 필요할 때 언제든지 인증 객체를 꺼내어 사용하도록 제공되는 클래스
    • 이러한 시큐리티 컨텍스트 객체를 저장하는 객체가 시큐리티 컨텍스트 홀더 
반응형