개발자로 후회없는 삶 살기
[보안] Jwt를 이용한 클라이언트 권한 인가 구현 본문
서론
JWT(Json Web Token)은 일반적으로 프론트엔드와 백엔드 사이에서 통신 시 권한 인가를 위해 사용하는 규칙입니다. 현재 진행하고 있는 캡스톤 디자인에서 REST API를 사용 중인데, 웹으로 Form을 통해 로그인하는 것이 아닌, API 호출로 프론트엔드에서 토큰으로 인증을 체크하고자 JWT를 선택하여 구현해보았습니다.
본론
- 필요한 코드들
저는 인터셉터와 커스텀 Argument Resolver를 이용해서 로그인한 사용자인지 필터링을 할 것이며, JWT를 이용한 로그인 인증을 할 것입니다. 따라서 필요한 파일들은 위 사진과 같습니다.
AuthenticationPrincipalConfig : 인터셉터와 Argument Resolver 설정
SecurityConfig : 스프링 시큐리티 설정
auth dir : AR와 인터셉터 구현 폴더
domain dir : 도메인 폴더
support dir : JWT 구현 폴더
위 5가지를 아래에서 설명하도록 하겠습니다.
1. domain dir
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "student_id")
private Long id;
@Column(name = "login_id", length = 255, nullable = false)
private String loginId;
@Column(name = "password", length = 255, nullable = false)
private String password;
로그인을 하기 위한 학생 도메인을 구현하였습니다. JPA를 활용한 학생 엔터티입니다.
2. auth dir
-> 로그인 컨트롤러
@PostMapping("/login/auth")
public ResponseEntity<TokenResponseDto> login(@RequestBody @Valid final LoginRequest loginRequest) {
TokenResponseDto tokenResponseDto = authService.login(loginRequest.getLoginId(), loginRequest.getLoginPassword());
return ResponseEntity.ok(tokenResponseDto);
}
로그인 요청을 받아서 서비스에서 로그인을 성공하면 토큰을 발행하여 반환하는 컨트롤러입니다.
-> LoginRequest dto
@NotBlank(message = "비어있는 항목을 입력해주세요.")
private String loginId;
@NotNull(message = "비어있는 항목을 입력해주세요.")
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[$@$!%*#?&.])[A-Za-z[0-9]$@$!%*#?&.]{" + 2 + "," + 16 + "}$")
private String loginPassword;
로그인 요청은 id와 pw로 구성되어 있습니다.
-> authService
public TokenResponseDto login(String loginId, String password) {
Student findStudent = studentRepository.findByLoginId(loginId)
.orElseThrow(() -> new NoSuchMemberException(loginId));
validatePassword(findStudent, password);
return issueTokenDto(findStudent.getId());
}
로그인 로직은 DB에 저장된 ID인지 확인하고 BCrypt로 인코딩된 패스워드 검증 한 후 검증에 성공하면 토큰을 발행하도록 했습니다. 보통 payload로 id나 이메일을 하는데 저희는 학생들이 사용할 것이라서 학번으로 진행해야 하나 고민했습니다. 하지만 추후에 id로 정보를 조회하는 것이 좋다고 판단되어 id로 교체하였습니다.
-> LoginInterceptor
public class LoginInterceptor implements HandlerInterceptor {
private final JwtTokenProvider jwtTokenProvider;
public LoginInterceptor(final JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response,
final Object handler) {
if (request.getMethod().equals("OPTIONS")) {
return true;
}
final String token = AuthorizationExtractor.extract(request);
jwtTokenProvider.validateAbleToken(token);
return true;
}
}
인터셉터는 사용자의 요청에서 토큰을 추출하고 유효한 토큰인지 검증한 후 맞다면 true를 반환하고 아니라면 validateAbleToken에서 예외를 발생시킵니다.
-> AuthenticationPrincipalArgumentResolver
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);
}
@Override
public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
final HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
final String token = AuthorizationExtractor.extract(request);
final String id = jwtTokenProvider.getPayload(token);
return new LoginMemberRequest(Long.valueOf(id));
}
스프링의 ArgumentResolver는 컨트롤러 단에서 요청값으로부터 원하는 객체 또는 프로퍼티를 반환하게 할 수 있습니다. 보통은 커스텀 유저 객체를 반환할 때 HandlerMethodArgumentResolver의 구현체를 이용해서 많이 사용합니다.
1) supportsParameter
커스텀 ArgumentResolver을 만들기 위해서는 2가지 메서드를 재정의 해야 합니다. support는 언제 커스텀 ArgumentResolver를 사용할지 조건을 거는 것으로 AuthenticationPrincipal 어노테이션이 있다면 사용할 것이라고 명시합니다.
ArgumentResolver는 디스패쳐 서블릿이 핸들러 어댑터를 호출하고 핸들러 어댑터가 컨트롤러를 호출하기 직전에 ArgumentResolver를 호출하게 되는데 그때 매핑 메서드의 파라미터로 값을 넣어주게 됩니다.
2) resolveArgument
여기에 반환할 데이터를 return하면 됩니다. 저는 로그인한 사용자가 아니라면 예외를 터트리고 맞다면 RequestDto를 넣도록 했습니다. 이제 Dto에 있는 id값으로 DB에서 사용자를 조회해서 컨텐츠를 제공하게 할 것입니다.
3. support dir
=> JwtTokenProvider
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey key;
private final long validityInMilliseconds;
public JwtTokenProvider(@Value("${jwt.token.secret-key}") final String secretKey,
@Value("${jwt.token.expire-length}") final long validityInMilliseconds) {
this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.validityInMilliseconds = validityInMilliseconds;
}
// payload에 저장된 값(유저 정보)으로 토큰을 생성해주는 부분
public String createToken(Map<String, Object> payload) {
Claims claims = Jwts.claims(payload);
final Date now = new Date();
final Date validity = new Date(now.getTime() + validityInMilliseconds);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
JWT 토큰을 생성하고, 토큰 복호화 및 정보를 추출하고, 요청받은 토큰이 유효한지 검증하는 기능이 구현된 클래스입니다. ArgumentResolver나 Interceptor에서 사용되고 있기 때문에 @Component로 빈 등록을 해주어야 합니다.
-> 토큰의 구조
토큰을 발급하기에 앞서 토큰의 구조를 알아봅니다.
토큰은 헤더, payload, signature를 내부 구조로 가지며 정보를 가지는 부분이 payload입니다. 여기에 담는 정보의 한 조각을 claim이라고 부르며 이는 [name : value] 한 쌍으로 이루어져 있고 여러 개의 클레임들을 넣을 수 있습니다.
-> 클레임의 종류
1. 등록된 클레임
iss : 토큰 발급자
sub: 토큰 제목 (subject)
aud: 토큰 대상자 (audience)
exp: 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370) 언제나 현재 시간보다 이후로 설정 되어 있어야 합니다.
등록된 클레임은 서비스에서 필요한 정보가 아닌 토큰에 대한 정보를 담고 있으며, 사용은 선택적입니다.
2. 비공개 클레임
{ "username": "velopert" }
클라이언트와 서버 사이에서 주고 받는 데이터가 비공개에 해당합니다.
signature는 헤더의 인코딩값과 payload의 인코딩 값을 합친 후 주어진 비밀키로 해쉬를 하여 생성하는 부분입니다. 이렇게 토큰이 생성됩니다.
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey key;
private final long validityInMilliseconds;
public JwtTokenProvider(@Value("${jwt.token.secret-key}") final String secretKey,
@Value("${jwt.token.expire-length}") final long validityInMilliseconds) {
this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.validityInMilliseconds = validityInMilliseconds;
}
// payload에 저장된 값(유저 정보)으로 토큰을 생성해주는 부분
public String createToken(Map<String, Object> payload) {
Claims claims = Jwts.claims(payload);
final Date now = new Date();
final Date validity = new Date(now.getTime() + validityInMilliseconds);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
이제 앞서 배운 이론으로 다시 코드를 분석하겠습니다. payload 데이터를 [name : value]로 받아 클레임을 만듭니다. JWT에서 제공하는 빌더로 원하는 정보를 헤더, 페이로드, signature에 넣을 수 있습니다.
클레임 = 데이터 Map(페이로드)
생성 일자 = 현재(헤더)
만료 일자 = 현재 시간 + exp length(헤더)
암호 키 = 서명에 쓰일 암호 키(시그니처)
위 정보를 넣어서 토큰을 발급하는 코드입니다.
# Jwt
jwt.token.secret-key=VlwEyVBsYt9V7zq57TejMnVUyzblYcfPQye08f7MGVA9XkHa
jwt.token.expire-length=3600000
토큰의 암호화, 복호화를 위한 secret key와 토큰 만료 시간을 properites에 설정합니다. 스프링 빈으로 동록된 컴포넌트는 @Value 값으로 가져옴으로써 공개적으로 드러내지 않게 할 수 있습니다. @Value는 등록된 빈에만 적용할 수 있고 따라서 테스트에서 @Value를 사용하려면 @SpringBootTest를 사용해야 합니다.
public String getPayload(final String token) {
return tokenToJws(token)
.getBody()
.getSubject();
}
토큰 값으로 payload를 추출할 수 있는 부분입니다. tokenToJws 메서드로 token을 파싱(토큰 내부 데이터 추출)합니다.
public void validateAbleToken(final String token) {
try {
final Jws<Claims> claims = tokenToJws(token);
validateExpiredToken(claims);
} catch (final JwtException | InvalidTokenException e) {
throw new TokenInvalidSecretKeyException(token);
}
}
private void validateExpiredToken(final Jws<Claims> claims) {
if (claims.getBody().getExpiration().before(new Date())) {
throw new TokenInvalidExpiredException();
}
}
토큰이 만료됐는지 여부를 확인해주는 부분입니다. validateExpiredToken에서 getExpiration이 토큰 만료 시간인데 만료 시간이 현재 시각보다 이전일 경우 예외를 발생시킵니다. 토큰을 생성할 때 만료 시간을 넣었기 때문에 시간 검사를 할 수 있습니다. validateExpiredToken을 로그인 인터셉터에서 호출하여 만료된 토큰은 프론트로 예외 메세지를 전달할 것입니다.
private Jws<Claims> tokenToJws(final String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
} catch (final IllegalArgumentException | MalformedJwtException e) {
throw new TokenInvalidFormException(); // 토큰이 잘 못된 경우
} catch (final SignatureException e) {
throw new TokenInvalidSecretKeyException(token); // 시크릿 키가 잘 못된 경우
} catch (final ExpiredJwtException e) {
throw new TokenInvalidExpiredException(); // 토큰 만료된 경우
}
}
사용자 토큰을 파싱하는 부분입니다. token이 null인 경우 IllegalArgumentException, token이 파싱이 제대로 되지 않았을 경우 MalformedJwtException이 발생합니다.
누군가 토큰을 변조했을 확률이 높은 경우 SignatureException, 토큰 만료는 위에서 처리해 주었지만 찰나의 순간에 만료되는 것을 대비하여 ExpiredJwtException 로직도 추가하였습니다.
-> JwtTokenProvider Test
토큰 생성기는 어떤 테스트가 필요할까요? 예외가 제대로 발생되는지, 토큰이 올바르게 발급되는 지 체크해야 합니다.
1. 올바르지 않은 시크릿 키
private final JwtTokenProvider invalidSecretKeyJwtTokenProvider
= new JwtTokenProvider(
"invalidSecretKeyInvalidSecretKeyInvalidSecretKeyInvalidSecretKey",
8640000L
);
@Test
@DisplayName("시크릿 키가 틀린 토큰 정보로 payload를 조회할 경우 예외를 발생시킨다.")
void getPayloadByWrongSecretKeyToken() {
// given
Map<String, Object> payloadMap = createPayloadMap(1L, STUDENT);
final String invalidSecretToken = invalidSecretKeyJwtTokenProvider.createToken(payloadMap);
// then
assertThatThrownBy(() -> jwtTokenProvider.getPayload(invalidSecretToken))
.isInstanceOf(TokenInvalidSecretKeyException.class)
.hasMessage(String.format("토큰의 secret key가 변조됐습니다. 해킹의 우려가 존재합니다. token={%s}", invalidSecretToken));
}
올바르지 않은 시크릿키를 사용하고 있는 테스트용 JwtTokenProvider를 생성해주고 이 provider를 사용할 때엔 TokenInvalidSecretKeyException()이 발생해야 합니다.
2. 올바른 토큰 생성
@Test
@DisplayName("토큰이 올바르게 생성된다.")
void createToken() {
Map<String, Object> payloadMap = createPayloadMap(1L, STUDENT);
final String token = jwtTokenProvider.createToken(payloadMap);
assertThat(token).isNotNull();
}
@Test
@DisplayName("올바른 토큰 정보로 payload를 조회한다.")
void getPayloadByValidToken() {
Map<String, Object> payloadMap = createPayloadMap(1L, STUDENT);
final String token = jwtTokenProvider.createToken(payloadMap);
assertThat(jwtTokenProvider.getPayload(token))
.isEqualTo(String.valueOf(1L));
}
올바르게 토큰이 생성되었다면 반드시 null이 아닐 것이며 payload를 올바르게 추출하는 지 확인해야 합니다. 보통 페이로드로는 id나 이메일처럼 중복되지 않는 값을 사용합니다.
3. 만료 토큰
@Test
@DisplayName("만료된 토큰으로 payload를 조회할 경우 예외가 발생한다.")
void getPayloadByExpiredToken() {
// given
final String expiredToken = Jwts.builder()
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256)
.setSubject(String.valueOf(1L))
.setExpiration(new Date((new Date()).getTime() - 1)) // 현재 시간 -1 = 이미 만료된 토큰
.compact();
// then
assertThatThrownBy(() -> jwtTokenProvider.getPayload(expiredToken))
.isInstanceOf(TokenInvalidExpiredException.class)
.hasMessage("토큰의 유효기간이 만료됐습니다.");
}
만료된 토큰을 만들고 예외가 발생하는 지 체크합니다.
public class TokenResponseDto {
private String accessToken;
private Long id;
public static TokenResponseDto of(final String accessToken, Long id) {
return new TokenResponseDto(accessToken, id);
}
}
위 코드로 검증된 사용자의 로그인 요청이 들어오면 TokenResponseDto에 토큰 정보를 담아서 클라이언트에게 반환합니다.
private TokenResponseDto issueTokenDto(Long studentId) {
String accessToken = jwtTokenProvider.createToken(String.valueOf(studentId));
return TokenResponseDto.of(accessToken, studentId);
}
서비스의 issueTokenDto가 최종적으로 클라이언트에게 토큰을 발행하는 코드입니다. 학번을 받아서 토큰을 생성하고 db pk와 함께 반환합니다.
-> AuthService Test
@BeforeEach
void setUp() {
dummyStudent = new Student(
"cherry1",
passwordEncoder.encode("123#a1"),
LocalDate.of(2023, 07, 18),
"컴퓨터공학부",
Grade.FOURTH,
PhoneNumber.from("010-1111-1111"),
Sex.FEMAIL,
"한상범",
Email.from("1@naver.com"),
"20182222"
);
studentRepository.save(dummyStudent);
}
이제 올바른 사용자의 로그인 요청이 오면 토큰을 발급하고 아니라면 예외가 발생하는지 테스트 해보겠습니다. setUp으로는 더미 데이터를 저장합니다.
1) DB에 없는 ID
@Test
@DisplayName("유효하지 않은 id로 로그인하면 예외가 발생한다.")
void throwException_invalidLoginId() {
// given
LoginRequest loginRequest = new LoginRequest("no", "no");
// then
assertThatThrownBy(() -> authService.login(
loginRequest.getLoginId(), loginRequest.getLoginPassword()
)).isInstanceOf(NoSuchMemberException.class)
.hasMessage("없는 ID 입니다. 다시 로그인 해주세요. loginId={no}");
}
DB에 없는 ID로 로그인을 하면 예외가 발생해야 합니다.
2) id와 pw mismatch
@Test
@DisplayName("id와 비밀번호가 일치하지 않으면 예외가 발생한다.")
void throwException_mismatchIdPassword() {
// given
LoginRequest loginRequest = new LoginRequest("cherry1", "no");
// then
assertThatThrownBy(() -> authService.login(
loginRequest.getLoginId(),
loginRequest.getLoginPassword()
)).isInstanceOf(IdPasswordMismatchException.class)
.hasMessage("아이디 혹은 비밀번호를 확인해주세요.");
}
id와 pw가 맞지 않으면 예외가 발생해야 합니다.
3) 토큰 발행 성공
@Test
@DisplayName("로그인 요청을 받아서 토큰을 생성한다.")
void createToken() {
// given
LoginRequest loginRequest = new LoginRequest("cherry1", "123#a1");
// when
TokenResponseDto tokenResponseDto = authService.login(loginRequest.getLoginId(), loginRequest.getLoginPassword());
// then
assertThat(tokenResponseDto).isNotNull();
}
올바른 사용자의 경우 토큰을 발행합니다.
로그를 찍어보면 토큰이 발행된 것을 확인할 수 있습니다.
@DisplayName("올바른 토큰 정보로 payload를 조회한다.")
@Test
void getPayloadByValidToken() {
final String payload = String.valueOf(1L);
final String token = jwtTokenProvider.createToken(payload);
assertThat(jwtTokenProvider.getPayload(token)).isEqualTo(payload);
}
끝으로 반드시 발행한 토큰에서 사용자 정보를 찾을 수 있는 지까지 테스트해야 합니다.
발행한 토큰을 복호화해보면 학번이 제대로 나오는 것을 볼 수 있습니다. 이제 이 토큰으로 로그인한 사용자인지 검증하는 인터셉터를 설정에 적용하고 어플리케이션에서 사용해보겠습니다.
4. AuthenticationPrincipalConfig
@Configuration
@RequiredArgsConstructor
public class AuthenticationPrincipalConfig implements WebMvcConfigurer {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void addInterceptors(final InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(jwtTokenProvider))
.addPathPatterns("/api/**")
.excludePathPatterns("/api/login/auth")
.excludePathPatterns("/api/students");
}
@Override
public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(new AuthenticationPrincipalArgumentResolver(jwtTokenProvider));
}
}
만든 인터셉터와 Argument Resolver를 등록합니다.
5. SecurityConfig
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
마지막으로 스프링 시큐리티까지 등록하면 모든 설정이 끝납니다.
- Postman 테스트
이제 postman을 통해서 눈으로 직접 확인해보겠습니다.
"cherry2",
passwordEncoder.encode("123#a2"),
DB에 원하는 정보를 저장하고
요청을 보내보면 accessToken을 잘 반환하는 것을 확인할 수 있습니다.
- 로그아웃
토큰을 이용한 로그아웃 방식은 로그인과 마찬가지로 간단하지 않습니다.
-> 세션 방식
세션은 서버에서 세션 저장소를 생성하고 관리하기에 간단하게 invalidate()로 로그아웃을 할 수 있지만 토큰은 세션 저장소와 같은 역할을 할 토큰 저장소를 따로 만들어 줘야합니다.
토큰 저장소는 어떻게 만드나요? ✅
토큰 저장소는 DB 테이블을 만드는 것이 일반적입니다. 그 중에서도 Redis를 사용하여 로그인한 사용자를 빠르게 관리하는 것이 좋습니다. 이번에는 MySql에 토큰 테이블을 만들고 로그인한 사용자를 저장, 관리하고 로그아웃을 만들어보겠습니다.
-> refresh 토큰과 access 토큰
그 전에 먼저 refresh 토큰에 대해 알아야합니다.
refresh 토큰이란? ✅
access token이 만료되면 다시 재생성하거나 access token을 파기할 때 필요한 토큰으로 access token을 관리하기 위한 토큰입니다. 유저가 로그아웃 하지 않았는데 access token이 만료되면 유저는 refresh token으로 access token을 연장할 수 있습니다.
-> refresh token 엔터티
여기서는 refresh 토큰을 이용하여 로그아웃을 하는 법을 알아봅니다.
package com.kyonggi.Capstone_Develop.domain.refreshtoken;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@Getter
@Table(name = "refresh_token")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {
private static final int EXPIRED_DAYS = 7;
private static final int REMAINING_DAYS_TO_EXTENDS = 2;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "refresh_token_id")
private Long id;
@Column(name = "token_value", unique = true, nullable = false)
private String tokenValue;
@Column(name = "member_id", nullable = false)
private Long memberId;
@Column(name = "expired_time")
private LocalDateTime expiredTime;
private RefreshToken(final String tokenValue, final Long memberId, final LocalDateTime expiredTime) {
this.tokenValue = tokenValue;
this.memberId = memberId;
this.expiredTime = expiredTime;
}
public static RefreshToken createBy(final Long memberId, final RefreshTokenGenerator generator) {
return new RefreshToken(
generator.generate(),
memberId,
LocalDateTime.now().plusDays(EXPIRED_DAYS) // 현재 시간부터 7일 이후로 파기
);
}
}
먼저 refresh token를 저장할 테이블과 엔터티를 만듭니다.
이전에는 사용자가 로그인을 하면 access token만 발급하였는데 추가로 refresh 토큰도 함께 발급합니다. 이것으로 사용자는 2개의 토큰을 가지고 있는 것이고 일반적인 요청에서는 access token을 사용하지만 로그아웃을 원하면 refresh token을 전송합니다.
-> 토큰 응답 DTO 재정의
public class TokenResponseDto {
private String accessToken;
private String refreshToken;
private Long id;
public static TokenResponseDto of(final String accessToken, String refreshToken, Long id) {
return new TokenResponseDto(accessToken, refreshToken, id);
}
}
응답 DTO에 refresh를 가지도록 재정의합니다. 이렇게 하면 브라우저는 2개의 토큰을 가질 것입니다.
-> 로그인 로직
// 로그인
public TokenResponseDto login(String loginId, String password) {
Student findStudent = studentRepository.findByLoginId(loginId)
.orElseThrow(() -> new NoSuchMemberIdException(loginId));
validatePassword(findStudent, password);
return issueTokenDto(findStudent.getId(), findStudent.getRoleType());
}
// 응답 DTO 만드는 로직
private TokenResponseDto issueTokenDto(Long studentId, RoleType roleType) {
Map<String, Object> payload = createPayloadMap(studentId, roleType);
String accessToken = jwtTokenProvider.createToken(payload);
RefreshToken refreshToken = createRefreshToken(studentId);
return TokenResponseDto.of(accessToken, refreshToken.getTokenValue(), studentId);
}
기존 로그인 로직에서 createRefreshToken을 만드는 로직이 추가되었습니다.
private RefreshToken createRefreshToken(final Long studentId) {
final Student findStudent = studentRepository.findById(studentId)
.orElseThrow(() -> new NoSuchMemberException(studentId));
final RefreshToken refreshToken = RefreshToken.createBy(findStudent.getId(), () -> UUID.randomUUID().toString());
return refreshTokenRepository.save(refreshToken);
}
보면 refresh 토큰을 생성하고 테이블에 저장한 후 생성한 refresh 토큰을 반환합니다.
이렇게 하면 로그인을 할 때 refresh 테이블에 토큰이 저장될 것입니다. 우리는 저 token_value를 지울 때 사용해서 로그아웃을 진행할 것입니다.
-> 로그아웃 로직
// 컨트롤러
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestBody @Valid final RefreshTokenRequest refreshTokenRequest) {
authService.logout(refreshTokenRequest.getRefreshToken());
return ResponseEntity.noContent().build();
}
사용자가 로그아웃 버튼을 누르면 가지고 있던 refresh 토큰을 서버로 전달합니다.
public void logout(String refreshToken) {
refreshTokenRepository.deleteByTokenValue(refreshToken);
}
로그아웃을 하면 테이블의 refresh 토큰이 deleteBy로 삭제됩니다. 이렇게 되면 사용자가 요청을 보낼 때마다 refresh 토큰 테이블에 해당 refresh 토큰이 있는 지 확인하고 있으면 로그인한 사용자로 간주하고 없으면 로그인 안 한 사용자로 간주합니다. 이렇게 토큰을 삭제하여 로그인 안 한 사용자로 만드는 원리입니다.
또한 시간이 지난 토큰은 유효기간이 만료됐다고 알려줍니다.
- 이슈 테스트
로그인이 잘 됐는지 그리고 컨트롤러 어드바이스와 커스텀 예외가 잘 동작하는지 테스트 해봅니다.
1. 없는 ID로 로그인
커스텀 예외가 발생하고
컨트롤러 어드바이스가 잘 잡습니다.
2. id 중복 저장
3. 비번 패턴 검증에 걸려서 빈 검증 필드에러 발생
패스워드에 패턴을 유효하지 않게 넣고 회원가입하면
빈 검증에 걸립니다.
4. 스프링 시큐리티 기본 로그인
스프링 시큐리티는 의존성을 주입하면 자동으로 이 주소에 대한 모든 접근을 일단 막고 본다고 합니다.
username : user
password : 콘솔의 Using generated security password
로 로그인해야 다음 화면으로 넘어간다고 하는데 저는 어떠한 설정을 해도 로그인이 안 됐습니다.
@SpringBootApplication(exclude = SecurityAutoConfiguration.class)
public class CapstoneDevelopApplication {
따라서 어쩔 수 없이 강제로 무시하는 방법을 취했습니다. 분명 시큐리티 설정이 잘 못된 것으로 보입니다. 이렇게 강제로 무시하는 것보단 스프링에서 제공하는 기능을 사용하는 것이 좋은 방법이기에 나중에 제대로 공부해서 해결해야 겠습니다.
결론
이렇게 JWT를 사용한 인증, 인가를 구현해보았습니다. 프론트엔드와 협업할 때 JWT를 사용하는 이유는 SSO 시나리오에 사용하여 인증을 단순화하고 분산시킬 수 있기 때문이라고 합니다. Spring Security를 더욱 강화해서 확실히 익혀야겠습니다.
'[대외활동] > [캡스톤 디자인]' 카테고리의 다른 글
[보안] RefreshToken, Redis 도입으로 성능 개선 및 탈취 당한 AccessToken 문제 방지 (0) | 2024.01.23 |
---|---|
캡스톤 디자인 PART.JWT 관리자 정책 변경 (0) | 2023.08.18 |
캡스톤 디자인 PART.스프링 & 리액트 프로젝트 연동 (0) | 2023.07.22 |
캡스톤 디자인 PART.DB 모델링 변경사항 (0) | 2023.07.11 |
캡스톤 디자인 PART.10, 11주차(구현 : 초기 엔터티 개선, 로그인, 회원가입 기능) (0) | 2023.06.10 |