토큰 기반 인증을 구현해봅시다.
springboot:2.7.12
org.springframework.boot:spring-boot-starter-web
org.springframework.boot:spring-boot-starter-data-jpa
org.springframework.boot:spring-boot-starter-security
(암호화를 위해 사용)org.springframework.boot:spring-boot-starter-data-redis
org.mapstruct:mapstruct:1.5.3.Final
io.jsonwebtoken:jjwt-api:0.11.5
io.jsonwebtoken:jjwt-impl:0.11.5
io.jsonwebtoken:jjwt-jackson:0.11.5
com.h2database:h2
lombok
패키지 구성은 Layered Architecture로 구성하였고 이전 세션 기반 인증을 발전시켜서 작성해보겠습니다.
JwtManager.class
package com.example.loginexample.domain.auth;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
@Component
public class JwtManager {
public static final String HTTP_ONLY = "HttpOnly";
public static final String ACCESS_TOKEN = "accessToken";
public static final String REFRESH_TOKEN = "refreshToken";
private final Key secretKey;
private final long ACCESS_TOKEN_VALIDATION_MILLIS;
private final long REFRESH_TOKEN_VALIDATION_MILLIS;
private final JwtParser parser;
public JwtManager(
@Value("${jwt.secretKey}") String key,
@Value("${jwt.accessTokenValidationMillis}") String accessTokenValidationMillis,
@Value("${jwt.refreshTokenValidationMillis}") String refreshTokenValidationMillis
) {
this.secretKey = Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8));
this.ACCESS_TOKEN_VALIDATION_MILLIS = Long.parseLong(accessTokenValidationMillis);
this.REFRESH_TOKEN_VALIDATION_MILLIS = Long.parseLong(refreshTokenValidationMillis);
this.parser = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build();
}
public String createAccessToken(String memberId) {
Date now = new Date();
Date expiration = new Date(now.getTime() + ACCESS_TOKEN_VALIDATION_MILLIS);
return Jwts.builder()
.setSubject(memberId)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(secretKey)
.compact();
}
public String createRefreshToken(String memberId) {
Date now = new Date();
Date expiration = new Date(now.getTime() + REFRESH_TOKEN_VALIDATION_MILLIS);
return Jwts.builder()
.setSubject(memberId)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(secretKey)
.compact();
}
public String validTokenAndGetBody(String token) {
if (token == null)
return null;
Claims claims = null;
try {
claims = (Claims) parser.parse(token).getBody();
} catch (JwtException e) {
e.printStackTrace();
return null;
}
return claims.getSubject();
}
}
JwtRepository.class
package com.example.loginexample.domain.auth;
public interface JwtRepository {
String KEY = "refreshToken";
void saveToken(String token);
boolean isExistToken(String token);
void deleteToken(String token);
}
JwtRepositoryV1.class
package com.example.loginexample.domain.auth;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
@Repository
@RequiredArgsConstructor
public class JwtRepositoryV1 implements JwtRepository {
private final RedisTemplate<String, String> template;
@Override
public void saveToken(String token) {
template.opsForSet().add(KEY, token);
}
@Override
public boolean isExistToken(String token) {
return template.opsForSet().isMember(KEY, token);
}
@Override
public void deleteToken(String token) {
template.opsForSet().remove(KEY, token);
}
}
JwtRepository
는 Redis
의 Set
자료구조를 이용해 구성하였습니다.AuthService.class
package com.example.loginexample.domain.auth;
public interface AuthService {
void saveToken(String token);
boolean isExistToken(String token);
void deleteToken(String token);
}
AuthService.class
package com.example.loginexample.domain.auth;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AuthServiceV1 implements AuthService {
private final JwtRepository jwtRepository;
@Override
public void saveToken(String token) {
jwtRepository.saveToken(token);
}
@Override
public boolean isExistToken(String token) {
return jwtRepository.isExistToken(token);
}
@Override
public void deleteToken(String token) {
jwtRepository.deleteToken(token);
}
}
package com.example.loginexample.facade;
import com.example.loginexample.domain.auth.AuthService;
import com.example.loginexample.domain.auth.JwtManager;
import com.example.loginexample.domain.member.MemberReadService;
import com.example.loginexample.dto.AuthenticationDto;
import com.example.loginexample.dto.MemberDto;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AuthMemberFacadeImpl implements AuthMemberFacade {
private final MemberReadService memberReadService;
private final AuthService authService;
private final JwtManager jwtManager;
private final PasswordEncoder passwordEncoder;
@Override
public MemberDto loginSession(String email, String password) {
MemberDto findMember = memberReadService.findMemberByEmail(email);
if (passwordEncoder.matches(password, findMember.getPassword()))
return findMember;
return null;
}
@Override
public AuthenticationDto loginJwt(String email, String password) {
MemberDto findMember = memberReadService.findMemberByEmail(email);
if (passwordEncoder.matches(password, findMember.getPassword()))
return createAuthentication(findMember.getMemberId());
return null;
}
@Override
public String tokenValidate(String token) {
return jwtManager.validTokenAndGetBody(token);
}
@Override
public AuthenticationDto reissueToken(String memberId) {
AuthenticationDto authentication = createAuthentication(memberId);
authService.saveToken(authentication.getRefreshToken());
return authentication;
}
private AuthenticationDto createAuthentication(String memberId) {
String accessToken = jwtManager.createAccessToken(memberId);
String refreshToken = jwtManager.createRefreshToken(memberId);
AuthenticationDto authenticationDto = AuthenticationDto.builder()
.memberId(memberId)
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
return authenticationDto;
}
@Override
public void deleteRefreshToken(String token) {
authService.deleteToken(token);
}
}
loginJwt
: email과 password를 확인해 토큰을 발급하는 메서드입니다.tokenValidate
: 토큰의 위변조 여부와 유효기간을 확인하는 메서드입니다.reissueToken
: 엑세스 토큰과 리프레시 토큰을 재발급 하는 메서드입니다.createAuthentication
: 엑세스 토큰과 리프레시 토큰을 생성하는 메서드입니다.deleteRefreshToken
: 리프레시 토큰을 삭제하는 메서드입니다.LoginCotroller.class
import static com.example.loginexample.domain.auth.JwtManager.ACCESS_TOKEN;
import static com.example.loginexample.domain.auth.JwtManager.REFRESH_TOKEN;
import static com.example.loginexample.presentation.auth.AuthMessage.*;
@RestController
@RequiredArgsConstructor
public class LoginController {
private final AuthMemberFacade authMemberFacade;
@PostMapping("/loginV1")
public ResponseEntity<ResponseDto> loginV1(@RequestBody LoginDto loginDto, HttpServletRequest request) {
MemberDto loginMember = authMemberFacade.loginSession(loginDto.getEmail(), loginDto.getPassword());
// 로그인 실패
if (loginMember == null)
throw new LoginFailureException();
// 로그인 성공
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
ResponseDto responseDto = ResponseDto.builder()
.body(LOGIN_SUCCESS.message)
.build();
return ResponseEntity
.status(HttpStatus.CREATED)
.body(responseDto);
}
@PostMapping("/loginV2")
public ResponseEntity<ResponseDto> loginV2(@RequestBody LoginDto loginDto, HttpServletResponse response) {
AuthenticationDto loginMember = authMemberFacade.loginJwt(loginDto.getEmail(), loginDto.getPassword());
// 로그인 실패
if (loginMember == null)
throw new LoginFailureException();
// 로그인 성공
// 토큰 쿠키 설정
setTokenCookies(response, loginMember);
ResponseDto responseDto = ResponseDto.builder()
.body(LOGIN_SUCCESS.message)
.build();
return ResponseEntity
.status(HttpStatus.CREATED)
.body(responseDto);
}
@PostMapping("/logoutV1")
public ResponseEntity<ResponseDto> logoutV1(HttpServletRequest request) {
request.getSession().invalidate();
ResponseDto responseDto = ResponseDto.builder()
.body(LOGOUT.message)
.build();
return ResponseEntity
.status(HttpStatus.OK)
.body(responseDto);
}
@PostMapping("/logoutV2")
public ResponseEntity<ResponseDto> logoutV2(
@CookieValue(name = "accessToken", required = false) Cookie accessTokenCookie,
@CookieValue(name = "refreshToken", required = false) Cookie refreshTokenCookie,
HttpServletResponse response
) {
List<Cookie> cookies = new ArrayList<>();
// 엑세스 토큰 쿠키 삭제
if (accessTokenCookie != null) {
accessTokenCookie.setMaxAge(0);
cookies.add(accessTokenCookie);
}
// 리프레시 토큰 쿠키 삭제
if (refreshTokenCookie != null) {
refreshTokenCookie.setMaxAge(0);
authMemberFacade.deleteRefreshToken(refreshTokenCookie.getValue());
cookies.add(refreshTokenCookie);
}
// 쿠키 추가
cookies.stream().forEach(cookie -> response.addCookie(cookie));
ResponseDto responseDto = ResponseDto.builder()
.body(LOGOUT.message)
.build();
return ResponseEntity
.status(HttpStatus.OK)
.body(responseDto);
}
@GetMapping("/is-loginV1")
public ResponseEntity<ResponseDto> isLoginV1(HttpServletRequest request) {
HttpSession session = request.getSession(false);
ResponseDto responseDto = ResponseDto
.builder()
.body((session != null) ? IS_LOGIN_TRUE.message : IS_LOGIN_FALSE.message)
.build();
return ResponseEntity
.ok()
.body(responseDto);
}
@GetMapping("/is-loginV2")
public ResponseEntity<ResponseDto> isLoginV2(
@CookieValue(name = "accessToken", required = false) Cookie accessTokenCookie,
@CookieValue(name = "refreshToken", required = false) Cookie refreshTokenCookie,
HttpServletResponse response
) {
Object message = null;
if (accessTokenCookie == null)
message = IS_LOGIN_FALSE.message;
else {
String memberId = authMemberFacade.tokenValidate(accessTokenCookie.getValue());
if (memberId != null)
message = IS_LOGIN_TRUE.message;
else if (
memberId != null &&
refreshTokenCookie != null &&
authMemberFacade.tokenValidate(refreshTokenCookie.getValue()) != null
) {
AuthenticationDto authenticationDto = authMemberFacade.reissueToken(memberId);
setTokenCookies(response, authenticationDto);
message = REISSUE_TOKEN;
} else
message = INVALID_TOKEN.message;
}
ResponseDto responseDto = ResponseDto
.builder()
.body(message)
.build();
return ResponseEntity
.ok()
.body(responseDto);
}
private void setTokenCookies(HttpServletResponse response, AuthenticationDto loginMember) {
// 엑세스 토큰 쿠키 설정
Cookie accessTokenCookie = new Cookie(ACCESS_TOKEN, loginMember.getAccessToken());
accessTokenCookie.setHttpOnly(true);
// 리프레쉬 토큰 쿠키 설정
Cookie refreshTokenCookie = new Cookie(REFRESH_TOKEN, loginMember.getAccessToken());
refreshTokenCookie.setHttpOnly(true);
// 토큰 추가
response.addCookie(accessTokenCookie);
response.addCookie(refreshTokenCookie);
}
}
loginV2
authMemberFacade.loginDto
을 통해 AuthenticationDto를 반환받습니다.LoginFailureException
을 발생시킵니다.logoutV2