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

사용자가 로그인 시 서버는 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;
}
위와 같이 만들었음
이제 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);
}
}
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 알고리즘 사용
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("유효하지 않은 토큰입니다");
}
}
public String getEmailFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key) // 서명을 검증하기 위해 키를 설정
.build() // 파서를 빌드하여 실제 검증 작업을 수행할 준비 완료
.parseClaimsJws(token) // 전달받은 토큰을 파싱하여 Claims 객체 반환
.getBody() // Claims 객체에서 페이로드(body)를 가져옴
.getSubject(); // 페이로드에서 subject 필드(사용자 이메일)를 가져옴
}
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();
}
}
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
}
}
JWT는 Authorization 헤더에 포함된다.
Authorization: Bearer <JWT>
실제 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);
}
@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();
}
Access Token은 보안상의 이유로 짧은 유효기간(예: 15분~1시간)을 설정함
만료된 Access Token은 더 이상 사용할 수 없으므로 클라이언트는 새로운 Access Token을 발급받아야 함
Refresh Token은 Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위해 사용됨
Refresh Token은 상대적으로 긴 유효기간(예: 7일~30일)을 가지며, 서버에서 안전하게 관리됨
추후에 필요시 구현하기
지금 당장은 꼭 필요한지 모르겠다
@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 생성 후 반환
}
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody MemberLoginRequest memberLoginRequest){
String token = authService.login(memberLoginRequest);
return ResponseEntity.ok(token);
}
POST /api/auth/login
Content-Type: application/json
{
"mbEmail": "test@example.com",
"mbPassword": "password123"
}
이와 같은 요청이 오는데
public ResponseEntity<String> login(@RequestBody MemberLoginRequest memberLoginRequest){
🌟 전체 흐름

@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);
}

@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));
}
@Test
@DisplayName("로그인 실패 테스트 - 존재하지 않는 이메일")
void notFoundEmail(){
Mockito.when(memberRepository.findByMbEmail(Mockito.anyString())).thenReturn(Optional.empty());
assertThrows(NotFoundException.class, ()->{
authService.login(new MemberLoginRequest("testmail", "testpass"));
});
}
private JwtProvider jwtProvider;
private final String secretKey = "7c83e8ed501e28981f123d88ca87fa8a61277945c39b18a14ec8816986f96399"; // 테스트용 시크릿 키
private final String testEmail = "test@example.com";
@BeforeEach
void setUp() {
jwtProvider = Mockito.spy(new JwtProvider(secretKey));
}
@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);
}
@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);
}
@Test
@DisplayName("유효하지 않은 토큰 검증 실패")
void validateToken_invalidToken_returnsFalse() {
// 유효하지 않은 토큰 생성
String invalidToken = "wrong-token";
doReturn(false).when(jwtProvider).validateToken(invalidToken);
// 유효하지 않은 토큰 검증 (실패 예상)
boolean isValid = jwtProvider.validateToken(invalidToken);
// 검증 결과가 false인지 확인
assertFalse(isValid);
}
@Test
@DisplayName("유효한 토큰 검증 시 예외 발생하지 않음")
void validateTokenOrThrow_validToken_noException() {
// 테스트 이메일로 토큰 생성
String token = "valid-token";
doNothing().when(jwtProvider).validateTokenOrThrow(token);
// 예외가 발생하지 않는지 확인
assertDoesNotThrow(() -> jwtProvider.validateTokenOrThrow(token));
}
@Test
@DisplayName("유효하지 않은 토큰 검증 시 예외 발생")
void validateTokenOrThrow_invalidToken_throwsException() {
// 유효하지 않은 토큰 생성
String invalidToken = "inValid-token";
doThrow(new TokenIsNotValidException("유효하지 않은 토큰입니다.")).when(jwtProvider).validateTokenOrThrow(invalidToken);
// TokenIsNotValidException 예외가 발생하는지 확인
assertThrows(TokenIsNotValidException.class, () -> jwtProvider.validateTokenOrThrow(invalidToken));
}
@Test
@DisplayName("토큰에서 이메일 추출 테스트")
void getEmailFromToken() {
// 테스트 이메일로 토큰 생성
String token = "valid-token";
doReturn(testEmail).when(jwtProvider).getEmailFromToken(token);
// 토큰에서 이메일 추출
String email = jwtProvider.getEmailFromToken(token);
// 추출한 이메일, 테스트 이메일 비교
assertEquals(email, testEmail);
}
generateToken(String email): 사용자 이메일로 JWT 토큰 생성
validateToken(String token): 토큰 유효성 검증
getEmailFromToken(String token): 토큰에서 이메일 추출
JwtAuthenticationFilter: 요청 헤더에서 JWT 토큰을 추출하고 검증하는 필터
모든 요청에 대해 Authorization 헤더에서 Bearer 토큰을 추출
토큰 검증 후 인증 정보를 SecurityContext에 설정
PasswordEncoder: 비밀번호 암호화 및 검증
BCrypt 알고리즘을 사용한 비밀번호 암호화
로그인 시 비밀번호 일치 여부 검증
사용자 정보(이메일, 이름, 비밀번호, 연락처 등) 저장
블로그 생성 및 카테고리 설정
로그인 API: /api/auth/login
이메일과 비밀번호로 사용자 인증
인증 성공 시 JWT 토큰 발급
CSRF 보호 비활성화 (REST API이므로)
세션 관리 정책을 STATELESS로 설정
인증이 필요한 경로와 필요하지 않은 경로 설정
JwtAuthenticationFilter 등록
UnauthorizedException: 인증 실패 시 발생
TokenIsNotValidException: 유효하지 않은 토큰일 때 발생
JwtProviderTest: JWT 토큰 생성, 검증, 이메일 추출 기능 테스트