Introduction

이 글에서는 Spring Boot + JWT + Security 를 사용해서 회원가입/로그인 로직을 구현했습니다.

JWT 와 Spring Security 코드는 인프런 Spring Boot JWT Tutorial (정은구) 강의를 수강하면서 만들고 제 스타일에 맞게 수정했습니다.

오로지 Security 와 인증 로직에 초점을 맞추기 위해 불필요한 코드는 제거했습니다.

구현하고자 하는 전체 로직은 다음과 같습니다.


1. JWT

JWT 에 관련된 글은 따로 작성했기 때문에 링크로 대체합니다.


2. Spring Security

Spring Security 는 사용자 정보 (ID/PW) 검증 및 유저 정보 관리 등을 쉽게 사용할 수 있도록 제공합니다.

JWT 와 같이 소개되는 경우가 많은데 스프링 시큐리티는 원래 세션 기반 인증을 사용하기 때문에 JWT 와 별개로 생각해야 합니다.

사용자 로그인 뿐만 아니라 보안 관련된 여러가지 설정들도 제공하기 때문에 실제 업무에서 사용한다면 꼭 개념을 미리 학습하고 사용하는 것을 권장합니다.

여기서는 겉핥기식으로 필요한 시큐리티 정보만 세팅하고 어떤식으로 동작하는 지만 파악합니다.

  • User Role 을 꼭 설정해야 하나요?
    • Spring Security 자체에서 내부적으로 사용하는 것 같음
    • ROLE_USER 처럼 정확히 형식을 지켜줘야 함

// build.gradle
plugins {
    id 'org.springframework.boot' version '2.4.3'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.tutorial'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

        // security 관련 의존성
    implementation 'org.springframework.boot:spring-boot-starter-security'

    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

        // jwt 관련 의존성
    compile group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    runtime group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    runtime group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
}

test {
    useJUnitPlatform()
}

3. Member 도메인 설계

시큐리티 설정을 테스트하기 위한 기본적인 사용자 도메인을 만듭니다.

시큐리티 자체적으로 UserDetails 의 구현체인 User 를 사용하기 때문에 헷갈리지 않도록 Account 또는 Member 로 이름 짓는게 좋습니다. (개인적인 생각)

  • Member 도메인
    • Member
    • MemberRepository
    • MemberService
    • MemberController
    • application.yml: h2 database 설정과 jwt secret key 설정

3.1. Member

@Getter
@NoArgsConstructor
@Table(name = "member")
@Entity
public class Member {

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

    private String email;

    private String password;

    @Enumerated(EnumType.STRING)
    private Authority authority;

    @Builder
    public Member(String email, String password, Authority authority) {
        this.email = email;
        this.password = password;
        this.authority = authority;
    }
}
  • 최소한의 정보만을 갖고 있는 Member Entity 입니다.

public enum Authority {
    ROLE_USER, ROLE_ADMIN
}
  • 권한은 Enum 클래스로 만들었습니다.

3.2. MemberRepository

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email);
    boolean existsByEmail(String email);
}
  • 마찬가지로 최소한의 쿼리만 갖고있습니다.
  • Email 을 Login ID 로 갖고 있기 때문에 findByEmail 와 중복 가입 방지를 위한 existsByEmail 만 추가합니다.

3.3. MemberService

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    @Transactional(readOnly = true)
    public MemberResponseDto getMemberInfo(String email) {
        return memberRepository.findByEmail(email)
                .map(MemberResponseDto::of)
                .orElseThrow(() -> new RuntimeException("유저 정보가 없습니다."));
    }

    // 현재 SecurityContext 에 있는 유저 정보 가져오기
    @Transactional(readOnly = true)
    public MemberResponseDto getMyInfo() {
        return memberRepository.findById(SecurityUtil.getCurrentMemberId())
                .map(MemberResponseDto::of)
                .orElseThrow(() -> new RuntimeException("로그인 유저 정보가 없습니다."));
    }
}
  • 내 정보를 가져올 때는 SecurityUtil.getCurrentMemberId() 를 사용합니다.
  • API 요청이 들어오면 필터에서 Access Token 을 복호화 해서 유저 정보를 꺼내 SecurityContext 라는 곳에 저장합니다.
  • SecurityContext 에 저장된 유저 정보는 전역으로 어디서든 꺼낼 수 있습니다.
  • SecurityUtil 클래스에서는 유저 정보에서 Member ID 만 반환하는 메소드가 정의되어 있습니다.

3.4. MemberController

