이지은님의 블로그
250304 - Java Spring JWT 로그인 인증방식 구현하기 본문
▷ 오늘 배운 것
jwt를 구현하는 방법에 대해 TIL을 작성해보고자 한다.
Jwt를 사용하여 회원가입, 로그인, 로그아웃, 리플레시 토큰 발급을 구현해보자.
<<목차>>
1. JwtUtil
1) 각 필드 소개
2) 생성자
3) Access Token 생성
4) 토큰에서 문자열 빼기
5) 토큰에서 사용자 정보 가져오기
2.AuthController
1) 회원가입 & 로그인
2) 로그아웃
3) 액세스 토큰 발급
3. JwtFilter
4. AdminInterceptor
1. JwtUtil
먼저 다음과 같은 util 클래스가 있어야한다. (리프레시 토큰에 사용자 정보를 넣는 경우)
@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {
private static final String BEARER_PREFIX = "Bearer ";
private static final long ACCESS_TOKEN_TIME = 60 * 60 * 1000L; // access 토큰 시간
private static final long REFRESH_TOKEN_TIME = 7 * 24 * 60 * 60 * 1000L; // refresh 토큰 시간
@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
//Access Token 생성
public String createAccessToken(Long userId, String email, UserRole userRole) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("email", email)
.claim("userRole", userRole)
.setExpiration(new Date(date.getTime() + ACCESS_TOKEN_TIME)) //만료시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
//Refresh Token 생성 - 간단한 유저 정보만 저장(Access재발금용이라 그냥 유저 판단만 할 수 있을 정도만 필요하다)
public String createRefreshToken(Long userId) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(String.valueOf(userId))
.setExpiration(new Date(date.getTime() + REFRESH_TOKEN_TIME))
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
} // BEARER_PREFIX를 지우기 위함
throw new ServerException("Not Found Token");
}
// 검증 + 추출
public Claims extractClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}
1) 각 필드 소개
private static final String BEARER_PREFIX = "Bearer ";
private static final long ACCESS_TOKEN_TIME = 60 * 60 * 1000L; // access 토큰 시간
private static final long REFRESH_TOKEN_TIME = 7 * 24 * 60 * 60 * 1000L; // refresh 토큰 시간
@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
BEARER_PREFIX은 토큰 앞에 붙여줄 문자열 (7자), ACCESS_TOKEN_TIME는 Access Token의 만료기간(1시간), REFRESH_TOKEN_TIME는 Refresh Token의 만료기간(1주일)을 정의한다.
secretKey는 비밀키, Key는 암호화된키, signatureAlgorithm는 서명알고리즘을 뜻한다.(HS256, enum 형식)
2) 생성자
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
@PostConstruct: 스프링 Bean이 생성된 후 자동으로 실행.
Base64.getDecoder().decode(secretKey);
Base64에서 secretKey를 디코딩해서 bytes에 담는다.
Keys.hmacShaKeyFor(bytes);
서명을 위한 키를 생성한다.(jwt의 생성 및 검증에 사용)
3) Access Token 생성
//Access Token 생성
public String createAccessToken(Long userId, String email, UserRole userRole) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("email", email)
.claim("userRole", userRole)
.setExpiration(new Date(date.getTime() + ACCESS_TOKEN_TIME)) //만료시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
.setSubject()으로 문자열화 한 userId를 subject로 저장
.claim(): key-value 형태로 유저 정보 저장 (보통 유저 id, password외 저장)
.setExpiration() 만료시간을 정의하는데 현재시간에 엑세스토큰 시간을 더함
.setIssuedAt() 현재 시간으로 발급일을 저장
.signWith(): 비밀키와 암호화 알고리즘을 함께 저장(signature)
.compact(): builder()를 합치는 메서드
4) 토큰에서 문자열 빼기
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
throw new ServerException("Not Found Token");
}
"Bearer “가 포함되어 있다면 토큰에서 삭제시키기 위해 사용한다. 만약 없을 경우 예외를 발생시킨다.
5) 토큰에서 사용자 정보 가져오기
public Claims extractClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
Claims 형태로 사용자 정보를 가져올 수 있다.(subject로 id, email, UserRole)
2.AuthController
@RestController
@RequiredArgsConstructor
@RequestMapping("/auths")
public class AuthController {
private final AuthService authService;
// 회원가입
@PostMapping("/signup")
public TokenResponse signup(@Valid @RequestBody SignupRequest signupRequest) {
return authService.signup(signupRequest);
}
// 로그인
@PostMapping("/login")
public TokenResponse login(@Valid @RequestBody SigninRequest signinRequest) {
return authService.login(signinRequest);
}
// 로그아웃
@PostMapping("/logout")
public void logout(@Auth AuthUser authUser) {
authService.logout(authUser);
}
// 리프레시 토큰 재발급
@PostMapping("/refresh")
public TokenResponse reissueAccessToken(@RequestBody RefreshTokenRequest request) {
return authService.reissueAccessToken(request);
}
}
1) 회원가입 & 로그인
// Controller
@PostMapping("/signup")
public TokenResponse signup(@Valid @RequestBody SignupRequest signupRequest) {
return authService.signup(signupRequest);
}
회원가입을 하면 로그인 상태를 만들기 위해,
아이디, 비밀번호 등을 입력받고 회원가입을 한뒤 Access Token과 Refresh Token을 반환한다.
// Service
@Transactional
public TokenResponse signup(SignupRequest signupRequest) {
if (userRepository.existsByEmail(signupRequest.getEmail())) {
throw new InvalidRequestException("이미 존재하는 이메일입니다.");
}
String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
UserRole userRole = UserRole.of(signupRequest.getUserRole());
User user = new User(
signupRequest.getEmail(),
encodedPassword,
userRole
);
userRepository.save(user);
// 토큰발행
String accessToken = tokenService.createAccessToken(user);
String refreshToken = tokenService.createRefreshToken(user);
return new TokenResponse(accessToken, refreshToken);
}
유저 저장을 한후 토큰을 발행한다.
// TokenService
public String createAccessToken(User user) {
return jwtUtil.createAccessToken(user.getId(), user.getEmail(), user.getUserRole());
}
public String createRefreshToken(User user) {
// RefreshToken 저장 (DB 또는 Redis)
RefreshToken refreshToken = refreshTokenRepository.save(new RefreshToken(user.getId()));
return refreshToken.getToken();
}
AccessToken은 헤더+사용자정보+시그니처 구조의 토큰을,
RefreshToken은 UUID랜덤값의 토큰을 생성하여 Refresh는 DB에 저장한다.
2) 로그아웃
// Controller
@PostMapping("/logout")
public void logout(@Auth AuthUser authUser) {
authService.logout(authUser);
}
로그아웃은 refreshToken을 만료시키는 방법을 사용한다. 이때 만료의 경우 DB에서 enum값을 INVAILD로 설정시키면 된다.(비활성화)
// Service
@Transactional
public void logout(AuthUser authUser) {
tokenService.revokeRefreshToken(authUser.getId());
}
RefreshToken을 만료하는 메서드를 작성한다. 이때 유저 정보가 필요하다.
// TokenService
public void revokeRefreshToken(Long userId) {
RefreshToken refreshToken = refreshTokenRepository.findById(userId).orElseThrow(
() -> new InvalidRequestException("해당 유저의 Token이 존재하지 않음."));
refreshToken.updateStatus(INVALIDATED);
}
userId를 받아서 토큰을 DB에서 받아와서 상태를 비활성화로 만든다.
3) 액세스 토큰 발급
// Controller
@PostMapping("/refresh")
public TokenResponse reissueAccessToken(@RequestBody RefreshTokenRequest request) {
return authService.reissueAccessToken(request);
}
리프레시 토큰을 요청값으로 넣고 새로 발급한 엑세스와 리플레시 토큰을 반환해준다.(refresh token rotation 방식)
// Service
@Transactional
public TokenResponse reissueAccessToken(@RequestBody RefreshTokenRequest request) {
User user = tokenService.reissueToken(request);
String accessToken = tokenService.createAccessToken(user);
String refreshToken = tokenService.createRefreshToken(user);
return new TokenResponse(accessToken,refreshToken);
}
입력받은 리플래시 토큰이 유효한지 확인하고 유저정보를 반환한다.
그 후, 새로 발급한 엑세스와 리플레시 토큰을 응답으로 반환한다.
// TokenService
public User reissueToken(RefreshTokenRequest request) {
String token = request.getRefreshToken();
RefreshToken refreshToken = refreshTokenRepository.findByToken(token).orElseThrow(
() -> new InvalidRequestException("유저 찾을 수 없음"));
if (INVALIDATED == refreshToken.getStatus()) {
throw new InvalidRequestException("유효기간이 지난 refresh 토큰입니다. 다시 로그인 해주세요");
}
return userService.findUserByIdOrElseThrow(refreshToken.getUserId());
}
리플래시 토큰을 검색해서 유효값을 검색, 이후 유저 정보를 반환한다.
3. JwtFilter
@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {
private final JwtUtil jwtUtil;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String url = httpRequest.getRequestURI();
if (url.startsWith("/auth")) {
chain.doFilter(request, response);
return;
}
// 로그인 검증
String bearerJwt = httpRequest.getHeader("Authorization");
if (bearerJwt == null) {
// 토큰이 없는 경우 400을 반환합니다.
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
return;
}
String jwt = jwtUtil.substringToken(bearerJwt); //베어러 제거
try {
// JWT 유효성 검사와 claims 추출
Claims claims = jwtUtil.extractClaims(jwt);
if (claims == null) {
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
return;
}
// 검증만하고 jwt에 담아줌
httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
httpRequest.setAttribute("email", claims.get("email"));
httpRequest.setAttribute("userRole", claims.get("userRole"));
chain.doFilter(request, response);
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
} catch (Exception e) {
log.error("Invalid JWT token, 유효하지 않는 JWT 토큰 입니다.", e);
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "유효하지 않는 JWT 토큰입니다.");
}
}
@Override
public void destroy() {
Filter.super.destroy();
}
}
특정 컨트롤러에서 비로그인 사용자를 필터링한다.
비로그인자 판단의 경우 AccessToken의 유무와 유효성을 판단한다.
// 로그인 검증
String bearerJwt = httpRequest.getHeader("Authorization");
if (bearerJwt == null) {
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
return;
}
String jwt = jwtUtil.substringToken(bearerJwt); //베어러 제거
try {
// JWT 유효성 검사와 claims 추출
Claims claims = jwtUtil.extractClaims(jwt);
if (claims == null) {
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
return;
}
httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
httpRequest.setAttribute("email", claims.get("email"));
httpRequest.setAttribute("userRole", claims.get("userRole"));
로그인 유저를 확인하기 위해 header에서 "Authorization”값을 얻고, bearer문자열을 제거하여, 사용자 정보를 추출한다. 사용자 정보는 httpRequest에 저장하여 argument resolver에서 처리한다.
4. AdminInterceptor(선택)
@Slf4j
public class AdminAuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));
log.info(String.valueOf(userRole));
if(UserRole.ADMIN != userRole) {
log.warn("관리자가 아닌 사용자 접근");
throw new AuthException("관리자가 아닌 사용자 접근");
}
return true;
}
}
request에서 사용자 정보를 얻어와 권한을 판단하여 접근가능 권한이 아닐시 예외를 발생시킨다.