새로운 토이 프로젝트를 하면서 JWT를 도입하였습니다. 앞으로 시리즈로 각 단계별 과정을 정리해보겠습니다.
우선 프로젝트에서 인증을 진행하는 과정은 다음과 같습니다.
이번 편에서 정리할 내용은 Access Token 발급입니다. Access Token은 사용자가 인증을 완료한 후 특정 자원이나 서비스에 접근할 수 있도록 권한을 부여하는 토큰입니다.
가장 먼저 JWT를 사용하기 위해서 의존성을 추가하였습니다. 이번에 사용한 버전은 0.12.3입니다. JWT와 인증/인가를 담당하는 Spring Security도 추가해주었습니다.
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
implementation 'org.springframework.boot:spring-boot-starter-security'
다음으로는 JwtUtil입니다. JwtUtil에서는 토큰을 발급받고 토큰에 저장된 값을 가져오는 기능 그리고 토큰이 유효한지 검사하는 기능이 포함되어 있습니다.
@Component
public class JwtUtil {
private final SecretKey secretKey;
public JwtUtil(@Value("${spring.jwt.secret}") String secretKey) {
this.secretKey = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
//페이로드에 저장되어있는 회원 ID 값을 가져옵니다.
public Long getId(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("id", Long.class);
}
//페이로드에 저장되어있는 회원 UUID 값을 가져옵니다.
public String getUuid(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("uuid", String.class);
}
//페이로드에 저장되어있는 회원 닉네임 값을 가져옵니다.
public String getNickname(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("nickname", String.class);
}
//페이로드에 저장되어있는 회원 권한 값을 가져옵니다.
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
//페이로드에 저장되어있는 회원 토큰 타입 값을 가져옵니다.
public String getCategory(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
}
//토큰 기간이 유효한지 값을 가져옵니다.
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
//페이로드에 저장되어있는 회원 값을 종합하여 가져옵니다.
public MemberResponseDto getMemberResponseDto(String token){
return MemberResponseDto.builder()
.id(getId(token))
.uuid(getUuid(token))
.nickname(getNickname(token))
.role(getRole(token))
.build();
}
//토큰 만들어서 리턴합니다.
public String createJwt(String category, MemberResponseDto memberResponseDto, Long expiredMs) {
return Jwts.builder()
.claim("category", category)
.claim("id", memberResponseDto.getId())
.claim("uuid", memberResponseDto.getUuid())
.claim("nickname", memberResponseDto.getNickname())
.claim("role", memberResponseDto.getRole())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
AuthController는 로그인을 처리하는 기능이 있습니다. AuthRequestDto 데이터가 필요하며 AuthService의 login 함수에 AuthRequestDto를 전달해주고 있습니다.
login 함수에서 반환되는 AuthResponseDto를 ResponseEntity에 감싸서 리턴을 하고 있습니다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController implements AuthControllerDocs {
private final AuthService authService;
@PostMapping("/login")
public ResponseEntity<AuthResponseDto> login(@RequestBody AuthRequestDto authRequestDto) {
AuthResponseDto authResponseDto = authService.login(authRequestDto);
return ResponseEntity.ok(authResponseDto);
}
}
AuthService는 로그인 요청을 처리하는 기능을 가지고 있습니다. 각 로직에 대한 설명은 주석으로 작성하였습니다.
@Service
@RequiredArgsConstructor
public class AuthService {
private final JwtUtil jwtUtil;
private final MemberService memberService;
@Value("${spring.jwt.accessToken_expiration_time}")
private Long accessTokenExpiredMs;
public AuthResponseDto login(AuthRequestDto authRequestDto) {
//authRequestDto가 가지고 있는 uuid를 조건으로 MemberResponseDto를 가져옵니다.
MemberResponseDto memberResponseDto = memberService.findByUuid(authRequestDto.getUuid());
//MemberResponseDto 값이 null이면 회원가입을 실행합니다.
if(memberResponseDto == null){
memberResponseDto = memberService.save(authRequestDto.getUuid(), authRequestDto.getNickname());
}
//MemberResponseDto를 기반으로 Access Token을 만듭니다.
String accessToken = jwtUtil.createJwt("access", memberResponseDto, accessTokenExpiredMs);
//AuthResponseDto에 Access Token 값을 넣어서 생성합니다.
AuthResponseDto authResponseDto = AuthResponseDto.builder()
.accessToken(accessToken)
.build();
//AuthResponseDto을 반환합니다.
return authResponseDto;
}
}
AuthRequestDto는 로그인을 요청하는 Dto입니다. 데이터로는 uuid와 nickname을 포함하고 있습니다.
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthRequestDto {
private String uuid;
private String nickname;
}
AuthResponseDto는 토큰을 반환하는 Dto입니다. 데이터로는 accessToken값을 포함하고 있습니다.
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponseDto {
private String accessToken;
}
JWT를 기반으로 인증을 처리하기 때문에 SecurityFilterChain 빈을 생성하였고 내용을 다음과 같이 구성하였습니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//csrf disable
//JWT를 사용하는 경우 CSRF 보호가 불필요 하기 때문에 비활성화합니다.
http
.csrf((auth) -> auth.disable());
//Form 로그인 방식 disable
//Form 로그인 폼 대신 API를 통한 인증을 사용하기 위해 비활성화합니다.
http
.formLogin((auth) -> auth.disable());
//HTTP Basic 인증 방식 disable
//JWT 인증 방식을 사용하기 위해 비활성화합니다.
http
.httpBasic((auth) -> auth.disable());
//인증 실패 시 401 Unauthorized 응답을 반환합니다.
//접근 권한이 없을 때 403 Forbidden 응답을 반환합니다.
http
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"))
.accessDeniedHandler((request, response, accessDeniedException) -> response.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden")));
//세션 설정 : STATELESS
//서버가 세션을 관리하지 않기 위해 STATELESS으로 설정합니다.
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
지금까지 구현한 내용으로 포스트맨을 사용해서 테스트를 진행해보겠습니다. 현재 /auth/login 경로에 POST 메소드로 uuid, nickname 값을 보내면 accessToken 값이 리턴되어야 합니다.
테스트를 진행해본 결과 accessToken에 데이터가 잘 담겨서 오는 것을 확인할 수 있었습니다. 다음 포스트에서는 발급된 accessToken을 사용해서 인증을 처리하는 로직을 작성해보겠습니다!