@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {
    private final MemberService memberService;

    @GetMapping("/me")
    public ResponseEntity<MemberResponseDto> getMyMemberInfo() {
        return ResponseEntity.ok(memberService.getMyInfo());
    }

    @GetMapping("/{email}")
    public ResponseEntity<MemberResponseDto> getMemberInfo(@PathVariable String email) {
        return ResponseEntity.ok(memberService.getMemberInfo(email));
    }
}
  • Service 와 동일합니다.

3.5. application.yml

spring:

  h2:
    console:
      enabled: true

  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
        show_sql: true

logging:
  level:
    com.tutorial: debug

# HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
# Secret 값은 특정 문자열을 Base64 로 인코딩한 값 사용 (아래 명령어를 터미널에 쳐보면 그대로 나옴)
# $ echo 'spring-boot-security-jwt-tutorial-jiwoon-spring-boot-security-jwt-tutorial' | base64
jwt:
  secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWppd29vbi1zcHJpbmctYm9vdC1zZWN1cml0eS1qd3QtdHV0b3JpYWwK
  • H2 Database 를 사용하기 위한 기본 설정과 JWT 시크릿 키를 설정해둡니다.
  • 시크릿 키도 원래는 깃헙에 올라가지 않게 별도로 보관하는 것이 안전합니다.

4. JWT 와 Security 설정

  • JWT 관련
    • TokenProvider: 유저 정보로 JWT 토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져옴
    • JwtFilter: Spring Request 앞단에 붙일 Custom Filter
  • Spring Security 관련
    • JwtSecurityConfig: JWT Filter 를 추가
    • JwtAccessDeniedHandler: 접근 권한 없을 때 403 에러
    • JwtAuthenticationEntryPoint: 인증 정보 없을 때 401 에러
    • SecurityConfig: 스프링 시큐리티에 필요한 설정
    • SecurityUtil: SecurityContext 에서 전역으로 유저 정보를 제공하는 유틸 클래스

4.1. TokenProvider

@Slf4j
@Component
public class TokenProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일

    private final Key key;

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

    public TokenDto generateTokenDto(Authentication authentication) {
        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 1516239022 (예시)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }

    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}
  • JWT 토큰에 관련된 암호화, 복호화, 검증 로직은 다 이곳에서 이루어집니다.
  • 생성자
    • application.yml 에 정의해놓은 jwt.secret 값을 가져와서 JWT 를 만들 때 사용하는 암호화 키값을 생성합니다.
  • generateTokenDto
    • 유저 정보를 넘겨받아서 Access Token 과 Refresh Token 을 생성합니다.
    • 넘겨받은 유저 정보의 authentication.getName() 메소드가 username 을 가져옵니다.
    • 저는 username 으로 Member ID 를 저장했기 때문에 해당 값이 설정될 겁니다.
    • Access Token 에는 유저와 권한 정보를 담고 Refresh Token 에는 아무 정보도 담지 않습니다.
  • getAuthentication
    • JWT 토큰을 복호화하여 토큰에 들어 있는 정보를 꺼냅니다.
    • Access Token 에만 유저 정보를 담기 때문에 명시적으로 accessToken 을 파라미터로 받게 했습니다.
    • Refresh Token 에는 아무런 정보 없이 만료일자만 담았습니다.
    • UserDetails 객체를 생생성해서 UsernamePasswordAuthenticationToken 형태로 리턴하는데 SecurityContext 를 사용하기 위한 절차라고 생각하면 됩니다..
    • 사실 좀 불필요한 절차라고 생각되지만 SecurityContextAuthentication 객체를 저장하기 때문에 어쩔수 없습니다.
    • parseClaims 메소드는 만료된 토큰이어도 정보를 꺼내기 위해서 따로 분리했습니다.
  • validateToken
    • 토큰 정보를 검증합니다.
    • Jwts 모듈이 알아서 Exception 을 던져줍니다.

4.2. JwtFilter

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;

    // 실제 필터링 로직은 doFilterInternal 에 들어감
    // JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        // 1. Request Header 에서 토큰을 꺼냄
        String jwt = resolveToken(request);

        // 2. validateToken 으로 토큰 유효성 검사
        // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보를 꺼내오기
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
  • OncePerRequestFilter 인터페이스를 구현하기 때문에 요청 받을 때 단 한번만 실행됩니다.
  • doFilterInternal
    • 실제 필터링 로직을 수행하는 곳입니다.
    • Request Header 에서 Access Token 을 꺼내고 여러가지 검사 후 유저 정보를 꺼내서 SecurityContext 에 저장합니다.
    • 가입/로그인/재발급을 제외한 모든 Request 요청은 이 필터를 거치기 때문에 토큰 정보가 없거나 유효하지 않으면 정상적으로 수행되지 않습니다.
    • 그리고 요청이 정상적으로 Controller 까지 도착했다면 SecurityContext 에 Member ID 가 존재한다는 것이 보장됩니다.
    • 대신 직접 DB 를 조회한 것이 아니라 Access Token 에 있는 Member ID 를 꺼낸 거라서, 탈퇴로 인해 Member ID 가 DB 에 없는 경우 등 예외 상황은 Service 단에서 고려해야 합니다.

