JWT 토큰 & 로그인 기능 구현

최인호·2025년 3월 17일
post-thumbnail

📌 JWT란?

JWT(JSON Web Token)는 사용자가 로그인에 성공한 후, 서버가 발급해주는 인증 토큰
이 토큰은 클라이언트가 서버에 요청할 때마다 자신이 인증된 사용자라는 것을 증명하는 데 사용됨

JWT의 구성요소

  • Header : 토큰의 타입(JWT)와 서명 알고리즘 포함
  • Payload : 사용자 정보 포함
  • Signature : 토큰이 변조되지 않았음을 보장하기 위해 생성된 서명

JWT 특징

  • REST API와 같은 상태 없는 통신에서 사용자 인증을 처리하는데 적합하다
  • 서버가 클라이언트의 상태(Session)를 저장할 필요가 없다.
  • 대부분의 인증 정보는 JWT(민감한 정보는 제외)에 포함되어 클라이언트와 함께 전송됨
  • 이러한 사이트에서 바로 복호화가 가능함

사용자가 로그인 시 서버는 JWT 발급
-> 이후 요청시마다 이 토큰을 통해 인증처리

  • JWT는 사용자 정보를 페이로드에 포함가능하기에 추가적인 데이터 전달이 가능 (Role, userID, 만료 시간 등..)

  • 서버가 상태를 저장하지 않기에 서버 부하 감소

  • 서명을 통해 토큰이 변조되지 않았음을 보장

  • 클라이언트가 서버로부터 받은 JWT만 있다면 서버는 추가적인 DB조회없이 사용자 인증 가능

  • 토큰에 만료시간 설정 가능함, 하지만 한번 발급되면 만료시간까지 유효하기 때문에 서버에서 강제로 만료시키기 어려움
    -> 사용자가 로그아웃해도 기존 토큰 유효

  • JWT는 모든 데이터를 자체적으로 포함하기에 크기가 커질 수 있음

  • 비밀키 유출시 모든 JWT가 위조될 수 있음

즉 REST API에서 인증 정보를 안전하게 받고 변조 방지를 위해 JWT

우선 로그인 요청 DTO를 만들어준다.
로그인 시에는 이메일, 패스워드만 있으면 되기 때문에

package com.nhnacademy.blog.member.dto;

import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class MemberLoginRequest {
    private String mbEmail;
    private String mbPassword;
}

위와 같이 만들었음

1. JWT 토큰 생성 컴포넌트

이제 JWT 토큰을 생성하고 검증하는 컴포넌트가 필요하다.
JwtTokenProvider.java

package com.nhnacademy.blog.common.security;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class JwtProvider {
    private final Key key;

    // @Value 어노테이션을 사용해 설정 파일에서 값을 주입받음
    public JwtProvider(@Value("${jwt.secret}") String secretKey) {
        // Base64로 인코딩된 비밀키를 디코딩하여 Key 객체 생성
        byte[] keyBytes = secretKey.getBytes();
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }


}
  • 스프링 빈으로 등록해서 다른 클래스에서 주입 받아 사용할 수 있도록 @Component
  • application.properties에 설정된 값 가져오기
    - secretKey : JWT 암호화시 사용하는 비밀키
    • validityInMilliseconds : JWT 토큰 만료될 때까지 유효시간 설정

2. JWT 토큰 생성 메소드

   public String generateToken(String email) {
        Date now = new Date();
        Date validity = new Date(now.getTime() + 3600000); // 1시간 유효

        return Jwts.builder()
                .setSubject(email) // 사용자 이메일을 subject로 설정
                .setIssuedAt(now) // 발급 시간 설정
                .setExpiration(validity) // 만료 시간 설정
                .signWith(key, SignatureAlgorithm.HS256) // Key 객체와 알고리즘을 사용해 서명
                .compact(); // 최종적으로 문자열 형태의 JWT 반환
    }
    }
  • subject는 JWT의 페이로드에 포함될 주요 데이터(사용자 식별 정보)

  • 로그인 성공시 사용자 이메일을 JWT subject 필드에 저장해서 이후 요청에서 사용자 식별 가능

  • 토큰이 일정 시간 후 만료되게 설정

  • HS256 알고리즘 사용

