이 글은 JWT의 개념과 Spring Security에 JWT를 적용하는 과정에 대해 설명한다.
JWT란?
JWT(Json Web Token)는 JSON 형태의 토큰으로, 웹서비스의 인증 시스템에서 주로 사용된다.
참고로 토큰은 간단하게 말해, 사용자가 로그인에 성공할시 서버가 전달해주는 문자열이다.
기존에 주로 사용되던 세션 인증 방식은 Stateful 방식이기 때문에 매 요청마다 서버와의 통신이 필요했다. 즉 서버에게 부담이 되는 방식이었다. JWT는 이러한 문제들을 해결하기 위해 등장했다. Stateless 인 것이 특징이며, 토큰 내에 미리 인증에 필요한 정보들을 넣어두기 때문에 매 요청마다 서버와 통신할 필요가 없어졌다. 서버 부하를 해결하게 된 것이다.
그러나 JWT 또한 문제는 존재한다. 서버에서 한 번 발급한 이후로는 토큰에 대한 제어권을 상실하기 때문에, 탈취와 같은 상황에서 유연하게 대처할 수 없는 것이다.
JWT 구조
JWT는 헤더, 페이로드, 서명으로 이루어져 있다.
- 헤더 : 서명 생성을 위해 어떤 알고리즘을 사용할지 식별하는 부분이다. 알고리즘 방식과 토큰의 타입을 지정할 수 있다.
- 페이로드 : 일련의 클레임을 포함한다. 토큰의 목적에 따라 여러가지 정보를 이곳에 추가할 수 있다.
- 표준 클레임 : 필수적이진 않으나, 일반적으로 사용되는 클레임 몇가지를 소개하겠다.
- iss(issuer) : JWT의 발행자
- sub(subject) : JWT의 제목
- aud(audience) : JWT가 의도한 수신자
- exp(expiration time) : JWT가 만료되는 시간
- nbf(not before time) : JWT의 활성 날짜로, 해당 날짜 이전에는 토큰이 처리되지 않는다.
- iat(issued at time) : JWT가 발급된 시간으로, JWT의 수명을 결정하는데 사용할 수 있다.
- jti(JWT ID) : 고유 식별자로, JWT가 재생되는 것을 방지하는데 사용할 수 있다.
- 표준 클레임 : 필수적이진 않으나, 일반적으로 사용되는 클레임 몇가지를 소개하겠다.
- 서명 : 토큰을 인코딩하거나 유효성을 검증하기 위해 이용되는 부분이다.
- 서명 생성 절차
- 헤더와 페이로드를 Base64URL을 통해 인코딩하고, 이 둘을 . 구분자로 연결한다.
- 헤더에서 정한 알고리즘과 secret key 값을 통해 암호화(해싱)한 값을 Base64URL 인코딩한다.
- 위 결과로 나온 값이 바로 서명이다.
- 서명 생성 절차
JWT는 위 헤더, 페이로드, 서명의 각 값을 모두 . 구분자로 연결지어 생성된다.
JWT 사용시 주의점
JWT의 가장 핵심적인 부분은 시크릿키에 대한 설정이다. 시크릿키를 알면 토큰을 생성하고 변조하여 악의적인 행동을 할 수 있다. 따라서 시크릿키는 안전하게 설정하고 보관할 필요가 있다.
시크릿키는 최소 512 bits 이상으로 설정하고, 환경변수와 같은 방식을 통해 안전하게 보관하도록 한다.
또한 JWT는 디코딩이 쉽기 때문에 민감하거나 중요한 데이터는 포함하지 않아야 한다.
JWT 사용시 문제점과 해결책
JWT는 서버의 부하를 줄이기 위해 탄생했기 때문에 태생적으로 Stateless 이다. 따라서 한 번 발급되고 나면 서버는 해당 토큰에 대한 제어권을 상실하고 만다. 이에 대한 문제를 해결하기 위해 일반적으로 통용되는 해결책은 다음과 같다.
1. 토큰의 만료 시간 설정
JWT의 만료 시간을 설정하여 특정 시점 이후로는 토큰을 이용할 수 없게 한다. 만료시간을 짧게 두는 것이 피해를 최소화 할 수 있다.
하지만 토큰의 만료시간을 짧게 설정한다면, 그만큼 다시 발급 받아야 하므로, 즉 재로그인해야 하므로 번거롭다. 이를 해결하기 위해 등장한 것이 바로 Access Token과 Refresh Token이다.
2. AccessToken, Refresh Token
Access Token은 실제로 인증하는 용도로 사용한다. 그리고 Refresh Token은 Access Token이 만료되었을 때 새로 발급하는 용도로만 제한하여 사용한다.
예를 들어, Access Token이 1시간, Refresh Token이 2주의 만료 시간을 가지도록 했다면 Refresh Token 값을 이용하여 1시간마다 새로운 Access Token으로 교체하여 사용할 수 있다. 2주가 지나 Refresh Token 또한 만료된다면 로그인을 통해 두 토큰 모두 새롭게 재발급 받으면 된다.
참고
https://leffept.tistory.com/450
[JWT]JWT 사용시 주의할 점 & 문제점
안녕하세요, 오늘은 지난번 포스팅에 이어 JWT를 무턱대고 사용할 때 생기는 문제점과 주의해야할 점들에 대해서 이야기 해보겠습니다. JWT 사용시 주의할 점 시크릿 키의 설정 JWT의 가장 핵심적
leffept.tistory.com
Spring Security + JWT
이제 본격적으로 Spring Security에 JWT를 적용해 보겠다.
Spring Security 설치 후 아무런 추가 설정도 하지 않았다면 http://localhost:8080/login 접속시 다음과 같은 화면이 보일 것이다.
Spring Security는 확장성이 좋은 구조를 가지고 있다.
따라서 spring-boot-starter-security 의존성 추가시 Spring Boot는 자동으로 Spring Security의 기본 설정을 활성화하고 기본적인 보안 설정을 제공하지만, 개발자가 제공한 설정으로 기본 설정을 덮어쓰고 확장할 수도 있다.
바로 다음과 같이 말이다.
@Configuration
public class SecurityConfig {
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests((authorize) -> authorize.requestMatchers("/login").permitAll().anyRequest().hasRole("USER"))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
}
위 코드를 보면 disable 되어 있는 설정이 눈에 띌 것이다.
JWT를 사용한 인증 방식은 Stateless 이므로 기존의 세션이나 폼 로그인과는 동작 방식이 다르다.
csrf는 세션 기반 인증에서 주로 사용되는 공격을 방지하는 메커니즘이고, formLogin은 세션을 사용하는 방식이며, HTTP Basic 인증은 HTTP 헤더에 사용자 자격 증명을 포함하여 인증하는 방식으로 모두 JWT 기반 인증에는 불필요하기에 비활성화 처리를 해두었다.
sessionManagement의 SessionCreationPolicy.STATELESS 또한 같은 이유로 세션을 비활성화한 것이다.
authorizeHttpRequests의 경우 특정 엔드포인트에 인증이 필요없도록 설정하여 누구나 접근할 수 있게 하거나, 특정 권한이 필요하도록 설정할 수 있다.
이제 JWT 관련하여 추가적인 작업이 필요하다. 조금 복잡하게 느껴질 수 있지만 결국 무엇을 위한 것인지 생각하면서 구현해 나가면 된다.
우선 토큰을 생성하고, 갱신하고, 제거하고, 응답하기 위해 헤더에 넣고, 요청에서 꺼내고, 검증하는 작업이 필요하다.
그리고 Spring Security의 필터 중 UsernamePasswordAuthenticationFilter는 form 로그인시 작업을 처리하는 필터이고, SecurityConfig에서 form 로그인을 비활성화 했기 때문에 JWT 관련 작업을 수행할 새로운 필터가 필요하다.
아쉽게도 Spring Security에서는 해당 역할의 필터를 아직 제공하지 않기 때문에 우리가 커스텀해야 한다.
하지만 대부분을 UsernamePasswordAuthenticationFilter와 유사하게 만들 것이기 때문에 어렵지 않다.
차근차근 해보자.
JwtService
먼저 토큰 관련 작업을 위해 Service 인터페이스와 구현 클래스를 구현하겠다.
public interface JwtService {
// create
String createAccessToken(String userId);
String createRefreshToken();
// update
void updateRefreshToken(String userId, String refreshToken);
// destroy
void destroyRefreshToken(String userId);
// send
void sendAccessAndRefreshToken(HttpServletResponse response, String userId, String accessToken, String refreshToken);
void sendAccessToken(HttpServletResponse response, String accessToken);
// extract
String extractAccessToken(HttpServletRequest request);
String extractRefreshToken(HttpServletRequest request);
String extractUserId(String accessToken);
// set
void setAccessTokenHeader(HttpServletResponse response, String accessToken);
void setRefreshTokenHeader(HttpServletResponse response, String userId, String refreshToken);
// validation
void isTokenValid(String token);
}
@Transactional
@Service
public class JwtServiceImpl implements JwtService {
private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
private static final String USERID_CLAIM = "userId";
private static final String BEARER = "Bearer ";
@Value("${jwt.secret}") // secret key
private String secret;
@Value("${jwt.access.expiration}")
private long accessTokenValiditySeconds;
@Value("${jwt.refresh.expiration}")
private long refreshTokenValidityInSeconds;
@Value("${jwt.access.header}")
private String accessHeader;
@Value("${jwt.refresh.header}")
private String refreshHeader;
@Autowired
private UserMapper userMapper;
// access token 생성
@Override
public String createAccessToken(String userId) {
return JWT.create() // jwt 토큰 생성
.withSubject(ACCESS_TOKEN_SUBJECT) // jwt subject 지정
.withExpiresAt(new Date(System.currentTimeMillis() + accessTokenValiditySeconds)) // 만료시간 설정 (accessTokenValiditySeconds 시간 후에 만료)
.withClaim(USERID_CLAIM, userId) // claim 추가
.withClaim("roles", List.of(Role.ADMIN.getValue()))
.sign(Algorithm.HMAC512(secret)); // HMAC512 알고리즘으로 암호화
}
// request token 생성
@Override
public String createRefreshToken() {
return JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withExpiresAt(new Date(System.currentTimeMillis() + refreshTokenValidityInSeconds * 1000))
.sign(Algorithm.HMAC512(secret));
}
// request token 재발급
@Override
public void updateRefreshToken(String userId, String refreshToken) {
UserDto userDto = userMapper.selectUserByUserId(userId);
if (userDto == null) {
throw new UserException(UserExceptionMessage.ACCOUNT_NOT_FOUND.getMessage());
}
userDto.setRefreshToken(refreshToken);
updateUserRefreshToken(userId, refreshToken);
}
// request token 제거 - 로그아웃
@Override
public void destroyRefreshToken(String userId) {
UserDto userDto = userMapper.selectUserByUserId(userId);
if (userDto == null) {
throw new UserException(UserExceptionMessage.ACCOUNT_NOT_FOUND.getMessage());
}
userDto.destroyRefreshToken();
updateUserRefreshToken(userId, null);
}
// access token, request token 발급 - 로그인
@Override
public void sendAccessAndRefreshToken(HttpServletResponse response, String userId, String accessToken, String refreshToken) {
response.setStatus(HttpServletResponse.SC_OK);
setAccessTokenHeader(response, accessToken);
setRefreshTokenHeader(response, userId, refreshToken);
updateUserRefreshToken(userId, refreshToken);
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put(ACCESS_TOKEN_SUBJECT, accessToken);
tokenMap.put(REFRESH_TOKEN_SUBJECT, refreshToken);
}
// access token 발급
@Override
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setStatus(HttpServletResponse.SC_OK);
setAccessTokenHeader(response, accessToken);
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put(ACCESS_TOKEN_SUBJECT, accessToken);
}
// get access token
@Override
public String extractAccessToken(HttpServletRequest request) {
String accessToken = request.getHeader(accessHeader);
if (accessToken != null && accessToken.startsWith(BEARER)) {
String token = accessToken.substring(BEARER.length());
isTokenValid(token);
userIdValid(token);
return token;
}
return null;
}
// get refresh token
@Override
public String extractRefreshToken(HttpServletRequest request) {
String refreshToken = request.getHeader(refreshHeader);
if (refreshToken != null && refreshToken.startsWith(BEARER)) {
String token = refreshToken.substring(BEARER.length());
isTokenValid(token);
return token;
}
return null;
}
// get user id
@Override
public String extractUserId(String accessToken) {
try {
isTokenValid(accessToken);
userIdValid(accessToken);
return JWT.require(Algorithm.HMAC512(secret))
.build()
.verify(accessToken)
.getClaim(USERID_CLAIM)
.asString();
} catch (Exception e) {
throw new TokenException(UserExceptionMessage.INVALID_VERIFY_TOKEN.getMessage());
}
}
// access token add header
@Override
public void setAccessTokenHeader(HttpServletResponse response, String accessToken) {
response.setHeader(accessHeader, BEARER + accessToken);
}
// refresh token add header
@Override
public void setRefreshTokenHeader(HttpServletResponse response, String userId, String refreshToken) {
response.setHeader(refreshHeader, refreshToken);
}
// update db
public void updateUserRefreshToken(String userId, String refreshToken) {
Map params = new HashMap();
params.put("userId", userId);
params.put("refreshToken", refreshToken);
params.put("upId", userId);
userMapper.updateRefreshToken(params);
}
// token 검증
@Override
public void isTokenValid(String token) {
try {
DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(secret)).build().verify(token);
} catch (Exception e) {
throw new TokenException(UserExceptionMessage.INVALID_VERIFY_TOKEN.getMessage());
}
}
// user id 클레임 검증
public void userIdValid(String token) {
DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(secret)).build().verify(token);
// userId 클레임이 있는지 확인
String userId = decodedJWT.getClaim(USERID_CLAIM).asString();
if (userId == null || userId.isEmpty()) {
throw new TokenException(UserExceptionMessage.INVALID_VERIFY_TOKEN.getMessage());
}
}
}
다음은 createAccessToken() 메서드이다. 사실 주석만 봐도 이해가 되겠지만 조금 더 자세히 살펴보겠다.
// access token 생성
@Override
public String createAccessToken(String userId) {
return JWT.create() // jwt 토큰 생성
.withSubject(ACCESS_TOKEN_SUBJECT) // jwt subject 지정
.withExpiresAt(new Date(System.currentTimeMillis() + accessTokenValiditySeconds * 1000)) // 만료시간 설정 (accessTokenValiditySeconds 시간 후에 만료)
.withClaim(USERID_CLAIM, userId) // claim 추가
.withClaim("roles", List.of(Role.ADMIN.getValue()))
.sign(Algorithm.HMAC512(secret)); // HMAC512 알고리즘으로 암호화
}
- JWT.create()
JWT 빌더 객체를 생성하는 부분이다. 이 객체를 사용하여 토큰에 필요한 정보를 추가하고 최종적으로 토큰을 생성할 수 있다.
- .withSubject()
토큰의 sub 클레임을 설정한다. 즉 토큰의 주제를 지정할 수 있다.
- .withExpiresAt()
토큰의 만료 시간을 설정한다. 위 코드에서 현재 시간에 Access 토큰의 유효 시간을 더한 것은 지금으로부터 Access 토큰의 유효 시간 뒤로 만료 시간을 설정한 것이다.
- .withClaim()
토큰에 커스텀 클레임을 설정한다.
- sign()
지정된 알고리즘과 secret key를 사용하여 토큰에 서명한다.
JsonUsernamePasswordAuthenticationFilter
클라이언트에서 Json 형태로 보낸 데이터를 파싱하여 Id와 Password를 꺼내기 위한 필터이다.
다음은 기존의 form 로그인 방식에서 사용하던 UsernamePasswordAuthenticationFilter이다. 해당 필터와 유사하게 작성해 보겠다.
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login", "POST");
private String usernameParameter = "username";
private String passwordParameter = "password";
private boolean postOnly = true;
public UsernamePasswordAuthenticationFilter() {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}
public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
}
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String username = this.obtainUsername(request);
username = username != null ? username.trim() : "";
String password = this.obtainPassword(request);
password = password != null ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
@Nullable
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(this.passwordParameter);
}
@Nullable
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(this.usernameParameter);
}
protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
public void setUsernameParameter(String usernameParameter) {
Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
this.usernameParameter = usernameParameter;
}
public void setPasswordParameter(String passwordParameter) {
Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
this.passwordParameter = passwordParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getUsernameParameter() {
return this.usernameParameter;
}
public final String getPasswordParameter() {
return this.passwordParameter;
}
}
다음은 JsonUsernamePasswordAuthenticationFilter이다. UsernamePasswordAuthenticationFilter와 동일하게 AbstractAuthenticationProcessingFilter를 상속받고 있다.
public class JsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final String DEFAULT_LOGIN_REQUEST_URL = "/login";
private static final String HTTP_METHOD = "POST";
private static final String CONTENT_TYPE = "application/json";
private static final String USERID_KEY = "userId";
private static final String PASSWORD_KEY = "password";
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD);
private final ObjectMapper objectMapper;
public JsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {
super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);
this.objectMapper = objectMapper;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
if (request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
String userId = usernamePasswordMap.get(USERID_KEY);
String password = usernamePasswordMap.get(PASSWORD_KEY);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(userId, password);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
- 생성자
ObjectMapper를 주입받아 JSON 처리를 수행한다. ObjectMapper는 역직렬화와 직렬화 기능을 제공하는 클래스이다.
또한 Matcher를 사용하여 필터가 적용될 요청을 식별한다.
AntPathRequestMatcher 클래스는 특정 HTTP 요청이 필터나 보안 설정의 적용 대상인지 판단하는 데 사용된다.
즉 DEFAULT_LOGIN_REQUEST_URL은 POST 메서드로 /login URL에 도달하는 요청을 매칭한다.
- attemptAuthentication()
실제 인증 처리를 수행하는 메서드이다. 필터로 가로챈 요청에서 사용자명과 비밀번호를 추출하고 인증을 시도한다.
해당 메서드를 단계별로 살펴보면 다음과 같다.
1. Content-Type 검사
2. request.getInputStream()을 사용하여 요청의 본문 읽기
3. objectMapper.readValue()를 통해 JSON 문자열을 Map<String, String> 형식으로 변환
4. Map에서 사용자명과 비밀번호를 추출하고, 인증 객체에 담기
5. 인증 객체를 통해 인증 시도
6. 인증 매니저가 설정된 AuthenticationProvider를 통해 실제 인증 로직 수행
JwtAuthenticationProcessingFilter
이제 JWT에 대한 작업을 수행하는 필터를 커스텀 해보겠다. 클라이언트의 요청에서 Access 토큰과 Refresh 토큰을 추출하여 인증을 처리하고 필요한 경우 새로운 Access 토큰을 재발급할 것이다.
한 요청에 한 번만 실행되면 되기 때문에, OncePerRequestFilter를 상속받았다.
public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter {
private final AntPathMatcher pathMatcher = new AntPathMatcher();
private final List<String> NO_CHECK_URLS = List.of("/login", "/", "/signup/**");
private final JwtService jwtService;
private final UserMapper userMapper;
private final GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
public JwtAuthenticationProcessingFilter(JwtService jwtService, UserMapper userMapper) {
this.jwtService = jwtService;
this.userMapper = userMapper;
}
/*
1. 로그인
access token, refresh token 발급
2. access token 검증
2-1. access token 만료
- refresh token 검증
- refresh token 만료 => 재로그인 필요
- refresh token 유효 => access token 발급
2-2. access token 유효 => 유효한 회원
* */
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 지정한 url에 대해 필터링하지 않는다.
for (String noCheckUrl : NO_CHECK_URLS) {
if (pathMatcher.match(noCheckUrl, request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
}
String accessToken = jwtService.extractAccessToken(request);
String refreshToken = jwtService.extractRefreshToken(request);
// access token 만료 => refresh token 검증
if (accessToken == null) {
checkRefreshTokenAndReIssueAccessToken(response, refreshToken);
}
// access token 유효 => 유효한 회원
String userId = jwtService.extractUserId(accessToken);
UserDto userDto = userMapper.selectUserByUserId(userId);
if (userDto == null) {
throw new UserException(UserExceptionMessage.ACCOUNT_NOT_FOUND.getMessage());
}
saveAuthentication(userDto);
filterChain.doFilter(request, response);
}
// refresh token 유효 & access token 발급
private void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) throws IOException {
UserDto userDto = userMapper.selectUserByRefreshToken(refreshToken);
if (userDto != null) {
jwtService.sendAccessToken(response, jwtService.createAccessToken(userDto.getUserId()));
} else {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("refresh token is invalid or expired");
response.getWriter().flush();
}
}
private void saveAuthentication(UserDto userDto) {
UserDto user = UserDto.builder()
.userId(userDto.getUsername())
.pwd(userDto.getPassword())
.role(userDto.getRole()) // 권한
.build();
Authentication authentication = new UsernamePasswordAuthenticationToken(user, null, authoritiesMapper.mapAuthorities(user.getAuthorities()));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
}
사실 Access 토큰과 Refresh 토큰의 발급에 대해서 적지 않은 고민을 해보았고, 내가 내린 결론은 업무에 따라 다르다는 것이다.
예를 들어, 'Access 토큰의 유효 기간은 남아 있는데 Refresh 토큰의 유효 기간은 만료된 경우에 인증을 성공되어야 하는가' 와 같은 고민이 있을 때, 은행과 같이 인증과 보안이 철저하게 지켜져야 하는 업무라면 인증이 실패되어야 할 것이고, 상대적으로 인증이 덜 중요하거나 사용자의 경험성이 더 중요한 경우에는 인증이 성공되어야 할 것이다.
Handler
성공, 실패 각각의 경우에 대한 처리 로직을 위해 핸들러가 필요하다.
LoginSuccessJWTProvideHandler
public class LoginSuccessJWTProvideHandler extends SimpleUrlAuthenticationSuccessHandler {
@Autowired
private JwtService jwtService;
@Autowired
private UserMapper userMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
String userId = extractEmail(authentication);
String accessToken = jwtService.createAccessToken(userId);
String refreshToken = jwtService.createRefreshToken();
jwtService.sendAccessAndRefreshToken(response, userId, accessToken, refreshToken);
UserDto selectedUser = userMapper.selectUserByUserId(userId);
if (selectedUser != null) {
selectedUser.setRefreshToken(refreshToken);
jwtService.updateRefreshToken(userId, refreshToken);
}
response.getWriter().write("success");
}
private String extractEmail(Authentication authentication) {
UserDto userDto = (UserDto) authentication.getPrincipal();
return userDto.getUsername();
}
}
인증 성공시 jwtService를 통해 토큰을 생성하고, 클라이언트에게 토큰을 전달하고, DB에 Refresh 토큰을 저장한다.
그리고 클라이언트에게 인증 성공을 알리는 메시지를 보낸다.
LoginFailureHandler
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //401 인증 실패
response.getWriter().write("fail");
log.info("로그인 실패");
}
}
인증 실패시 클라이언트에게 인증 실패를 알리는 401 코드와 메시지를 보낸다.
SecurityConfig + JWT
커스텀 필터와 핸들러를 적용하기 위한 Security 설정은 다음과 같다.
@Configuration
public class SecurityConfig {
@Autowired
private UserServiceImpl userServiceImpl;
@Autowired
private UserMapper userMapper;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private PasswordEncoderConfig passwordEncoder;
@Autowired
private JwtService jwtService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
// jwt 로그인
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
// 권한 설정
.authorizeHttpRequests((authorize) -> authorize
// 인증 불필요
.requestMatchers("/signup", "/", "/login")
.permitAll()
.anyRequest()
.hasRole("USER")
)
// custom filter
.addFilterAfter(jsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class)
.addFilterBefore(jwtAuthenticationProcessingFilter(), JsonUsernamePasswordAuthenticationFilter.class)
// logout
.logout((logout) -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login")
.addLogoutHandler(((request, response, authentication) -> {
// session invalidate
HttpSession session = request.getSession();
session.invalidate();
// destroy refresh token
String accessToken = jwtService.extractAccessToken(request);
String userId = jwtService.extractUserId(accessToken);
jwtService.destroyRefreshToken(userId);
}))
.invalidateHttpSession(true))
// stateless
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
// provider
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userServiceImpl);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder.passwordEncoder());
return daoAuthenticationProvider;
}
// manager
@Bean
public AuthenticationManager authenticationManager() {
DaoAuthenticationProvider provider = daoAuthenticationProvider();
return new ProviderManager(provider);
}
// handler
@Bean
public LoginSuccessJWTProvideHandler loginSuccessJWTProvideHandler() {
return new LoginSuccessJWTProvideHandler();
}
@Bean
public LoginFailureHandler loginFailureHandler() {
return new LoginFailureHandler();
}
// custom filter - json type request
@Bean
public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordAuthenticationFilter() {
JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter = new JsonUsernamePasswordAuthenticationFilter(objectMapper);
jsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
jsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessJWTProvideHandler());
jsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler());
return jsonUsernamePasswordLoginFilter;
}
// custom filter - token 발급
@Bean
public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() {
JwtAuthenticationProcessingFilter jsonUsernamePasswordLoginFilter = new JwtAuthenticationProcessingFilter(jwtService, userMapper);
return jsonUsernamePasswordLoginFilter;
}
}
'Spring' 카테고리의 다른 글
Mockito를 사용한 Spring Boot Unit Test (0) | 2024.09.24 |
---|---|
Spring Security 403 에러 해결 과정 (0) | 2024.09.22 |
Spring Security에 OAuth2 소셜 로그인 적용하기 (Google, Kakao) (1) | 2024.09.19 |
Spring Security 개념, 동작 방식, 인증 과정 (0) | 2024.09.13 |