[JWT를 활용한 일반 로그인 API 구현] 과정을 기록해보려고 한다. 프로젝트 개발 환경은 아래와 같다.
Java: 17
JDK: 17.0.6
IDE: IntelliJ IDEA 2024.1
Framework: Spring Boot 3.3.1
로그인 API 구현하기
지금까지 토큰 발급부터 토큰 인증 처리까지 모든 기능을 구현했다. 이제 비즈니스 로직을 완성해 보자.
MemberRequestDTO, MemberResponseDTO
a. MemberRequestDTO.MemberLoginDTO
클라이언트에게 받을 정보의 틀이 되는 class로, 이메일과 비밀번호를 입력받는다.
public class MemberRequestDTO {
...
@Getter
public static class MemberLoginDTO {
@NotBlank(message = "이메일은 필수 입력 값입니다.")
@Length(min = 5, max = 255)
@Pattern(
regexp = "\\w+@\\w+\\.\\w+(\\.\\w+)?",
message = "이메일 형식이 올바르지 않습니다."
)
private String email;
@NotBlank(message = "비밀번호는 필수 입력 값입니다.")
@Length(min = 8, max = 30)
@Pattern(
regexp = "^[a-zA-Z0-9~!@#$%^&*()]{8,30}",
message = "비밀번호는 영어 대소문자, 숫자, 특수 문자로 구성돼야 합니다."
)
private String password;
}
}
b. MemberResponseDTO.
클라이언트에게 전송할 정보의 틀이 되는 class로, 로그인 후 생성된 토큰과 토큰의 타입, 만료 시간을 전송한다.
public class MemberResponseDTO {
...
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class LoginJwtTokenDTO {
String grantType;
String accessToken;
Long accessTokenExpiresAt;
}
}
MemberService
로그인을 시도하는 회원의 이메일과 비밀번호를 입력받고, 각각 예외 처리를 진행한다.
- 요청 DTO에서 이메일을 꺼내 memberRepository에 해당 회원이 존재하는지 확인한다.
- 요청 DTO의 비밀번호를 암호화한 값과 memberRepository에 이미 암호화되어 저장된 비밀번호가 일치하는지 검증한다.
- 위에서 예외가 발생하지 않았다면, JwtUtil class를 활용해 Access Token을 발급하고 응답 DTO 형식으로 바꿔 반환한다.
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
...
// 로그인 - 액세스 토큰 발급
public MemberResponseDTO.LoginJwtTokenDTO login(MemberRequestDTO.MemberLoginDTO loginDTO) {
String email = loginDTO.getEmail();
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new MemberHandler(ErrorStatus.NO_MEMBER_EXIST));
if (!passwordEncoder.matches(loginDTO.getPassword(), member.getPassword())) {
throw new MemberHandler(ErrorStatus.PASSWORD_NOT_MATCH);
}
String accessToken = jwtUtil.createAccessJwt(member.getId(), member.getEmail(), null);
Long accessExpiredAt = jwtUtil.getExpiration(accessToken);
return MemberResponseDTO.NewLoginJwtTokenDTO.builder()
.grantType("Bearer")
.accessToken(accessToken)
.accessTokenExpiresAt(accessTokenExpiredAt)
.build();
}
}
MemberApiController
요청 DTO를 MemberService에 넘기고 로그인을 진행한 뒤 Access Token을 반환받는다. 이후 ApiResponse를 활용해 응답을 통일한 뒤 전송한다.
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
@Tag(name = "member", description = "회원 관련 API")
public class MemberApiController {
private final MemberService memberService;
...
@PostMapping("/login")
@Operation(summary = "로그인 API", description = "로그인을 진행하는 API (토큰 방급; 액세스 토큰 필요 없음)")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4005", description = "존재하지 않는 회원입니다.",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4006", description = "비밀번호가 일치하지 않습니다.",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
})
public ApiResponse<MemberResponseDTO.LoginJwtTokenDTO> login(@Valid @RequestBody MemberRequestDTO.MemberLoginDTO request) {
MemberResponseDTO.LoginJwtTokenDTO token = memberService.login(request);
return ApiResponse.onSuccess(token);
}
@GetMapping("/test")
public String loginTest() {
return "login user";
}
}
/member/test 경로는 요청 시 토큰을 입력해야만 동작하는 모습을 확인하기 위해 임시로 만들어뒀다. 이전 글에서 구현한 SecurityConfig를 살펴보면 permitAll()을 적용하지 않은 모든 경로에 대한 요청은 인증된(토큰을 입력한) 사용자만 접근(.authenticated)할 수 있다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http
...
.authorizeHttpRequests((request) -> request
.requestMatchers("/member/signup/**").permitAll()
.requestMatchers("/member/login").permitAll()
.requestMatchers("/health").permitAll() // health check
.requestMatchers(AUTH_WHITELIST).permitAll()
.anyRequest().authenticated()) // 나머지 경로는 모두 인증이 필요함
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionHandlerFilter, JwtAuthenticationFilter.class);
return http.build();
}
}
Swagger로 테스트하기
회원가입 API를 호출해 DB에 회원을 저장해 놓고 테스트를 진행한다.
- email = name@name.name
- password = password
회원가입 시 입력한 이메일과 비밀번호를 사용해 로그인 API를 호출하면 Access Token이 반환된다. (토큰 만료 시간은 생략했다.)
이제 위에서 발급받은 Access Token을 Swagger 오른쪽 상단에 있는 Authorize 버튼을 클릭해 입력하면, 해당 토큰이 'Authrization: Bearer {token}'의 형식으로 Http 요청 헤더에 들어간다.
- 토큰을 입력하지 않으면 아래처럼 에러 메시지가 뜬다. JwtAuthenticationEntryPoint가 동작한 결과라고 보면 된다.
- 토큰을 입력하면 정상적으로 동작하는 걸 확인할 수 있다. 동작 과정을 요약하면 아래와 같다.
- 인증이 필요한 경로로 토큰(자격 증명)을 입력한 요청을 전송하면 JwtAuthenticationFilter에서 인증을 진행한다.
- 인증에 성공하면, 최종적으로 SecurityContextHolder에 사용자의 Authentication을 설정한다.
- 따라서 인증이 완료됐으므로 /member/test 경로에 접근할 수 있다.