4.3. JwtSecurityConfig

// 직접 만든 TokenProvider 와 JwtFilter 를 SecurityConfig 에 적용할 때 사용
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final TokenProvider tokenProvider;

    // TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
  • SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> 인터페이스를 구현하는 구현체입니다.
  • 여기서 직접 만든 JwtFilter 를 Security Filter 앞에 추가합니다.

4.4. JwtAuthenticationEntryPoint

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}
  • 유저 정보 없이 접근하면 SC_UNAUTHORIZED (401) 응답을 내려줍니다.

4.5. JwtAccessDeniedHandler

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
  • 유저 정보는 있으나 자원에 접근할 수 있는 권한이 없는 경우 SC_FORBIDDEN (403) 응답을 내려줍니다.

4.6. SecurityConfig

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // h2 database 테스트가 원활하도록 관련 API 들은 전부 무시
    @Override
    public void configure(WebSecurity web) {
        web.ignoring()
            .antMatchers("/h2-console/**", "/favicon.ico");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
            // CSRF 설정 Disable
        http.csrf().disable()

            // exception handling 할 때 우리가 만든 클래스를 추가
            .exceptionHandling()
            .authenticationEntryPoint(jwtAuthenticationEntryPoint)
            .accessDeniedHandler(jwtAccessDeniedHandler)

            // h2-console 을 위한 설정을 추가
            .and()
            .headers()
            .frameOptions()
            .sameOrigin()

            // 시큐리티는 기본적으로 세션을 사용
            // 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
            .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

            // 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
            .and()
            .authorizeRequests()
            .antMatchers("/auth/**").permitAll()
            .anyRequest().authenticated()   // 나머지 API 는 전부 인증 필요

            // JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
            .and()
            .apply(new JwtSecurityConfig(tokenProvider));
    }
}
  • WebSecurityConfigurerAdapter 인터페이스의 구현체입니다.
  • Spring Security 의 가장 기본적인 설정이며 JWT 를 사용하지 않더라도 이 설정은 기본으로 들어갑니다.
  • 오버라이드한 configure 내부에서 각종 설정들을 추가해줍니다.
  • 각 설정에 대한 설명은 주석을 확인하면 됩니다.

4.7. SecurityUtil

@Slf4j
public class SecurityUtil {

    private SecurityUtil() { }

    // SecurityContext 에 유저 정보가 저장되는 시점
    // Request 가 들어올 때 JwtFilter 의 doFilter 에서 저장
    public static Long getCurrentMemberId() {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || authentication.getName() == null) {
            throw  new RuntimeException("Security Context 에 인증 정보가 없습니다.");
        }

        return Long.parseLong(authentication.getName());
    }
}
  • JwtFilter 에서 SecurityContext 에 세팅한 유저 정보를 꺼냅니다.
  • 저는 무조건 memberId 를 저장하게 했으므로 꺼내서 Long 타입으로 파싱하여 반환합니다.
  • SecurityContextThreadLocal 에 사용자의 정보를 저장합니다.

5. Refresh Token 저장소

Access Token 과 Refresh Token 을 함께 사용하기 때문에 저장이 필요합니다.

보통은 Token 이 만료될 때 자동으로 삭제 처리 하기 위해 Redis 를 많이 사용하지만, 귀찮으니 일단 임시로 RDB 에 저장하는 방식으로 구현했습니다.

만약 지금 예제처럼 RDB 를 저장소로 사용한다면 배치 작업을 통해 만료된 토큰들을 삭제해주는 작업이 필요합니다.


5.1. RefreshToken

@Getter
@NoArgsConstructor
@Table(name = "refresh_token")
@Entity
public class RefreshToken {

    @Id
    @Column(name = "rt_key")
    private String key;

    @Column(name = "rt_value")
    private String value;

    @Builder
    public RefreshToken(String key, String value) {
        this.key = key;
        this.value = value;
    }