3. 토큰 검증 및 사용

 public boolean validateToken(String token){
        try{
            Jwts.parserBuilder()
                    .setSigningKey(key) // 서명을 검증하기 위해 키를 설정
                    .build()   // 파서를 빌드하여 실제 검증 작업을 수행할 준비를 완료
                    .parseClaimsJws(token); // 전달받은 토큰을 파싱하여 서명 및 구조가 올바른지 확인

            return true;
        }catch(Exception e){
            return false;
        }
    }
    
 // 유효하지 않은 토큰 예외
  
public void validateTokenOrThrow(String token) {
        //validateToken 메소드를 호출하여 토큰의 유효성을 확인
        if (!validateToken(token)) {
            throw new TokenIsNotValidException("유효하지 않은 토큰입니다");
        }
    }
  • Jwts.parserBuilder() : 서명을 검증하기 위해 키를 설정하는데 이때 이 키는 토큰 생성시 사용된 비밀키와 동일해야함
  • 이를 통해 클라이언트가 제공한 토큰이 변조되지 않았고, 서버에서 발급된 것인지 확인 가능
  • setSigningKey(key)는 토큰의 서명이 비밀키로 생성되었는지 확인하는 중요한 단계

3-1. JWT 이메일 추출

  public String getEmailFromToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key) // 서명을 검증하기 위해 키를 설정
                .build() // 파서를 빌드하여 실제 검증 작업을 수행할 준비 완료
                .parseClaimsJws(token) // 전달받은 토큰을 파싱하여 Claims 객체 반환
                .getBody() // Claims 객체에서 페이로드(body)를 가져옴
                .getSubject(); // 페이로드에서 subject 필드(사용자 이메일)를 가져옴
    }
  • 사용자가 로그인하면 이 메서드를 호출하여 JWT를 생성하고 클라이언트에게 반환

4. JWT를 활용하여 인증 및 권한 부여를 시스템에 통합하기

4-1. Spring Security와 JWT 통합

SecurityConfig 설정


