오늘은 저번 포스팅에서 했던 Security + JWT로 로그인을 구현했습니다. 이번에는 RefreshToken을 이용하려고 하는데요 이 RefreshToken을 이용하는 이유를 설명하겠습니다.
먼저 저희가 로그인 하면 AccessToken을 발급받게 됩니다. 이 AccessToken은 유효시간이 짧습니다. 저번 코드에서는 30분이었는데 이번 코드에서는 실습을 위해 2분으로 설정했습니다. 이렇게 AccessToken의 유효시간은 짧아서 매 30분마다 로그인을 다시 해야 하는데요 이런 불편함을 해결하고자 RefreshToken을 사용하게 됩니다.
기본적인 로직을 설명드리겠습니다.
1. 사용자가 로그인시 AccessToken, RefreshToken을 발급합니다. 이 두 Token은 Front 측에서 LocalStorege에 보관합니다.
2. 이때 RefreshToken은 Server 측에서 관리해주기 위해 DB에 따로 저장합니다. 저희 소스에서는 현재 접속한 기기의 정보 또한 저장합니다. 이때 접속한 기기의 정보는 Header에 User-Agent의 값으로 설정했습니다.
3. 사용자가 AccessToken으로 인증이 필요한 페이지를 요청하면 유효한 AccessToken일 경우 정상 접속이됩니다.
4. 하지만 AccessToken이 유효한 토큰이 아닐 경우 RefreshToken을 이용하여 AccessToken을 재발급합니다.
5. 프런트는 AccessToken을 새로 LocalStorege에 다시 저장합니다. AccessToken이 갱신되었으므로 다시 API요청을 정상적으로 수행합니다.
위 로직을 Spring Security를 이용하여 코드로 설명드리겠습니다.
먼저 프로젝트 구조는 다음과 같습니다.
보시면 Board와 관련된 클래스는 현 포스팅과 관련 없으므로 무시해주세요.
<전체 소스 코드>
https://github.com/Darren4641/Spring-Security-JWT
먼저 사용자 정보를 보여드리겠습니다.
package com.Model;
import com.Role.Role;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Builder
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class Users implements UserDetails {
@Id
private String id;
@Column(length = 255)
private String password;
@Column(length = 255, nullable = false)
private String email;
private String gender;
@Column(length = 13)
private String phone;
@Column(length = 8)
@Enumerated(EnumType.STRING)
private Role role;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
저번 포스팅에서도 설명했듯이 Spring Security를 이용하기 위해 UserDetails를 implements 하여 구현하였습니다. 저번 포스팅을 참고해주세요.
https://darrenh.tistory.com/11
그리고 RefreshToken을 DB에 저장하기 위해 따로 객체로 만들었습니다.
package com.Jwt;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Builder
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long refreshTokenId;
private String refreshToken;
private String keyId;
private String userAgent;
}
그리고 사용자에게 값을 전달해줄 Token을 따로 클래스로 추출하였습니다.
package com.Jwt;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Token {
private String grantType;
private String accessToken;
private String refreshToken;
private String key;
}
그리고 저는 사용자의 권한의 경우 권한에 따른 게시판 작성 및 요청을 위해 따로 enum으로 빼주었습니다.
package com.Role;
import com.fasterxml.jackson.annotation.JsonCreator;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public enum Role {
ADMIN("ROLE_ADMIN"),
USER("ROLE_USER");
private String value;
@JsonCreator
public static Role from(String s) {
return Role.valueOf(s.toUpperCase());
}
}
그럼 기본적인 준비는 끝이 났습니다. 추가로 저는 JPA를 이용했기 때문에 Repository를 생성해줍니다.
package com.Repository;
import com.Model.Users;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UsersRepository extends JpaRepository<Users, String> {
Optional<Users> findById(String id);
Optional<Users> findByEmail(String email);
Page<Users> findAll(Pageable pageable);
}
현재 findByEmail은 사용하고 있지 않습니다.
먼저 저희가 SpringSecurity 설정을 해주어야 하기 때문에 SecurityConfig.class를 작성해줍니다.
package com.Security;
import com.ErrorHandler.AutheniticationEntryPointHandler;
import com.ErrorHandler.WebAccessDeniedHandler;
import com.Jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final AutheniticationEntryPointHandler autheniticationEntryPointHandler;
private final WebAccessDeniedHandler webAccessDeniedHandler;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
//http.httpBasic().disable(); // 일반적인 루트가 아닌 다른 방식으로 요청시 거절, header에 id, pw가 아닌 token(jwt)을 달고 간다. 그래서 basic이 아닌 bearer를 사용한다.
http.httpBasic().disable()
.authorizeRequests()// 요청에 대한 사용권한 체크
.antMatchers("/test").authenticated()
.antMatchers("/write").authenticated()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.antMatchers("/**").permitAll()
.and()
.exceptionHandling()
.authenticationEntryPoint(autheniticationEntryPointHandler)
.accessDeniedHandler(webAccessDeniedHandler)
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class); // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
// + 토큰에 저장된 유저정보를 활용하여야 하기 때문에 CustomUserDetailService 클래스를 생성합니다.
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
return http.build();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring()
.antMatchers("/h2-console/**")
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
}
보시면 저번 포스팅과 다른 점이 추가되었습니다. 보시면 에러를 핸들링해주었는데요.
exceptionHandling() - 예외처리 기능 작동
authenticationEntryPoint(EntryPonit) - EntryPoint를 이용하여 인증 실패 시 처리
accessDeniedHandler(AccessDeniedHandler) - 인가 실패시 처리
먼저 Filter소스입니다.
package com.Security;
import com.Jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
//헤더에서 JWT를 받아온다.
String AccessToken = jwtTokenProvider.getAccessToken((HttpServletRequest) request);
//토큰 유효성 검사
if(AccessToken != null && jwtTokenProvider.validateToken(request, AccessToken)) {
Authentication authentication = jwtTokenProvider.getAuthentication(AccessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
authenticationEntryPointHandler와 webAccessDeniedHandler는 저희가 따로 커스텀해주었습니다.
package com.ErrorHandler;
import lombok.RequiredArgsConstructor;
import org.json.simple.JSONObject;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
@RequiredArgsConstructor
public class AutheniticationEntryPointHandler implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
String exception = (String) request.getAttribute("exception");
ErrorCode errorCode;
if(exception == null) {
errorCode = ErrorCode.UNAUTHORIZEDException;
setResponse(response, errorCode);
return;
}
//토큰이 만료된 경우
if(exception.equals("ExpiredJwtException")) {
errorCode = ErrorCode.ExpiredJwtException;
setResponse(response, errorCode);
return;
}
//아이디 비밀번호가 다를 경우
if(exception.equals("UsernameOrPasswordNotFoundException")) {
errorCode = ErrorCode.UsernameOrPasswordNotFoundException;
setResponse(response, errorCode);
}
}
private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException{
JSONObject json = new JSONObject();
response.setContentType("application/json;charset=UTF-8");
response.setCharacterEncoding("utf-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
json.put("code", errorCode.getCode());
json.put("message", errorCode.getMessage());
response.getWriter().print(json);
}
}
;
SecurityConfig에서 사용하기 위해 Bean에 등록하기 위해 @Component를 달았습니다.
AuthenticationEntryPoint를 implements 받아 구현해야 하는데요 commence() 메서드를 구현해야 합니다. Security에서 인증 실패 시 commence 메서드를 실행하게 됩니다.
ErrorCode는 Error를 enum Type으로 모아놓은 객체입니다. request에서 Exception의 값을 가져와 따로 저장한 다음에 에러 처리를 해주었습니다.
그럼 ErrorCode를 살펴보겠습니다.
package com.ErrorHandler;
import lombok.Getter;
import org.springframework.http.HttpStatus;
public enum ErrorCode {
UsernameOrPasswordNotFoundException (400, "아이디 또는 비밀번호가 일치하지 않습니다.", HttpStatus.BAD_REQUEST),
ForbiddenException(403, "해당 요청에 대한 권한이 없습니다.", HttpStatus.FORBIDDEN),
UNAUTHORIZEDException (401, "로그인 후 이용가능합니다.", HttpStatus.UNAUTHORIZED),
ExpiredJwtException(444, "기존 토큰이 만료되었습니다. 해당 토큰을 가지고 /refresh 링크로 이동해주세요.", HttpStatus.UNAUTHORIZED),
ReLogin(445, "모든 토큰이 만료되었습니다. 다시 로그인해주세요.", HttpStatus.UNAUTHORIZED),
;
@Getter
private int code;
@Getter
private String message;
@Getter
private HttpStatus status;
ErrorCode(int code, String message, HttpStatus status) {
this.code = code;
this.message = message;
this.status = status;
}
public String toString() {
return "{" +
"\"code\" : " + "\""+code+"\"" +
"\"status\" : " + "\""+status+"\"" +
"\"message\" : " + "\""+message+"\"" +
"}";
}
}
이렇게 인증이 실패 시 예외처리를 해주었고 이제 인가 처리 실패시 예외처리를 하도록 하겠습니다.
package com.ErrorHandler;
import org.json.simple.JSONObject;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class WebAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
ErrorCode errorCode = ErrorCode.ForbiddenException;
response.setContentType("application/json;charset=UTF-8");
response.setCharacterEncoding("utf-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
JSONObject json = new JSONObject();
json.put("code", errorCode.getCode());
json.put("message", errorCode.getMessage());
response.getWriter().print(json);
}
}
인가 실패 시 ForbiddenException을 던지게 됩니다.
인증 예외처리 시 Exception을 남기기 위해 AuthenticationException을 이용하겠습니다. abstract 클래스이므로 저희가 따로 커스텀해주어야 합니다.
package com.ErrorHandler;
import org.springframework.security.core.AuthenticationException;
public class AuthenticationCustomException extends AuthenticationException {
public AuthenticationCustomException(String msg, Throwable cause) {
super(msg, cause);
}
public AuthenticationCustomException(String msg) {
super(msg);
}
public AuthenticationCustomException(ErrorCode errorCode) {
super(errorCode.toString());
}
}
에러를 던질 때 메시지 대신 ErrorCode를 이용하여 에러를 출력하도록 했습니다.
저희가 Error나 리턴값을 던질 때 JSON형태로 값을 출력하기 위해 ApiResponse를 작성하겠습니다.
package com.ErrorHandler;
import lombok.Getter;
import lombok.Setter;
import org.springframework.http.HttpStatus;
import java.util.HashMap;
import java.util.Map;
@Setter
@Getter
public class ApiResponse {
private int code = HttpStatus.OK.value();
private Object result;
public ApiResponse() { }
public void setResult(Object result) {
this.result = result;
}
public static class ResponseMap extends ApiResponse {
private Map responseData = new HashMap();
public ResponseMap() {
setResult(responseData);
}
public void setResponseData(String key, Object value) {
this.responseData.put(key, value);
}
}
}
이제 모든 준비가 끝났으므로 Controller부터 설명드리겠습니다.
package com.Controller;
import com.ErrorHandler.ApiResponse;
import com.Model.Users;
import com.Repository.UsersRepository;
import com.Role.Role;
import com.Service.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.Collections;
import java.util.Map;
@RequiredArgsConstructor
@RestController
public class UsersController {
private final PasswordEncoder passwordEncoder;
private final UsersRepository usersRepository;
private final JwtService jwtService;
@PostMapping("/test")
public String test(){
return "<h1>test 통과</h1>";
}
@PostMapping("/join")
public String signUp(@RequestBody Map<String, String> user) {
Role role = Role.from(user.get("role"));
return usersRepository.save(Users.builder()
.id(user.get("id"))
.password(passwordEncoder.encode(user.get("password")))
.email(user.get("email"))
.gender(user.get("gender"))
.phone(user.get("phone"))
.role(role)
.roles(Collections.singletonList(role.getValue()))
.build()).toString();
}
@PostMapping("/login")
public ApiResponse login(HttpServletRequest request, @RequestBody Map<String, String> user, @RequestHeader("User-Agent") String userAgent) {
return jwtService.login(request, user, userAgent);
}
}
저번 포스팅처럼 /join의 경우 그대로 회원가입을 하게 됩니다.
그리고 "/login"으로 요청을 받게 되면 jwtService.login() 메서드를 타게 됩니다.
package com.Service;
import com.ErrorHandler.ApiResponse;
import com.ErrorHandler.AuthenticationCustomException;
import com.ErrorHandler.ErrorCode;
import com.Jwt.JwtTokenProvider;
import com.Jwt.RefreshToken;
import com.Jwt.Token;
import com.Model.Users;
import com.Repository.RefreshTokenRepository;
import com.Repository.UsersRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class JwtService {
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final AuthenticationManager authenticationManager;
private final UsersRepository usersRepository;
@Transactional
public ApiResponse login(HttpServletRequest request, Map<String, String> user, String userAgent) {
ApiResponse.ResponseMap result = new ApiResponse.ResponseMap();
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(user.get("id"), user.get("password"))
);
Users userinfo = usersRepository.findById(user.get("id")).get();
Token token = jwtTokenProvider.createToken(user.get("id"), userinfo.getRoles());
//RefreshToken을 DB에 저장
RefreshToken refreshToken = RefreshToken.builder()
.keyId(token.getKey())
.refreshToken(token.getRefreshToken()).
userAgent(userAgent).build();
String loginUserId = refreshToken.getKeyId();
//다음번 로그인시 UserAgent값이 다를 경우 다른 기기에서 로그인한경우인데 여기 if문에서 처리해주면 된다
if(refreshTokenRepository.existsByKeyIdAndUserAgent(loginUserId, userAgent)) {
refreshTokenRepository.deleteByKeyIdAndUserAgent(loginUserId, userAgent);
}
refreshTokenRepository.save(refreshToken);
result.setResponseData("accessToken", token.getAccessToken());
result.setResponseData("refreshToken", token.getRefreshToken());
result.setResponseData("key", token.getKey());
}catch (Exception e) {
e.printStackTrace();
request.setAttribute("exception", "UsernameOrPasswordNotFoundException");
throw new AuthenticationCustomException(ErrorCode.UsernameOrPasswordNotFoundException);
}
return result;
}
public ApiResponse newAccessToken(RefreshToken refreshToken) {
ApiResponse.ResponseMap result = new ApiResponse.ResponseMap();
if(refreshToken.getRefreshToken() != null) {
String newToken = jwtTokenProvider.validateRefreshToken(refreshToken);
result.setResponseData("accessToken", newToken);
}else {
result.setResponseData("code", ErrorCode.ReLogin.getCode());
result.setResponseData("message", ErrorCode.ReLogin.getMessage());
result.setResponseData("HttpStatus", ErrorCode.ReLogin.getStatus());
}
return result;
}
}
먼저 사용자의 id와 password가 맞는지 authenticationManager.authenticate를 이용하여 검사해줍니다. 이때 다를 경우 catch구문으로 이동하는데요 에러를 핸들링해주었습니다.
사용자의 정보가 맞다면 사용자의 정보를 저장합니다. 그 정보를 가지고 AccessToken을 만듭니다.
그리고 RefreshToken도 만들어준 뒤 DB에 저장해줍니다. 이때 다른 기기에서 로그인할 경우 UserAgent값이 다른데 이경우 기존의 데이터를 삭제 후 만다시 DB에 저장해줍니다. 그리고 리턴 값으로는 accesstoken과 refreshtoken 그리고 key값을 전달해줍니다.
JwtTokenProvider
package com.Jwt;
import com.ErrorHandler.AuthenticationCustomException;
import com.ErrorHandler.ErrorCode;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import java.util.Base64;
import java.util.Date;
import java.util.List;
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private String accessSecretKey = "Darren";
private String refreshSecretKey = "DarrenH";
//유효시간 2분
private long accessTokenValidTime = 2 * 60 * 1000L;
//유효시간 3분
private long refreshTokenValidTime = 3 * 60 * 1000L;
//유효시간 31일
// private long refreshTokenValidTime = 30 * 24 * 60 * 60 * 1000L;
private final UserDetailsService userDetailsService;
//secretKey를 Base64로 인코딩
@PostConstruct
protected void init() {
accessSecretKey = Base64.getEncoder().encodeToString(accessSecretKey.getBytes());
refreshSecretKey = Base64.getEncoder().encodeToString(refreshSecretKey.getBytes());
}
//jwt 토큰 생성
public Token createToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk);// JWT PayLoad에 저장되는 정보단위, PK값
claims.put("roles", roles);
Date currentTime = new Date();
String accessToken = getToken(claims, currentTime, accessTokenValidTime, accessSecretKey);
String refreshToken = getToken(claims, currentTime, refreshTokenValidTime, refreshSecretKey);
return Token.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.key(userPk).build();
}
private String getToken(Claims claims, Date currentTime, long tokenValidTime, String secretKey) {
return Jwts.builder()
.setClaims(claims) //정보 저장
.setIssuedAt(currentTime) //토큰 발행시간 정보
.setExpiration(new Date(currentTime.getTime() + tokenValidTime)) //Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) //암호화 알고리즘
.compact();
}
//jwt 토큰 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
if(userDetails == null)
return null;
else
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(accessSecretKey).parseClaimsJws(token).getBody().getSubject();
}
//Header에서 token값을 가지고온다.
public String getAccessToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
//토큰의 유효성 검사
public boolean validateToken(ServletRequest request, String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(accessSecretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (MalformedJwtException e) {
request.setAttribute("exception", "MalformedJwtException");
} catch (ExpiredJwtException e) {
request.setAttribute("exception", "ExpiredJwtException");
} catch (UnsupportedJwtException e) {
request.setAttribute("exception", "UnsupportedJwtException");
} catch (IllegalArgumentException e) {
request.setAttribute("exception", "IllegalArgumentException");
}
return false;
}
// RefreshToken 유효성 검증 메소드
public String validateRefreshToken(RefreshToken refreshTokenObj) {
String refreshToken = refreshTokenObj.getRefreshToken();
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(refreshSecretKey).parseClaimsJws(refreshToken);
//AccessToken이 만료되지않았을떄만
/*if(!claims.getBody().getExpiration().before(new Date())) {
return recreationAccessToken(claims.getBody().get("sub").toString(), claims.getBody().get("roles"));
}*/
return recreationAccessToken(claims.getBody().get("sub").toString(), claims.getBody().get("roles"));
}catch (Exception e) {
e.printStackTrace();
throw new AuthenticationCustomException(ErrorCode.ExpiredJwtException);
}
//토큰 만료시 login페이지 reDirect
}
//AccessToken 새로 발급
private String recreationAccessToken(String email, Object roles) {
Claims claims = Jwts.claims().setSubject(email);
claims.put("roles", roles);
Date currentTime = new Date();
return getToken(claims, currentTime, accessTokenValidTime, accessSecretKey);
}
}
저번 포스팅과 다른 점을 설명해드리겠습니다.
아까 SecurityConfig에서 AuthenticationEntryPoint에서 인증 실패 시 처리를 해준다 했는데요, 저희가 커스텀한 Filter를 보시면 validateToken을 실행하는데 이때 에러를 핸들링해서 커스텀한 EntryPoint를 적용시켜주게 됩니다.
validateRefreshToken() - 이 메서드는 RefreshToken의 유효성을 검사해서 유효하면 AccessToken을 재 발급해주는 메서드입니다.
추가로 필요한 소스입니다.
RefreshTokenRepository.interface
package com.Repository;
import com.Jwt.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByRefreshToken(String refreshToken);
boolean existsByKeyIdAndUserAgent(String userEmail, String userAgent);
void deleteByKeyIdAndUserAgent(String userEmail, String userAgent);
}
UserService.class 혹은 UserDetailService.class
package com.Service;
import com.ErrorHandler.AuthenticationCustomException;
import com.ErrorHandler.ErrorCode;
import com.Model.Users;
import com.Repository.UsersRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class UserService implements UserDetailsService {
private final UsersRepository usersRepository;
@Override
public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
Optional<Users> user = usersRepository.findById(id);
if(user.isPresent()) {
return user.get();
}
throw new AuthenticationCustomException(ErrorCode.UsernameOrPasswordNotFoundException);
}
}
RefreshController.class
package com.Controller;
import com.ErrorHandler.ApiResponse;
import com.Jwt.RefreshToken;
import com.Service.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class RefreshController {
private final JwtService jwtService;
@PostMapping("/refresh")
public ApiResponse validateRefreshToken(@RequestBody RefreshToken bodyJson) {
return jwtService.newAccessToken(bodyJson);
}
}
"/refresh"로 요청 시 새로운 AccessToken을 발급해줍니다.
해당 AccessToken을 가지고 다시 "/Test"로 요청하면 정상적으로 로그인이 되는 것을 볼 수 있다.
참조 자료
https://kdg-is.tistory.com/228
https://velog.io/@jkijki12/Jwt-Refresh-Token-%EC%A0%81%EC%9A%A9%EA%B8%B0
'스프링' 카테고리의 다른 글
Apple 로그인 Oauth 2.0 구현 (0) | 2023.10.27 |
---|---|
[KaKaoLogin RestAPI] oAuth2.0 + SpringBoot - (1) (0) | 2022.08.19 |
[Spring Security] + [JWT] 스프링 시큐리티 JWT 로그인[실습] (0) | 2022.08.12 |
[Spring Security] + [JWT] 스프링 시큐리티 JWT 로그인[이론] (1) | 2022.08.12 |
스프링부트 게시판 작성/목록 머스테치로 구현하기 (0) | 2022.01.26 |