    public RefreshToken updateValue(String token) {
        this.value = token;
        return this;
    }
}
  • key 에는 Member ID 값이 들어갑니다.
  • value 에는 Refresh Token String 이 들어갑니다.
  • 위에서 언급한대로 RDB 로 구현하게 된다면 생성/수정 시간 컬럼을 추가하여 배치 작업으로 만료된 토큰들을 삭제해주어야 합니다.

5.2. RefreshTokenRepository

@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByKey(String key);
}
  • Member ID 값으로 토큰을 가져오기 위해 findByKey 만 추가했습니다.

6. 사용자 인증 과정

지금까지 스프링 시큐리티와 JWT 를 사용하기 위한 설정들을 전부 끝냈습니다.

지금부터는 실제로 사용자 로그인 요청이 들어왔을 때 인증 처리 후에 JWT 토큰을 발급하는 과정을 알아봅니다.

  • AuthController
  • AuthService
  • CustomUserDetailsService

6.1. AuthController

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
    private final AuthService authService;

    @PostMapping("/signup")
    public ResponseEntity<MemberResponseDto> signup(@RequestBody MemberRequestDto memberRequestDto) {
        return ResponseEntity.ok(authService.signup(memberRequestDto));
    }

    @PostMapping("/login")
    public ResponseEntity<TokenDto> login(@RequestBody MemberRequestDto memberRequestDto) {
        return ResponseEntity.ok(authService.login(memberRequestDto));
    }

    @PostMapping("/reissue")
    public ResponseEntity<TokenDto> reissue(@RequestBody TokenRequestDto tokenRequestDto) {
        return ResponseEntity.ok(authService.reissue(tokenRequestDto));
    }
}
  • 회원가입 / 로그인 / 재발급 을 처리하는 API 입니다.
  • SecurityConfig 에서 /auth/** 요청은 전부 허용했기 때문에 토큰 검증 로직을 타지 않습니다.
  • MemberRequestDto 에는 사용자가 로그인 시도한 ID / PW String 이 존재합니다.
  • TokenRequestDto 에는 재발급을 위한 AccessToken / RefreshToken String 이 존재합니다.

6.2. AuthService

@Service
@RequiredArgsConstructor
public class AuthService {
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;

    @Transactional
    public MemberResponseDto signup(MemberRequestDto memberRequestDto) {
        if (memberRepository.existsByEmail(memberRequestDto.getEmail())) {
            throw new RuntimeException("이미 가입되어 있는 유저입니다");
        }

        Member member = memberRequestDto.toMember(passwordEncoder);
        return MemberResponseDto.of(memberRepository.save(member));
    }

    @Transactional
    public TokenDto login(MemberRequestDto memberRequestDto) {
        // 1. Login ID/PW 를 기반으로 AuthenticationToken 생성
        UsernamePasswordAuthenticationToken authenticationToken = memberRequestDto.toAuthentication();

        // 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분
        //    authenticate 메서드가 실행이 될 때 CustomUserDetailsService 에서 만들었던 loadUserByUsername 메서드가 실행됨
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // 3. 인증 정보를 기반으로 JWT 토큰 생성
        TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

        // 4. RefreshToken 저장
        RefreshToken refreshToken = RefreshToken.builder()
                .key(authentication.getName())
                .value(tokenDto.getRefreshToken())
                .build();

        refreshTokenRepository.save(refreshToken);

        // 5. 토큰 발급
        return tokenDto;
    }

    @Transactional
    public TokenDto reissue(TokenRequestDto tokenRequestDto) {
        // 1. Refresh Token 검증
        if (!tokenProvider.validateToken(tokenRequestDto.getRefreshToken())) {
            throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
        }

        // 2. Access Token 에서 Member ID 가져오기
        Authentication authentication = tokenProvider.getAuthentication(tokenRequestDto.getAccessToken());

        // 3. 저장소에서 Member ID 를 기반으로 Refresh Token 값 가져옴
        RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
                .orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));

        // 4. Refresh Token 일치하는지 검사
        if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) {
            throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
        }

        // 5. 새로운 토큰 생성
        TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

        // 6. 저장소 정보 업데이트
        RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
        refreshTokenRepository.save(newRefreshToken);

        // 토큰 발급
        return tokenDto;
    }
}

#회원가입 (signup)

  • 평범하게 유저 정보를 받아서 저장합니다.

#로그인 (login)

  • Authentication
    • 사용자가 입력한 Login ID, PW 로 인증 정보 객체 UsernamePasswordAuthenticationToken를 생성합니다.
    • 아직 인증이 완료된 객체가 아니며 AuthenticationManager 에서 authenticate 메소드의 파라미터로 넘겨서 검증 후에 Authentication 를 받습니다.
  • AuthenticationManager
    • 스프링 시큐리티에서 실제로 인증이 이루어지는 곳입니다.
    • authenticate 메소드 하나만 정의되어 있는 인터페이스며 위 코드에서는 Builder 에서 UserDetails 의 유저 정보가 서로 일치하는지 검사합니다.
    • 그런데 코드상으로는 전혀 구현된게 없는데 어떻게 된 걸까요?
    • 내부적으로 수행되는 검증 과정은 아래의 CustomUserDetailsService 클래스에서 다루겠습니다.
  • 인증이 완료된 authentication 에는 Member ID 가 들어있습니다.
  • 인증 객체를 바탕으로 Access Token + Refresh Token 을 생성합니다.
  • Refresh Token 은 저장하고, 생성된 토큰 정보를 클라이언트에게 전달합니다.

#재발급 (reissue)

  • Access Token + Refresh Token 을 Request Body 에 받아서 검증합니다.
  • Refresh Token 의 만료 여부를 먼저 검사합니다.
  • Access Token 을 복호화하여 유저 정보 (Member ID) 를 가져오고 저장소에 있는 Refresh Token 과 클라이언트가 전달한 Refresh Token 의 일치 여부를 검사합니다.
  • 만약 일치한다면 로그인 했을 때와 동일하게 새로운 토큰을 생성해서 클라이언트에게 전달합니다.
  • Refresh Token 은 재사용하지 못하게 저장소에서 값을 갱신해줍니다.

6.3. CustomUserDetailsService

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return memberRepository.findByEmail(username)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
    }

    // DB 에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴
    private UserDetails createUserDetails(Member member) {
        GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString());

        return new User(
                String.valueOf(member.getId()),
                member.getPassword(),
                Collections.singleton(grantedAuthority)
        );
    }
}
  • UserDetailsService 인터페이스를 구현한 클래스입니다.
  • loadUserByUsername 메소드를 오버라이드 하는데 여기서 넘겨받은 UserDetailsAuthentication 의 패스워드를 비교하고 검증하는 로직을 처리합니다.
  • 물론 DB 에서 username 을 기반으로 값을 가져오기 때문에 아이디 존재 여부도 자동으로 검증 됩니다.
  • loadUserByUsername 메소드를 어디서 호출하는지 내부를 타고 들어가봅니다.

6.3.1. CustomUserDetailsService

loadUserByUsername 는 여러 곳에서 호출하고 있는데 이 중에서 DaoAuthenticationProvider 내부를 확인해봅니다.


6.3.2. DaoAuthenticationProvider

  • username 을 받아서 넘겨주는 retrieveUser 메소드 내부에서 호출합니다.
  • 그럼 이 retrieveUser 는 어디서 호출할까요?

6.3.3. AbstractUserDetailsAuthenticationProvider

  • DaoAuthenticationProvider 의 부모 클래스인 AbstractUserDetailsAuthenticationProvider 에서 호출합니다.
  • 코드를 쭉 보니 받아온 user 변수로 additionalAuthenticationChecks 메소드를 호출합니다.
  • 메소드를 확인해보니 추상 클래스였고, DaoAuthenticationProvider 를 다시 확인해보니 오버라이드 해서 구현이 되어 있었습니다.

6.3.4. 다시 DaoAuthenticationProvider

  • 실제로 비밀번호 검증이 이루어지는 부분입니다 !
  • Request 로 받아서 만든 authentication 와 DB 에서 꺼낸 값인 userDetails 의 비밀번호를 비교합니다.
  • DB 에 있는 값은 암호화된 값이고 사용자가 입력한 값은 raw 값이지만 passwordEncoder 가 알아서 비교해줍니다.
  • 그래서 결국 비밀번호 검증이 시큐리티가 제공하는 클래스에서 이루어지는 것을 확인했는데 로그인 시에 사용되는 AuthenticationManager 와는 무슨 관계일까요?
  • AbstractUserDetailsAuthenticationProviderauthenticate 를 어디에서 호출하는지 확인해봅니다.

  • AbstractUserDetailsAuthenticationProviderauthenticate 는 단 한곳에서 호출합니다.

6.3.5. ProviderManager

  • 여기서도 authenticate 라는 메소드네요.
  • AuthenticationProvider 라는 인터페이스에서 호출하는데요.
  • 이름으로 짐작할 수 있듯이 AbstractUserDetailsAuthenticationProvider 의 상위 인터페이스입니다.
  • 그리고 ProviderManager.authenticate 를 호출하는 곳을 확인해보니 드디어 찾을 수 있었습니다.

6.3.6. AuthService

  • ProviderManagerAuthenticationManager 의 구현체입니다.
  • 지금까지의 탐구 과정을 역으로 다시 가보면 어떤 순서로 비밀번호 검증이 이루어지는 지 알 수 있습니다.

  1. AuthService (그림에서는 오타) 에서 AuthenticationManagerBuilder 주입 받음
  2. AuthenticationManagerBuilder 에서 AuthenticationManager 를 구현한 ProviderManager 생성
  3. ProviderManager 는 AbstractUserDetailsAuthenticationProvider 의 자식 클래스인 DaoAuthenticationProvider 를 주입받아서 호출
  4. DaoAuthenticationProvider 의 authenticate 에서는 retrieveUser 로 DB 에 있는 사용자 정보를 가져오고 additionalAuthenticationChecks 로 비밀번호 비교
  5. retrieveUser 내부에서 UserDetailsService 인터페이스를 직접 구현한 CustomUserDetailsService 클래스의 오버라이드 메소드인 loadUserByUsername 가 호출됨

7. API 호출 테스트

이제 서버를 띄우고 실제로 API 호출을 해봅니다.

API 요청은 인텔리제이에 있는 http Tool 을 사용했습니다.


7.1. 가입

# Request
POST http://localhost:8080/auth/signup
Content-Type: application/json

{
  "email": "test@test.net",
  "password": "1q2w3e4r"
}

# Response
{
  "email": "test@test.net"
}

7.2. 로그인

# Request
POST http://localhost:8080/auth/login
Content-Type: application/json

{
  "email": "test@test.net",
  "password": "1q2w3e4r"
}

# Response
{
  "grantType": "bearer",
  "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ",
  "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2MTU3MTcyODB9.DKqk-EZVT0TJAvvHpSN8nClIHKq-k4KYMHpx-Ltf7V8OB6Og4D_dsYnr3Z4Rw7iR7ckv-ZWMyi5SkheESw-T0g",
  "accessTokenExpiresIn": 1615114280584
}

7.3. 일반 API 요청

# Request
GET http://localhost:8080/member/me
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ

# Response
{
  "email": "test@test.net"
}
  • 사용자 요청 -> JwtFiletr (SecurityContext 세팅) -> Controller -> Service

7.4. 재발급

# Request
POST http://localhost:8080/auth/reissue
Content-Type: application/json

{
  "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ",
  "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2MTU3MTcyODB9.DKqk-EZVT0TJAvvHpSN8nClIHKq-k4KYMHpx-Ltf7V8OB6Og4D_dsYnr3Z4Rw7iR7ckv-ZWMyi5SkheESw-T0g"
}

# Response
{
  "grantType": "bearer",
  "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDM2NX0.5VXa6Cht_DPEEGe7-BrElvsrs7qRXmVnkDdi4Lm3PxZ0vAgqFdirhe5RlE1D-Wc1zaUepBmGhhw-u-oP_-rbKQ",
  "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2MTU3MTczNjV9.tZytWyCWkWIYitvT3pa8FSnxilBDMtSevUzKRFK21TGLITf2eLXEwNNS_Q7rylD9uUe3Rx9ZR2NVqE_ZNWxTqg",
  "accessTokenExpiresIn": 1615114365284
}

Reference

  1. 이전 댓글 더보기
  2. 익명 2021.08.23 10:13

    비밀댓글입니다

    • 익명 2021.08.25 13:47

      비밀댓글입니다

    • 익명 2021.08.25 17:19

      비밀댓글입니다

  3. 익명 2021.09.05 18:56

    비밀댓글입니다

    • 익명 2021.09.11 15:02

      비밀댓글입니다

  4. 이동석 2021.10.31 14:56

    글 정말 잘보았습니다. 특히 DaoAuthentication... 부분이 인상적이었네요, 도움 많이 받았습니다~!

  5. kangheon 2021.11.05 22:52

    좋은글 감사합니다. 덕분에 JWT 기본적인 원리를 배웠습니다.

  6. 공대키메라 2021.12.20 21:49 신고

    진짜 너무좋네요 엉 엉...

  7. 수박통통 2022.01.05 19:55 신고

    안녕하세요 너무너무 잘봤습니다!ㅠㅠ 저도 인프런 JWT Tutorial 을 봤는데 거기선 로그인부분에서 Header에서도 Bearer+토큰 을 넣던것 같던데, 포스트에서는 Body에만 토큰과 type을 넣는 것같은데.. Header에 안넣는 이유가 있을까요?

    • 뱀귤 2022.01.06 01:58 신고

      @수박통통
      안녕하세요.
      위 포스팅에서는 로그인할 때는 헤더에 토큰을 넣지 않고 일반 API 요청할 때 `Authorization: Bearer {token}` 형식으로 헤더에 값을 넣어주고 있습니다.
      혹시 어떤걸 말씀하시는지 구체적으로 알 수 있을까요?

    • 수박통통 2022.01.06 05:59 신고

      아 강의에서는 로그인API에 헤더에도 토큰을 넣는 것 같아서, 혹시 토큰을 바디에만 넣는 이유가 있을까 해서 여쭤봤었습니다! 지금은 해당 강의의 강사님께서 "헤더와 바디 두 곳에 담아서 반환할 수 있다는 것을 보여드리고 싶어서 두 곳 모두에 토큰을 넣었다"라고 답변하신 질문글을 봐서 해소됐습니다! 답글 달아주셔서 너무 감사합니다:-)

  8. 홋메 2022.01.09 22:14

    안녕하세요 정리하신 글 잘보았습니다. 덕분에 사이드 프로젝트에서 jwt 구현을 대부분 할 수 있었습니다. 한가지 여쭤보고 싶은 부분이 있는데, Access token이 만료되었을 때 처리하는 부분이 없어서 어떻게 처리하셨는지 궁금합니다.

    • 뱀귤 2022.01.10 04:35 신고

      @홋메
      안녕하세요. AccessToken 이 만료된 경우에는 `tokenProvider.validateToken(jwt)` 코드가 false 를 반환하기 때문에 SecurityContextHolder 에 값 세팅이 되지 않고 `JwtAuthenticationEntryPoint` 에 의해 401 응답을 내려줄겁니다.
      혹시 클라이언트 입장을 말씀하신 거라면 Refresh Token 과 함께 새 Access Token 발급을 요청해야 합니다.

  9. 익명 2022.01.14 23:29

    비밀댓글입니다

    • 익명 2022.01.17 03:53

      비밀댓글입니다

  10. pinako 2022.01.29 01:48

    안녕하세요!!
    혹시 TokenDto는 어떻게 구성되어있는지 알 수 있을까요???

    • 뱀귤 2022.01.29 21:53 신고

      @pinako

      @Getter
      @NoArgsConstructor
      @AllArgsConstructor
      @Builder
      public class TokenDto {

      private String grantType;
      private String accessToken;
      private String refreshToken;
      private Long accessTokenExpiresIn;
      }

      TokenDto 클래스는 이렇게 구성되어 있습니다.
      코드상으로 더 궁금하신 점이 있으면 본문 맨 밑에 있는 Github 전체 코드 참고해주시면 됩니다

  11. 익명 2022.02.26 23:19

    비밀댓글입니다

    • 익명 2022.02.27 05:32

      비밀댓글입니다

  12. 익명 2022.02.27 15:01

    비밀댓글입니다

    • 익명 2022.03.02 23:31

      비밀댓글입니다

  13. 아이유 2022.02.28 17:11

    좋은 글 정말 잘봤습니다.
    보면서 사이드 프로젝트에 맞게 변형하면서 하고있는데 JwtFiltter부분에서 "doFilter 들어옴" 이 출력되고 "유효한 JWT 토큰이 없습니다, uri: /login."
    라고 오류가 나고있습니다. 토큰생성부분에 임의로 값을 넣어서 테스트 했을때는 토큰이 잘 생성되는데 authentication으로 받아왔을때 문제가 생기는건지
    아니면 다른곳을 고쳐야되는지 여쭤보고싶습니다 ㅠㅠ

    • 뱀귤 2022.03.02 23:35 신고

      @아이유

      안녕하세요.
      JwtFilter 에서 발생한다면 토큰을 가져오는 부분에서 문제가 발생한 것 같아요
      헤더의 키 값이 맞는지, 토큰을 제대로 가져오는지 로그를 찍어서 확인해봐야 할 것 같습니다.

  14. kong 2022.03.23 12:57 신고

    좋은 글 감사합니다..! 그대로 따라해 보다가 궁금한 점이 있어서 질문 드립니다 ! 혹시 application.yml에서 secret key값을 base64로 인코딩한 이유를 알 수 있을까요 ??

    • 뱀귤 2022.03.24 17:29 신고

      @kong
      안녕하세요.
      해당부분은 저도 강의를 보면서 따라해서 정확한 이유는 모르겠네요
      JWT 도 base64 인코딩된 문자열을 사용하기 때문이 아닌가 싶네요

  15. muskDeer 2022.03.30 17:52

    안녕하세요. 작년에 작성하신 게시글을 오늘 보고 열심히 작업하였습니다.
    react + spring boot 환경에서 작업하고있습니다.

    게시글을 보며 작업하다가 한가지 오류가 발생하였고, 원인을 발견한 후 수정하였습니다.

    RefreshToken.java 의 id값 과 관련되어있는데,
    @Id
    private String key;

    의 이름이 key이기 때문에 table을 생성할 당시 에러가 발생합니다.

    파라미터 명 key를 다른 이름, 예를들어 tokenKey로 수정할 경우 이러한 오류가 발생하지 않는것이 확인되었습니다.

    • 뱀귤 2022.04.02 02:43 신고

      @muskDeer
      안녕하세요.
      아마 DB 종류에 따라 다를 것 같은데 혼란을 방지하기 위해 컬럼명을 명시적으로 지정하는게 좋다고 생각했습니다.
      말씀해주신 부분 수정해서 반영해두었습니다.
      감사합니다!

  16. 익명 2022.04.05 22:51

    비밀댓글입니다

    • 익명 2022.04.07 11:43

      비밀댓글입니다

  17. 행인 2022.04.13 01:22

    좋은 글 정말 정말 잘 보고 갑니다.
    하나하나 다 읽어봤어요.

    메서드 다 따라가면서 확인 하시는 부분은 정말 대단하신 것 같습니다.

    덕분에 많이 배웠습니다. 감사합니다.

  18. 익명 2022.04.19 16:09

    비밀댓글입니다

    • 익명 2022.04.22 12:17

      비밀댓글입니다

    • 익명 2022.04.24 14:56

      비밀댓글입니다

  19. 안녕하세요 2022.05.26 01:21

    안녕하세요.
    덕분에 잘 구현했습니다!

    혹시 컨트롤러에서 로그인한 유저의 정보를 바탕으로 기능을 구현하고 싶은데,

    어떻게 하면 될까요?

    매개변수로 Authentication 받아서 어떻게 하는지 감이 안잡힙니다..

    • 뱀귤 2022.05.26 22:11 신고

      @안녕하세요
      4.7. SecurityUtil 클래스를 참고하시면 SecurityContextHolder 에서 꺼낸 Authentication 클래스의 getName() 을 호출하는 것을 보실 수 있습니다.
      해당 정보가 JwtFilter 인증 완료 후에 세팅된 memberId 정보라서 이 값을 이용하시면 됩니다.

  20. spring 2022.06.08 18:09

    안녕하세요! 혹시 SecurityConfig에서 회원가입 api를 permitAll()해줬는데, request.getHeader(AUTHORIZATION_HEADER) must not be null라는 에러가 뜨는 이유는 무엇인가요?

    • 뱀귤 2022.06.17 22:28 신고

      @spring
      안녕하세요. 답변이 늦었습니다.
      permitAll() 이 정상적으로 동작한다면 Header 에 토큰값이 없어도 괜찮을거에요
      뭔가 다른 이유가 있는 것 같은데 코드나 로그가 없는 상태에서는 제가 파악할 수 있는게 없네요 ㅠ

  21. 피카 2022.06.28 18:21

    좋은 자료 감사합니다. 아래 내용과 관련하여 질의 드립니다.

    액세스 토큰 재발급 로직 중에, 리프레시 토큰의 만기일은 체크하지 않고 DB에 저장된 리프레시 토큰과의 일치 여부만 확인하는 방식을 채택하신 이유가 있을까요?
    - 이 경우, 작성하신 글처럼 만료된 리프레시 토큰들을 제거해줘야 하는 번거로움이 발생하고,
    - 재발급 과정에서 리프레시 토큰의 만기가 도래하였을 경우, 리프레시 토큰도 함께 재발급 하는 등의 후속 조취가 어려움 등 단점들이 많을 것 같아서요.

    • 뱀귤 2022.06.28 23:42 신고

      @피카

      안녕하세요.
      리프레시 토큰은 원래 RDB 보다는 Redis 같은 저장소에서 자동으로 만료 되게 처리하는 걸 추천하는 편입니다.
      여기서는 Redis 환경까지 구성하는 건 주제에서 좀 엇나가는 것 같아서 예제에 집중하기 위해 Local DB 만으로 처리하도록 작성했습니다.
      RDB 로 구성한다면 말씀하신 것처럼 날짜 기준으로 필터링 한 후 가장 최근 값을 가져와야 합니다.

+ Recent posts