2022.02.19 - [Backend/Spring] - Spring boot에서 JWT 구현(1)
이전 글
전 글에서 JWT에 대한 개념을 대충 정리를 했다면 이번에는 구현에 대해서 말해보겠습니다😃
JWT 로직
- 클라이언트에서 로그인을 합니다.
- 로그인 성공 시 Access token과 Refresh token을 만듭니다. 여기서 Access token은 만료시간을 30분으로 지정하고 Refresh token은 만료시간을 2주로 설정합니다.
- Refresh token은 Redis에 저장을 하고 클라이언트에게는 Access token을 보내줍니다.
- 로그인한 유저는 헤더에 Access token을 가지고 다니며 API 통신을 할 때 서버에서 만료된 토큰인지 체크를 합니다.
- 만료된 토큰일 경우 Access token이 만료되었음을 알려주고 클라이언트는 재발급 API를 요청합니다.
- 저장소에 저장된 Refresh Token이 만료가 되지 않았으면 Access token을 재발급을 하고 클라이언트에게 보내줍니다.
Refresh Token을 사용하는 이유 및 Redis를 사용하는 이유?
여기서 Refresh Token과 Redis를 사용하는 이유를 말해보겠습니다!
- 유저의 Access Token을 누군가가 탈취를 했을 때 악의적인 사용을 막기 위해 Access Token의 만료 시간을 짧게 설정합니다. 예로 30분으로 설정하게 되면 정작 토큰의 주인 유저가 사용을 할 때 30분마다 로그인을 해주어야 한다는 번거로운 점이 생기게 됩니다. 이를 위해 만료시간이 보다 긴 Refresh Token을 생성하고 저장소에 보관함으로써 Access Token이 만료되면 재발급을 하는 구조로 사용자의 번거로움을 없애줍니다.
- 이때 Access Token 만료로 재발급을 받는 API의 동작이 느려지면 당연히 안되겠죠? 때문에 Redis와 같은 Key-Value 형태의 가벼운 저장소를 사용함으로써 성능이 느려지지 않도록 합니다.
사실 제 코드는 발급된 Access Token을 localstorage에 저장을 하는 코드입니다🥲....
localstorage에 토큰을 저장하는 로직은 보안 측면에서 좋지 않아 권장하지 않습니다ㅠㅠ
구현을 하면서도 고민이었던 부분인데 나중에 이런 보안적인 측면까지 고려하여 글을 추가하도록 하겠습니다🥲🥲..
그럼 일단 Spring boot에서 코드를 어떻게 작성했는지 아래에서 확인해보겠습니다❗
설정값 구현
🛠 Mac OS, Spring boot 2.4.0, Java8을 사용하였습니다. 🛠
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- REDIS -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.6.0</version>
</dependency>
- Spring Security, Jwt, Redis와 관련된 dependency를 pom.xml에 추가하고 메이븐 업데이트를 진행합니다.
####################JWT######################
jwt.security.key=JWT
jwt.response.header=Authorization
jwt.token.prefix=Bearer
#################################################
#######################REDIS#####################
spring.redis.host=127.0.0.1
spring.redis.port=6379
#################################################
properties 파일에 다음과 같이 추가합니다.
- jwt secret key는 원래는 공개되어서는 안됩니다! 저는 혼자 만들어보는 코드이니 대충 JWT라고 적어보겠습니다.
- jwt header는 토큰을 발급받은 클라이언트가 API를 요청할 때 Header에 담아 보낼 이름입니다.
- prefix는 이전 글에 설명하였던 인증 타입과 관련하여 'Authorization: Bearer <token>'처럼 보내고 받기 위한 값입니다.
- Redis는 기본 설정 값 그대로 가져왔습니다.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
Redis 설정하기 위한 코드입니다.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Value("${jwt.response.header}")
private String jwtHeader;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.exposedHeaders(jwtHeader) // 'Authorization' 헤더 값을 받아온다
.allowedMethods(
HttpMethod.GET.name(),
HttpMethod.POST.name(),
HttpMethod.DELETE.name(),
HttpMethod.PUT.name(),
HttpMethod.OPTIONS.name()
)
.allowCredentials(true);
}
}
- 저는 RestAPI를 사용했기 때문에 Cors 설정이 필요했습니다.
- 참고하셔야 될 부분은 exposedHeaders 부분으로 API 요청 시 Header에 'Authorization' 이름의 값은 받아온다는 뜻입니다.
import com.blog.api.server.common.Role;
import com.blog.api.server.handler.CustomAccessDeniedHandler;
import com.blog.api.server.handler.CustomAuthenticationEntryPoint;
import com.blog.api.server.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.WebSecurityConfigurerAdapter;
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.authentication.UsernamePasswordAuthenticationFilter;
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserService userService;
@Autowired
private TokenProvider tokenProvider;
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable() // 기본 로그인 창 X
.cors()
.and()
.csrf().disable() // Restful API 방식 사용으로 disable 처리
.formLogin().disable() // 위와 같음
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반이므로 세션 사용 안함
.and()
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
.addFilterBefore(new JwtAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class); // 꼭 필요한 코드!
}
}
- addFilterBefore(new JwtAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)는 API 요청 시에 후자의 Filter보다 JwtAuthenticationFilter를 먼저 실행하라는 뜻입니다. 이 코드를 통해 모든 API 통신에 Access token이 존재하는지와 만료되었는지를 체크합니다.
그럼 저 tokenProvider를 구현하러 가봅시다 ❗
import com.blog.api.server.model.Member;
import com.blog.api.server.model.Token;
import com.blog.api.server.repository.RedisRepository;
import com.blog.api.server.utils.JwtUtil;
import io.jsonwebtoken.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
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 org.springframework.util.StringUtils;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.util.*;
@Component
public class TokenProvider {
@Value("${jwt.security.key}")
private String secretKey;
@Value("${jwt.response.header}")
private String jwtHeader;
@Value("${jwt.token.prefix}")
private String jwtTokenPrefix;
private long accessTokenValidTime = Duration.ofMinutes(30).toMillis(); // 만료시간 30분
private long refreshTokenValidTime = Duration.ofDays(14).toMillis(); // 만료시간 2주
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private RedisRepository redisRepository;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
public Token createAccessToken(Member member) {
return createToken(member, accessTokenValidTime);
}
public Token createRefreshToken(Member member) {
return createToken(member, refreshTokenValidTime);
}
public Token createToken(Member member, long tokenValidTime) {
Claims claims = Jwts.claims().setSubject(member.getEmail());
claims.put("roles", member.getRole());
Date now = new Date();
String token = Jwts.builder()
.setClaims(claims) // 정보
.setIssuedAt(now) // 토큰 발행 시간
.setExpiration(new Date(now.getTime() + tokenValidTime)) // 토큰 만료 시간
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
return Token.builder()
.key(member.getEmail())
.value(token)
.expiredTime(tokenValidTime)
.build();
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
HashMap<String, String> payloadMap = JwtUtil.getPayloadByToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(payloadMap.get("sub"));
return new UsernamePasswordAuthenticationToken(userDetails, token, userDetails.getAuthorities());
}
// 토큰으로 회원 정보 조회
public String getUserEmail(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// GET ACCESS TOKEN BY HEADER
public String resolveAccessToken(HttpServletRequest request) {
String bearerToken = request.getHeader(jwtHeader);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(jwtTokenPrefix)) {
return bearerToken.substring(7);
}
return null;
}
// 토큰의 유효 및 만료 확인
public boolean validateToken(String token, HttpServletRequest request) {
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return true;
} catch(SecurityException | MalformedJwtException e) {
log.error("Invalid JWT signature");
return false;
} catch(UnsupportedJwtException e) {
log.error("Unsupported JWT token");
return false;
} catch(IllegalArgumentException e) {
log.error("JWT token is invalid");
return false;
}
}
public void setHeaderAccessToken(HttpServletResponse response, String accessToken) {
response.setHeader(jwtHeader, accessToken);
}
}
토큰을 생성하고 만료되었는지 확인하고 header로부터 값을 가져오는 코드들이 저장되어 있습니다.
주석을 참고해주세요!
import com.blog.api.server.common.ErrorCode;
import com.blog.api.server.common.ResponseVO;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.ExpiredJwtException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
public JwtAuthenticationFilter(TokenProvider provider) {
tokenProvider = provider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
String path = request.getServletPath();
if(path.startsWith("/api/auth/reissue")) { // 토큰을 재발급하는 API 경우 토큰 체크 로직 건너뛰기
filterChain.doFilter(request, response);
} else {
String accessToken = tokenProvider.resolveAccessToken(request);
boolean isTokenValid = tokenProvider.validateToken(accessToken, request);
if (StringUtils.hasText(accessToken) && isTokenValid) {
this.setAuthentication(accessToken);
}
filterChain.doFilter(request, response);
}
} catch (ExpiredJwtException e) {
ResponseVO responseVO = ResponseVO.builder()
.status(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getStatus())
.message(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getMessage())
.code(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getCode())
.build();
response.setStatus(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getStatus().value());
response.getWriter().write(new ObjectMapper().writeValueAsString(responseVO));
response.getWriter().flush();
}
}
// SecurityContext에 Authentication 저장
private void setAuthentication(String token) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
모든 API가 실행되기 전에 위의 Filter 코드가 실행이 됩니다.
- Access token을 재발급하는 API에는 토큰을 체크하는 로직을 태우지 않습니다.
- 헤더로부터 가져온 Access token이 유효한지 체크를 하고 만일 유효하다면 SecurityContextHolder에 저장합니다.
- ExpiredJwtException은 체크하는 토큰이 만료가 된 경우의 Exception입니다. Filter에서 발생하는 Exception은 Custom 할 수가 없습니다🥲.. HttpServletResponse에 Access token 만료를 알리는 Custom 400 에러를 담아 보내줍니다.
로그인 구현
@PostMapping("/signin")
public ResponseEntity<ResponseVO> signin(@RequestBody Member memberDTO, HttpServletResponse response) {
MemberDTO member = userService.signin(memberDTO, response);
return ResponseEntity
.status(HttpStatus.OK)
.body(ResponseVO.builder()
.status(HttpStatus.OK)
.response(member)
.build());
}
@Autowired
TokenProvider tokenProvider;
@Autowired
RedisRepository redisRepository;
@Override
public MemberDTO signin(Member memberDTO, HttpServletResponse response) {
Optional<Member> member = userRepository.findById(memberDTO.getEmail());
member.orElseThrow(
() -> new CustomException(ErrorCode.NOT_FOUND_MEMBER)
);
// 보내온 비밀번호가 데이터베이스에 저장된 비밀번호와 일치하는지 확인합니다
if(!passwordEncoder.matches(memberDTO.getPassword(), member.get().getPassword())) {
throw new CustomException(ErrorCode.NOT_FOUND_MEMBER);
}
Token accessToken = tokenProvider.createAccessToken(member.get());
Token refreshToken = tokenProvider.createRefreshToken(member.get());
tokenProvider.setHeaderAccessToken(response, accessToken.getValue());
redisRepository.save(refreshToken);
return MemberDTO.builder()
.accessToken(accessToken.getValue())
.member(member.get())
.build();
}
- 존재하지 않는 이메일, 일치하지 않는 비밀번호의 경우 에러를 보내주고 로그인이 성공한 경우 Access token과 Refresh token을 발급합니다.
- Redis에 Refresh token 객체를 보내 저장을 하고 Access token과 로그인 한 유저의 정보를 담아 보내줍니다.
- 유저 정보에 패스워드가 담기는데 해당 부분은 수정해주시면 좋을 것 같습니다😅
import com.blog.api.server.model.Token;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface RedisRepository extends CrudRepository<Token, String> {
Optional<Token> findByKey(String key);
}
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;
import org.springframework.data.redis.core.index.Indexed;
@Getter
@Setter
@RedisHash("auth")
@NoArgsConstructor
public class Token {
@Id
@Indexed
private String key;
private String value;
@TimeToLive
private Long expiredTime;
@Builder
public Token(String key, String value, Long expiredTime) {
this.key = key;
this.value = value;
this.expiredTime = expiredTime;
}
}
- Redis Repository와 Token 객체입니다. 참고해주세요😀
테스트
로그인 API를 실행시키고 받은 return 값은 다음과 같습니다.
{
"status": "OK",
"message": null,
"response": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ5dWppbjEyMy5raW1AZ21haWwuY29tIiwicm9sZXMiOiJNQVNURVIiLCJpYXQiOjE2NDUzMzIxNTMsImV4cCI6MTY0NTMzMzk1M30.WLhWPGX8Uga6k8S2_I9qL2rChqGMPCWXfRuexvc0X6A",
"member": {
"email": "yujin123.kim@gmail.com",
"name": "ㅎㅎ",
"role": "MASTER",
"authorities": [
{
"authority": "MASTER"
}
],
"username": null,
"accountNonExpired": false,
"accountNonLocked": false,
"credentialsNonExpired": false,
"enabled": false
}
},
"code": null
}
- Redis에 Refresh Token이 제대로 저장되었는지 확인합니다
- value 값으로 access 토큰과는 또 다른 토큰 값이 저장되어 있는데 이 값이 Refresh 토큰입니다❗
참고
다음글
'Backend > Spring' 카테고리의 다른 글
Spring boot에서 권한 Scope 처리를 해보자! (AccessDecisionManager, AccessDecisionVoter) (0) | 2022.10.30 |
---|---|
Spring boot에서 JWT 구현(3) (2) | 2022.02.20 |
Spring boot에서 JWT 구현(1) (0) | 2022.02.19 |
Spring boot에서 첨부파일 업로드 (Apache Tika 파일 변조 체크) (0) | 2021.11.14 |
Spring boot에 lucy-xss-servlet-filter 적용하기 (0) | 2021.07.11 |