@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtProvider jwtProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth ->
                        auth.requestMatchers("/api/auth/**").permitAll()
                                .anyRequest().authenticated());

        return httpSecurity.build();
    }
}
  • REST API는 클라이언트가 매번 인증 정보를 포함하여 요청하므로 CSRF 보호가 필요❌
  • 요청별로 보안 정책 설정
  • /api/auth/** 경로로 들어오는 요청은 인증 없이 접근가능하게 허용
  • 위에 명시된 경로를 제외한 모든 요청은 인증 필요

4-2. JwtAuthenticationFilter

  • 클라이언트가 요청시 전달한 JWT를 검증, 인증 정보를 SecurityContextHolder에 저장하는 역할
  • Spring Security Filter Chain에 등록되어 모든 요청 처리전 실행됨
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

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

    }
}
  • OncePerRequestFilter : Spring Security의 기본 필터를 확장하여 요청마다 한 번만 실행되는 필터

JWT는 Authorization 헤더에 포함된다.

  • 클라이언트는 서버에 요청시 JWT를 HTTP 헤더에 포함
Authorization: Bearer <JWT>
  • Bearer는 단순히 토큰임을 나타내는 접두사

실제 JWT는 Bearer뒤에 있는 값이므로 잘라내는 메소드

  // Authorization 헤더에서 Bearer 토큰 추출
    private String extractToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            return header.substring(7); // "Bearer " 이후의 토큰만 추출
        }
        return null; // 헤더가 없거나 Bearer로 시작하지 않으면 null 반환
    }

doFilterInternal
요청이 들어올 때마다 실행되는 메소드

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 요청 헤더에서 JWT 추출
        String token = extractToken(request);

        if(token!=null && jwtProvider.validateToken(token)){
            String email = jwtProvider.getEmailFromToken(token);
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(email, null, null);

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }
  • extractToken으로 실제 토큰 값 추출
  • 클라이언트가 JWT를 보내는지 확인 후 보냈다면 JWT가 유효한지 확인 + 서명이 올바른가, 만료되지 않았는가 등
  • getEmailFromToken을 통해 토큰에서 이메일 꺼낸 후
    SpringSecurity에서 사용하는 인증 객체 UsernamePasswordAuthenticationToken에다가 이메일만 인증정보로 설정
  • 이후 SpringSecurtiy 컨텍스트에 인증 정보 저장
  • 이를 이용해 추후 컨트롤러에서 인증된 사용자 정보 사용 가능
  • 필터 작업이 끝나면 요청을 다음 필터로 전달

4-3.만든 필터를 SecurityConfig에 추가하기

  @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth ->
                        auth.requestMatchers("/api/auth/**").permitAll()
                                .anyRequest().authenticated())  
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);

        return httpSecurity.build();
    }
  • Spring Security의 기본 인증 필터(UsernamePasswordAuthenticationFilter) 앞에 JwtAuthenticationFilter를 추가

4-4. RefreshToken 고민

Access Token은 보안상의 이유로 짧은 유효기간(예: 15분~1시간)을 설정함
만료된 Access Token은 더 이상 사용할 수 없으므로 클라이언트는 새로운 Access Token을 발급받아야 함
Refresh Token은 Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위해 사용됨
Refresh Token은 상대적으로 긴 유효기간(예: 7일~30일)을 가지며, 서버에서 안전하게 관리됨

추후에 필요시 구현하기
지금 당장은 꼭 필요한지 모르겠다

5. AuthController에 추가하기

5-1. AuthService 인터페이스에 로그인 메소드 추가


    @Override
    public String login(MemberLoginRequest memberLoginRequest) {
        Member member = memberRepository.findByMbEmail(memberLoginRequest.getMbEmail())
                .orElseThrow(()->new NotFoundException("이메일 또는 비밀번호가 일치하지 않습니다"));

        if (!passwordEncoder.matches(memberLoginRequest.getMbPassword(), member.getMbPassword())) {
            throw new UnauthorizedException("이메일 또는 비밀번호가 일치하지 않습니다.");
        }

        return jwtProvider.generateToken(member.getMbEmail()); // JWT 생성 후 반환 
    }
  • 이메일로 멤버 조회후 존재하지 않을시 예외 발생
  • 만약 입력된 비밀번호와 DB에 저장된 암호화된 비밀번호 비교 후 일치하지 않을 시 예외 발생
  • 인증 성공시 JWT 토큰 생성 후 반환

5-2. AuthController에 로그인 엔드 포인트 추가

   @PostMapping("/login")
    public ResponseEntity<String> login(@RequestBody MemberLoginRequest memberLoginRequest){
        String token = authService.login(memberLoginRequest);
        return ResponseEntity.ok(token);
    }
  • /api/auth/login 주소로 POST 요청시
POST /api/auth/login
Content-Type: application/json

{
    "mbEmail": "test@example.com",
    "mbPassword": "password123"
}

이와 같은 요청이 오는데

public ResponseEntity<String> login(@RequestBody MemberLoginRequest memberLoginRequest){
  • 클라이언트가 보낸 JSON 형식의 요청 데이터를 자바 객체(MemberLoginRequest)로 변환하여 받음
  • authService.login 메소드로 생성된 JWT를 클라이언트에게 반환
  • 클라이언트는 이 JWT를 사용해서 인증이 필요한 API 호출

🌟 전체 흐름

6. 추가된 기능 테스트하기 by Mockito

6-1. 로그인 성공 테스트

 @Test
    @DisplayName("로그인 성공 테스트")
    void loginSuccess() {
        // Given: 로그인 요청 데이터 준비
        MemberLoginRequest loginRequest = new MemberLoginRequest("test@nhnacademy.com", "password123123!");

        // Mock 설정: 데이터베이스에서 사용자 조회
        Member member = Member.ofNewMember(
                "test@nhnacademy.com",
                "TestUser",
                passwordEncoder.encode("password123123!"),
                "01012345678"
        );

        Mockito.when(memberRepository.findByMbEmail(Mockito.anyString())).thenReturn(Optional.of(member));
        // Mock 설정: JWT 생성 동작 정의 (Mock)
        Mockito.when(jwtProvider.generateToken(Mockito.anyString())).thenReturn("mock-jwt-token");

        // When: 로그인 로직 실행
        String token = authService.login(loginRequest);

        // Then: 결과 검증
        assertNotNull(token);
        assertEquals("mock-jwt-token", token);
    }
  • @Spy 어노테이션은 내가 모킹해주는 메소드에 한해서만 모킹하고 나머지는 실제 메소드가 동작
  • 이와 같이 설정해준 후 테스트 진행

6-2. 로그인 실패 테스트 - 비밀번호 불일치


    @Test
    @DisplayName("로그인 실패 테스트 - 비밀번호 불일치")
    void loginFail() {
        // Given: 로그인 요청 데이터 준비
        MemberLoginRequest loginRequest = new MemberLoginRequest("test@nhnacademy.com", "temp");

        // Mock 설정: 데이터베이스에서 사용자 조회
        Member member = Member.ofNewMember(
                "test@nhnacademy.com",
                "TestUser",
                passwordEncoder.encode("password123123!"),
                "01012345678"
        );

        Mockito.when(memberRepository.findByMbEmail(Mockito.anyString())).thenReturn(Optional.of(member));

        // When & Then: 로그인 로직 실행 시 UnauthorizedException 발생 확인
        assertThrows(UnauthorizedException.class, () -> authService.login(loginRequest));

    }
  • 로그인 실패 테스트
  • 요청 비밀번호, 저장된 비밀번호 일부러 실패하게 만든후 예외 확인

6-3. 로그인 실패 테스트 - 존재하지 않는 이메일

  @Test
    @DisplayName("로그인 실패 테스트 - 존재하지 않는 이메일")
    void notFoundEmail(){
        Mockito.when(memberRepository.findByMbEmail(Mockito.anyString())).thenReturn(Optional.empty());

        assertThrows(NotFoundException.class, ()->{
            authService.login(new MemberLoginRequest("testmail", "testpass"));
        });
    }
  • 메일로 멤버 조회시 일부러 Optional.empty 리턴
  • 이후 로그인 시 NotFound 예외 발생하는지 검증

7. JwtProvider 검증

7-1. 환경 설정

   private JwtProvider jwtProvider;
    private final String secretKey = "7c83e8ed501e28981f123d88ca87fa8a61277945c39b18a14ec8816986f96399"; // 테스트용 시크릿 키
    private final String testEmail = "test@example.com";

    @BeforeEach
    void setUp() {
        jwtProvider = Mockito.spy(new JwtProvider(secretKey));
    }
  • 테스트용 시크릿키 및 테스트 데이터
  • Mockito.spy로 JwtProvider 초기화

7-2. 토큰 생성 테스트

  @Test
    @DisplayName("토큰 생성 테스트")
    void generateToken() {

        // jwt 토큰이 mock-jwt-token 반환하도록 설정
        doReturn("mock-jwt-token").when(jwtProvider).generateToken(testEmail);

        // 테스트 이메일로 토큰 생성
        String token = jwtProvider.generateToken(testEmail);

        // 생성된 토큰이 NULL이 아닌지
        assertNotNull(token);

        // 생성된 토큰이 비어있지 않은지
        assertFalse(token.isEmpty());

        // mock-jwt-token인지
        assertEquals("mock-jwt-token", token);
    }
  • generateToken() 메소드가 정상적으로 실행되며, mock-jwt-token이 반환되는지 확인

7-3. 유효한 토큰 검증

 @Test
    @DisplayName("유효한 토큰 검증 성공")
    void validateToken_validToken_returnsTrue() {
        String token = "valid-token";
        // validateToken시 true 반환
        doReturn(true).when(jwtProvider).validateToken(token);

        // 생성된 토큰 검증 (성공 예상)
        boolean isValid = jwtProvider.validateToken(token);

        //  검증 결과가 true인지 확인
        assertTrue(isValid);
    }
  • validateToken()이 true를 반환하는지 테스트

7-4. 유효하지 않은 토큰 검증 (실패)

    @Test
    @DisplayName("유효하지 않은 토큰 검증 실패")
    void validateToken_invalidToken_returnsFalse() {
        // 유효하지 않은 토큰 생성
        String invalidToken = "wrong-token";
        doReturn(false).when(jwtProvider).validateToken(invalidToken);

        // 유효하지 않은 토큰 검증 (실패 예상)
        boolean isValid = jwtProvider.validateToken(invalidToken);

        // 검증 결과가 false인지 확인
        assertFalse(isValid);
    }
  • validateToken()이 false를 반환하는지 테스트

7-5. 유효한 토큰 검증 시 예외 발생 X

    @Test
    @DisplayName("유효한 토큰 검증 시 예외 발생하지 않음")
    void validateTokenOrThrow_validToken_noException() {
        //  테스트 이메일로 토큰 생성
        String token = "valid-token";
        doNothing().when(jwtProvider).validateTokenOrThrow(token);

        //  예외가 발생하지 않는지 확인
        assertDoesNotThrow(() -> jwtProvider.validateTokenOrThrow(token));
    }
  • validateTokenOrThrow()가 정상적으로 실행되는지 확인.

7-6. 유효하지 않은 토큰 검증 시 예외 발생

    @Test
    @DisplayName("유효하지 않은 토큰 검증 시 예외 발생")
    void validateTokenOrThrow_invalidToken_throwsException() {
        // 유효하지 않은 토큰 생성
        String invalidToken = "inValid-token";
        doThrow(new TokenIsNotValidException("유효하지 않은 토큰입니다.")).when(jwtProvider).validateTokenOrThrow(invalidToken);

        //  TokenIsNotValidException 예외가 발생하는지 확인
        assertThrows(TokenIsNotValidException.class, () -> jwtProvider.validateTokenOrThrow(invalidToken));
    }
  • TokenIsNotValidException이 발생하는지 검증.

7-7. 토큰에서 이메일 추출 테스트

  @Test
    @DisplayName("토큰에서 이메일 추출 테스트")
    void getEmailFromToken() {
        // 테스트 이메일로 토큰 생성
        String token = "valid-token";
        doReturn(testEmail).when(jwtProvider).getEmailFromToken(token);

        // 토큰에서 이메일 추출
        String email = jwtProvider.getEmailFromToken(token);

        // 추출한 이메일, 테스트 이메일 비교
        assertEquals(email, testEmail);
    }
  • getEmailFromToken()이 예상한 이메일 값을 반환하는지 확인.

추후에 공부할 내용

  • Refresh Token 관리 방식
    - DB저장? Redis?
    • Refresh Token 탈취방지 보안 이슈
  • JWT Payload
    - Payload 암호화 되지 않으므로 민감한 정보 포함 X
    • 추가 정보 필요시 DB 조회
  • JWT 발급 후 클라이언트에서 저장 방식
    - LocalStorage는 보안상 위험 -> HTTP ONLY 쿠키 사용 권장
    • 쿠키 사용지 CORS, Secure 설정 고려
  • 로그아웃 처리
    - Access Token은 클라이언트가 갖고 있기 때문에 서버에서 강제 만료 어렵
    - 보통 Refresh Token을 서버에서 삭제하는 방식으로 처리

내용 정리

  1. JWT 인증 시스템 구현
    JwtProvider: JWT 토큰 생성, 검증, 이메일 추출 기능 구현

generateToken(String email): 사용자 이메일로 JWT 토큰 생성

validateToken(String token): 토큰 유효성 검증

getEmailFromToken(String token): 토큰에서 이메일 추출

JwtAuthenticationFilter: 요청 헤더에서 JWT 토큰을 추출하고 검증하는 필터

모든 요청에 대해 Authorization 헤더에서 Bearer 토큰을 추출

토큰 검증 후 인증 정보를 SecurityContext에 설정

PasswordEncoder: 비밀번호 암호화 및 검증

BCrypt 알고리즘을 사용한 비밀번호 암호화

로그인 시 비밀번호 일치 여부 검증

  1. 인증 관련 API 구현
    회원가입 API: /api/auth/register

사용자 정보(이메일, 이름, 비밀번호, 연락처 등) 저장

블로그 생성 및 카테고리 설정

로그인 API: /api/auth/login

이메일과 비밀번호로 사용자 인증

인증 성공 시 JWT 토큰 발급

  1. 보안 설정
    SecurityConfig: Spring Security 설정

CSRF 보호 비활성화 (REST API이므로)

세션 관리 정책을 STATELESS로 설정

인증이 필요한 경로와 필요하지 않은 경로 설정

JwtAuthenticationFilter 등록

  1. 예외 처리
    NotFoundException: 리소스를 찾을 수 없을 때 발생

UnauthorizedException: 인증 실패 시 발생

TokenIsNotValidException: 유효하지 않은 토큰일 때 발생

  1. 테스트 코드
    AuthServiceImplTest: 회원가입 및 로그인 기능 테스트

JwtProviderTest: JWT 토큰 생성, 검증, 이메일 추출 기능 테스트

0개의 댓글