[JWT를 활용한 일반 로그인 API 구현] 과정을 기록해보려고 한다. 프로젝트 개발 환경은 아래와 같다.
Java: 17
JDK: 17.0.6
IDE: IntelliJ IDEA 2024.1
Framework: Spring Boot 3.3.1
JWT에 대한 설명은 아래 글을 참고하자.
Spring Security의 구조(Filter)와 인증(Authentication) 구조를 이해했다고 가정한 상태에서 쓰는 글이라 이해하기 어려울 수도 있다. 아래 글을 참고해서 흐름 정도만 알아 두자.
라이브러리 및 application.yml 설정
build.gradle - JJWT 의존성 추가
JWTs(JSON WEB TOKENs)와 JWKs(JSON Web Keys)를 쉽게 생성할 수 있게 해주는 JJWT(Java JWT)를 사용해서 구현했다.
- 자바 7 이상에서 사용 가능하다. (+Android)
- 표준 JWS 알고리즘을 사용해 디지털 서명된 JWTs를 생성하고 파싱하고 검증할 수 있다. 토큰 검증이 실패하면 JwtException을 발생시킨다.
- 표준 JWS 알고리즘 예시: HS256, ES256, RS512, PS512, EdDSA 등
dependencies {
...
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
// jjwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
application.yml
JJWT에 사용될 secret 값과 토큰 만료 시간을 설정해 둔다.
- JJWT에 들어갈 민감한 정보를 디지털 서명하기 위해 HMAC 알고리즘을 사용한 secretKey를 사용했다. 어떤 HMAC 알고리즘을 사용하느냐에 따라 secretKey의 최소 비트 수가 결정된다.
- HS256(HMAC with SHA-256): 256-bit (=32 byte) 이상
- HS384(HMAC with SHA-384): 384-bit (=48 byte) 이상
- HS512(HMAC with SHA-512): 512-bit (=64 byte) 이상
- secret 값은 Base64로 인코딩 된 문자열로 만들어야 한다. 이후 Base64로 Decoding 해 byte[]로 바꾼 뒤, 그 값을 이용해 secretKey를 생성한다. 따라서 secret 값을 decoding 해서 나온 byte 수가 최소 32 byte가 되도록 만들어야 한다. 길이가 부족하면 에러 메시지가 뜨므로 문자와 숫자를 섞어서 적당히 길게 만들어 두면 된다. (ex. kj21ji21i3j5j54i12ok0fdds99a79f9c...)
- secretKey로 서명할 때 특정 HMAC 알고리즘을 명시하지 않으면 byte 수에 맞는 알고리즘을 자동으로 선택한다.
- ex. secretKey의 byte 수 == 55 byte → HS384로 자동 설정
- 여기서 secret 값은 공개하면 보안상 문제가 되는 민감한 정보이기 때문에 IntelliJ 환경변수에서 값을 가져오게 만들었다. 토큰 만료 시간도 굳이 코드에 드러낼 필요는 없으니까 application.yml에 작성해 두는 게 좋다. 코드에선 @Value 애노테이션을 사용해 application.yml의 값을 가져올 수 있다.
spring:
...
jwt:
secret: ${JWT_SECRET} // 비밀 키는 공개하면 안 되므로 IntelliJ 환경변수로 빼서 사용하자.
token:
access_expiration: 7200000 // 2시간 = 1000 * 60 * 60 * 2
refresh_expiration: 2592000000 // 30일 = 1000 * 60 * 60 * 24 * 30
구현한 코드 흐름 정리하기
[JWT를 활용한 일반 로그인 API 구현]에 필요한 class를 모아 다이어그램을 만들어봤다. IntelliJ에서 class를 선택하고 마우스 오른쪽 버튼을 누른 뒤 맨 밑에 Diagrams를 클릭하면 만들 수 있다.
Class | Description |
MemberDetail | UserDetails의 구현체로, Member 객체가 담겨 있음 |
MemberDetailService | UserDetailsService의 구현체로, UserDetails 객체를 찾는 용도로 사용 (MemberDetail을 UserDetails에 담아서 반환) |
JwtUtil | 토큰 생성, 파싱, 검증 기능을 하는 class (추가로 Authentication에 넣을 UsernamePasswordAuthenticationToken을 반환) |
JwtAuthenticationFilter | JWT 토큰 인증을 지원하는 Security Filter |
JwtExceptionHandler | JwtException 예외를 처리할 핸들러 |
JwtExceptionHandlerFilter | JwtAuthenticationFilter의 예외를 처리해 줄 핸들러 Filter |
JwtAuthenticationEntryPoint | AuthenticationEntryPoint의 구현체로, 사용자에게 자격 증명을 요청할 때 에러 메시지가 통일된 응답으로 보내지도록 함 |
JwtAccessDeniedHandler | AccessDeniedException의 구현체로, 접근을 거절하는 에러 메시지가 통일된 응답으로 보내지도록 함 |
SecurityConfig | 기존 SecurityFilterChain에 앞에 구현해 둔 Filters와 핸들러를 추가하고, CORS, CSRF 등의 설정을 하는 Configuration |
UserDetail & UserDetailService
Spring Security의 인증(Authentication) 구조의 핵심은 Authentication 객체를 SecurityContextHolder에 설정하는 것이다. 따라서 로그인 기능을 구현하기 위해 Authentication 객체를 만드는 코드부터 구현한다.
JWT에 담긴 값을 기반으로 UserDetailsService를 사용해 UserDetails를 찾고, 찾은 UserDetails를
UsernamePasswordAuthenticationToken으로 만들어야 한다.
- UserDetails
- 사용자를 식별할 수 있는 정보(이름, 비밀번호)를 담은 객체다.
- UserDetailsService
- DaoAuthenticationProvider가 UserDetails를 찾기 위해 사용한다.
- 사용자 식별 정보를 바탕으로 사용자가 존재하는지 확인한 뒤, UserDetails 객체에 해당 사용자를 담아 반환한다.
- UsernamePasswordAuthenticationToken
- AbstractAuthenticationToken의 서브 class로, Authentication 객체에 담을 수 있다. 따라서 principal과 credentials, authorities를 갖는다.
원래는 아래처럼 AbstractAuthenticationProcessingFilter의 구현체와 AuthenticationProvider(DaoAuthenticationProvider)의 구현체를 만들어 사용해야 Spring Security를 제대로 사용했다고 할 수 있다.
- AbstractAuthenticationProcessingFilter에서 Authentication(ex. UsernamePasswordAuthenticationToken)을 확인한다.
- AuthenticationManager에 Authentication을 넘겨 인증 여부를 확인한다.
- Authentication이 UsernamePasswordAuthentication 타입이므로 ProviderManager를 통해 DaoAuthenticationProvider를 호출한다.
- DaoAuthenticationProvider는 Authentication에 담긴 Username을 UserDetailsService에 넘겨 UserDetails를 찾는다.
- 이후 Authentication에 담긴 Password를 PasswordEncoder로 검증한다.
- 위에서 예외가 발생하지 않는다면 인증이 성공한 것이므로 UsernamePasswordAuthenticationToken에 UserDetails와 Authorities를 담아 반환한다. 이후 해당 토큰은 Authentication에 담겨 SecurityContextHolder에 설정된다.
- 인증 실패와 성공 여부에 따라 각각 AuthenticationFailureHandler 또는 AuthenticationSuccessHandler를 호출해 처리한다.
- 구현할 때 기간 안에 급하게 구현하느라 블로그 글을 이것저것 참고했더니 이도저도 아닌 코드를 만든 것 같다. Spring Security에 어느 부분은 의존하고, 어느 부분은 따로 커스텀으로 들어 의존하지 않는 코드가 돼버렸다. 아무튼 인증 및 인가 기능은 제대로 동작하지만... 나중에 개인 프로젝트에서 Spring Security를 제대로 사용한 인증 및 인가 기능을 만들어 봐야겠다.
MemberDetail
MemberDetail은 UserDetails의 구현체로, 원래는 사용자를 식별할 수 있는 정보(이름, 비밀번호)만 넣으면 되지만 나는 그냥 회원 객체 자체를 넣어 놨다.
@RequiredArgsConstructor
@Getter
public class MemberDetail implements UserDetails {
// 유저의 인증을 담은 객체
private final Member member;
public static MemberDetail createMemberDetail(Member member) {
return new MemberDetail(member);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return null;
}
});
return collect;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
다른 백엔드 팀원들이 Member 엔티티를 사용하면서 문제가 생길까 봐 그냥 따로 MemberDetail class를 만들어 진행했다. 아래 코드처럼 Member 엔티티가 바로 UserDetails의 구현체가 되도록 구현해도 된다.
- 아래처럼 구현하면 엔티티 자체에 UserDetails의 메서드를 override하면서 코드가 복잡해지기도 하고, 엔티티가 Spring Security에 의존하게 되기 때문에 그냥 MemberDetail처럼 따로 만드는 게 나은 것 같다.
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@DynamicUpdate
@DynamicInsert
public class Member extends BaseEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 30)
private String name;
@Column(nullable = false, length = 100)
private String password;
...
}
MemberDetailService
MemberDetailService는 UserDetailsService의 구현체로, 회원 식별 정보(UserDetails)를 담은 UserDetails 객체를 반환하는 서비스 기능을 한다. JWT에 담긴 회원 이메일 값을 통해 회원을 찾고, 해당 회원을 UserDetails로 감싸 반환한다. 회원을 찾지 못하면 UsernameNotFoundException을 발생시킨다.
@Service
@Slf4j
@RequiredArgsConstructor
public class MemberDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
log.info("loadUserByUsername 함수 실행");
Optional<Member> member = memberRepository.findByEmail(email);
if (member.isEmpty()) throw new UsernameNotFoundException("해당 유저를 찾을 수 없습니다.");
return MemberDetail.createMemberDetail(member.get());
}
}
위에서 Member 엔티티 자체가 UserDetails의 구현체가 되도록 구현했다면, MemberRepository에서 회원을 찾고 해당 회원을 반환하도록 만들면 된다.
- 물론 MemberService 자체가 UserDetailsService의 구현체가 되도록 구현할 수 있다. 그래도 위와 비슷한 이유로 따로 구현체를 만들어서 사용하는 게 더 좋다.
@Service
@Slf4j
@RequiredArgsConstructor
public class MemberDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByName(username);
if (member == null) {
throw new UsernameNotFoundException("해당 유저가 없습니다.");
}
return member;
}
}
JwtUtil
토큰의 생성, 파싱, 검증 기능을 하는 class다. application.yml에서 설정한 JWT 관련 프로퍼티를 필드에 사용한다.
위에서 만든 두 class로 UsernamePasswordAuthenticationToken을 만드는 메서드도 추가한다. 이 Token은 Authentication 객체에 담겨 인증 정보를 SecurityContextHolder에 설정할 때 사용된다.
기초 설정
위에서 application.yml에 설정해 둔 JWT 관련 프로퍼티를 필드에 설정하고 초기화 작업까지 해둔다.
- InitializingBean의 구현체로 만들고, afterPropertiesSet() 메서드를 override 했다. 이 메서드를 사용하면 JWT 서명에 사용되는 secretKey를 모든 의존성 주입이 끝난 시점에 바로 생성한다.
- InitializingBean의 afterPropertiesSet() 메서드나 @PostConstruct 애노테이션이 붙은 메서드는 class의 인스턴스가 생성되고, 모든 프로퍼티(의존성 주입 등)가 설정되고 나서 호출된다.
- secret 값을 Base64로 디코딩해 byte[]로 만들고, 그 byte[] 값을 HMAC 알고리즘을 통해 secretKey로 만든다. 이 secretKey는 JWT를 디지털 서명할 때 사용된다.
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtUtil implements InitializingBean {
@Value("${spring.jwt.secret}")
private String secret;
private SecretKey secretKey;
@Value("${spring.jwt.token.access_expiration}")
private Long accessExpireMs;
@Value("${spring.jwt.token.refresh_expiration}")
private Long refreshExpireMs;
private final MemberDetailService memberDetailService;
@Override
public void afterPropertiesSet() throws Exception {
byte[] keyBytes = Decoders.BASE64.decode(secret);
secretKey = Keys.hmacShaKeyFor(keyBytes);
}
}
액세스 토큰 생성, 추출, 파싱, 검증하는 메서드 구현
a. createAccessJwt()
회원의 PK와 이메일을 JWT의 claim에 넣고 만료 시간을 설정한 뒤 위에서 만든 secretKey로 서명을 진행한다.
- secretKey로 서명할 때 사용할 HMAC 알고리즘을 지정할 수 있다. 지정하지 않으면 secretKey 길이에 맞는 HMAC 알고리즘이 자동으로 설정된다.
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtUtil implements InitializingBean {
...
// 액세스 토큰 생성 - payload claim에 member의 ID와 이메일을 저장 (role은 null 값)
public static String createAccessJwt(Long memberId, String email, String role) {
return Jwts.builder()
.header()
.add("typ", "JWT")
.and()
.claim("memberId", memberId)
.claim("email", email)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + accessExpireMs))
.signWith(secretKey)
.compact();
}
}
b. resolveToken()
HttpServletRequest의 'Authorization: Bearer {token}' 형식으로 들어온 헤더에서 토큰을 추출한다.
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtUtil implements InitializingBean {
...
// HttpServletRequest의 Authorization: Bearer {token} 형식으로 들어온 헤더에서 토큰 추출
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { // 띄어쓰기 삭제
return bearerToken.substring(7);
}
return null;
}
}
c. getEmail(), getExpiration(), isExpired()
입력받은 JWT을 secretKey로 검증한 뒤 claim을 파싱하고, payload에서 회원의 이메일 또는 토큰의 만료 시간을 파싱한다.
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtUtil implements InitializingBean {
...
// 토큰의 payload claim에 담긴 사용자 이메일 파싱
public static String getEmail(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("email", String.class);
}
// 토큰의 만료 여부를 확인
public static boolean isExpired(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getExpiration()
.before(new Date());
}
// 토큰의 payload claim에 담긴 토큰 만료 시간을 파싱
public static long getExpiration(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getExpiration()
.getTime();
}
}
d. validateToken()
입력받은 토큰을 secretKey로 검증하고 claim을 파싱한다. 파싱 과정에서 토큰이 유효하지 않거나 만료됐다면 JwtException을 발생시키므로, try-catch문을 통해 발생할 수 있는 모든 예외를 JwtExceptionHandler에 넘긴다.
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtUtil implements InitializingBean {
...
// 토큰의 유효성과 만료 여부 검증
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
throw new JwtExceptionHandler(ErrorStatus.WRONG_TYPE_SIGNATURE.getMessage(), e);
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
throw new JwtExceptionHandler(ErrorStatus.TOKEN_EXPIRED.getMessage(), e);
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
throw new JwtExceptionHandler(ErrorStatus.WRONG_TYPE_TOKEN.getMessage(), e);
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
throw new JwtExceptionHandler(ErrorStatus.NOT_VALID_TOKEN.getMessage(), e);
}
}
}
Authentication에 넣을 UsernamePasswordAuthenticationToken을 반환하는 메서드 구현
입력받은 JWT에서 이메일을 파싱해 memberDetailService의 loadUserByUsername 메서드에 넘기고 UserDetails를 반환받는다. 이후 UsernamePasswordAuthenticationToken에 넣어 반환한다.
- UsernamePasswordAuthenticationToken은 결국엔 Authentication의 구현체라고 볼 수 있다. 따라서 Principal, Credentials, Authorities를 갖는다.
- Principal에 UserDetails을 넣고 나머지는 null로 넣어서 사용했다. (userDetails.getAuthorities()도 null을 반환한다.)
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtUtil implements InitializingBean {
...
public Authentication getAuthentication(String token) {
UserDetails userDetails = memberDetailService.loadUserByUsername(this.getEmail(token));
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
참고자료