jwt
jwt 로그인 구현
원코드
2024. 9. 20. 14:23
로그인 구현 순서 :

1. dto와 컨트롤러를 통해 이메일, 비밀번호를 전달받는다
LoginRequest.class (DTO)
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@Getter
public class LoginRequest {
private String email;
private String password;
@Builder(builderMethodName = "testBuilder")
public LoginRequest(String email, String password) {
this.email = email;
this.password = password;
}
}
LoginController.class
@RequiredArgsConstructor
@RestController
public class LoginController {
private final LoginService loginService;
private final Long COOKIE_MAX_AGE = 604800000L;
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletResponse response) {
MemberToken tokens = loginService.login(request);
final ResponseCookie cookie = ResponseCookie.from("refresh-token", tokens.getRefreshToken())
.maxAge(COOKIE_MAX_AGE)
.sameSite("None")
.secure(true)
.httpOnly(true)
.path("/")
.build();
response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.status(HttpStatus.CREATED).body(new LoginResponse(tokens.getAccessToken()));
}
}
dto를 파라미터로 전달받을 때 @RequestBody를 이용해야 json 형식으로 받을 수 있다.
이후 loginService.login()을 호출하여 비즈니스 로직 처리.
완료되면 ResponseCookie를 이용하여 쿠키를 생성하여 리프레시 토큰을 담고, response.setHeader를 이용해 리스폰스 헤더로 전달한다.
엑세스 토큰은 ResponseEntity를 이용해 리스폰스 바디에 담아서 전달한다.
2. 유효성 체크, 토큰 발행
LoginService.class
import org.springframework.stereotype.Service;
import com.tlog.backend.global.exception.AuthException;
import com.tlog.backend.global.exception.ExceptionCode;
import com.tlog.backend.global.utils.Encryption;
import com.tlog.backend.login.JwtProvider;
import com.tlog.backend.login.domain.MemberToken;
import com.tlog.backend.login.domain.RefreshToken;
import com.tlog.backend.login.domain.RefreshTokenRepository;
import com.tlog.backend.login.web.dto.LoginRequest;
import com.tlog.backend.member.domain.Member;
import com.tlog.backend.member.domain.repository.MemberRepository;
import com.tlog.backend.member.domain.repository.SaltRepository;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class LoginService {
private final MemberRepository memberRepository;
private final SaltRepository saltRepository;
private final JwtProvider jwtProvider;
private final RefreshTokenRepository refreshTokenRepository;
public MemberToken login(LoginRequest request) {
Encryption encryption = new Encryption();
String encoded = null;
//이메일 존재하는지 확인
Member member = memberRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new AuthException(ExceptionCode.EMAIL_NOT_FOUND));
//비밀번호 일치하는지 확인
encoded = encryption.sha256encode(request.getPassword(),
saltRepository.findByMemberId(member.getId()).get().getSalt());
if(!member.getPassword().equals(encoded)) {
throw new AuthException(ExceptionCode.INVALID_PASSWORD);
}
//토큰 발행
MemberToken tokens = jwtProvider.generateLoginToken(member.getId());
//리프레시 토큰 저장
RefreshToken refreshToken = new RefreshToken(tokens.getRefreshToken(), member.getId());
refreshTokenRepository.save(refreshToken);
//토큰 전달
return tokens;
}
}
이메일, 비밀번호 유효성 체크 시, 유효하지 않을 경우에는 커스텀 익셉션을 이용해 원하는 에러 코드와 메시지를 전달한다.
AuthException.class
import lombok.Getter;
@Getter
public class AuthException extends RuntimeException{
private final int code;
private final String message;
public AuthException(final ExceptionCode code) {
this.code = code.getCode();
this.message = code.getMessage();
}
}
생성한 커스텀 익셉션은 익셉션핸들러를 통해 인식할 수 있도록 설정한다.
GlobalExceptionHandler.class
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AuthException.class)
public ResponseEntity<ExceptionResponse> handleAuthException(AuthException e) {
log.warn(e.getMessage(), e);
return ResponseEntity.badRequest().body(new ExceptionResponse(e.getCode(), e.getMessage()));
}
}
이후 jwtProvider.generatLoginToken()을 호출해 토큰을 생성한다.
JwtProvider.class
package com.tlog.backend.login;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import com.tlog.backend.global.exception.ExceptionCode;
import com.tlog.backend.global.exception.ExpiredPeriodJwtException;
import com.tlog.backend.global.exception.InvalidJwtException;
import com.tlog.backend.login.domain.MemberToken;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.Keys;
@Component
public class JwtProvider {
private final SecretKey secretKey;
private final Long refreshExpirationTime;
private final Long accessExpirationTime;
public JwtProvider(@Value("${jwt.secret}") final String stringKey,
@Value("${jwt.refresh_expiration_time}") final Long refreshExpirationTime,
@Value("${jwt.access_expiration_time}") final Long accessExpirationTime) {
this.secretKey = Keys.hmacShaKeyFor(stringKey.getBytes(StandardCharsets.UTF_8));
this.refreshExpirationTime = refreshExpirationTime;
this.accessExpirationTime = accessExpirationTime;
}
public MemberToken generateLoginToken(Long memberId) {
String refreshToken = createToken(memberId, refreshExpirationTime);
String accessToken = createToken(memberId, accessExpirationTime);
return new MemberToken(accessToken, refreshToken);
}
public String createToken(Long memberId, Long expirationTime) {
Date now = new Date();
Date expiration = new Date(now.getTime() + expirationTime);
return Jwts.builder()
.header().add("type", "jwt")
.and()
.claim("memberId", memberId)
.issuedAt(now)
.expiration(expiration)
.signWith(secretKey)
.compact();
}
}
3. 테스트 코드
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.aspectj.lang.annotation.Before;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.context.WebApplicationContext;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tlog.backend.login.web.dto.LoginRequest;
import com.tlog.backend.member.domain.Member;
import com.tlog.backend.member.domain.Role;
import com.tlog.backend.member.domain.repository.MemberRepository;
import com.tlog.backend.member.web.dto.MemberSignUpRequest;
@TestInstance(Lifecycle.PER_CLASS)
@AutoConfigureMockMvc
@SpringBootTest
public class LoginControllerTest {
@Autowired
protected MockMvc mockMvc;
ObjectMapper mapper = new ObjectMapper();
@Autowired
MemberRepository memberRepository;
@BeforeAll
void signUp_for_test_data() throws Exception {
//given
String email = "abc@googole.com";
String password = "1234";
String nickname = "foo";
//가입할 이메일, 비밀번호, 닉네임이 json으로 전달된다.
//api 호출 시 전달한 json이 MemberSignUpRequest에 담긴다.
MemberSignUpRequest request = MemberSignUpRequest.testDataBuilder()
.test_email(email)
.test_password(password)
.test_checkPassword(password)
.test_nickname(nickname)
.build();
String json = mapper.writeValueAsString(request);
System.out.println(json);
mockMvc.perform(post("/member")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andDo(print());
}
@Test
@DisplayName("로그인이 정상적으로 완료되어 토큰이 반환된다.")
void login_test() throws Exception {
//given
String email = "abc@googole.com";
String password = "1234";
LoginRequest request = LoginRequest.testBuilder()
.email(email)
.password(password)
.build();
String jsonReq = mapper.writeValueAsString(request);
mockMvc.perform(post("/login")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonReq))
.andExpect(status().isCreated())
.andDo(print());
}
}