이전 포스트에서는 Session과 JWT이 어떻게 다른지, JWT가 무엇인지, 어떤 고려사항들이 있는지 알아보았었다. 여러가지 고려사항들에 대해 어떠한 고민을했고 어떻게 적용을 했는지 작성을 한다.
SpringSecurity와 JWT를 사용할 것이기 때문에 build.gradle의 dependency에 아래와 같이 추가 해준다.
dependencies {
...
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: '2.4.5'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
...
}
refreshToken을 저장하기 위해 redis를 사용한다.
redis는 캐싱 등에 자주 사용되는 key-value 인메모리 방식의 데이터베이스다. 굉장히 빠른 성능을 가지고, 만료 기한을 지정할 수 있어 만료 시점이 된다면 자동으로 데이터가 삭제된다. 이러한 특징들은 refreshToken을 사용하는 JWT 방식과 찰떡 궁합이다.
아래와 같이 dependency를 추가 해준다.
dependencies {
...
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-redis', version: '2.4.10'
...
}
추가적으로 Redis도 설치해야한다.
아래 페이지에서 쉽게 설치 할 수 있다.
스프링에서 redis를 사용할 수 있도록 설정파일을 작성해 준다.
아래와 같이 스프링 설정파일(application.yml)에 host와 port를 추가해준다.
spring:
redis:
host: localhost
port: 6379
RedisRepositoryConfig.class 파일을 아래와 같이 작성해준다.
@Configuration
@EnableRedisRepositories
public class RedisRepositoryConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
자세한 내용 설명은 지금 당장은 중요한 것이 아닌것 같아 생략하기로한다.
UserDetail을 상속받은 LoginInfo.class를 작성한다.
Member Entity로부터 SpringSecurity에 사용할 인증 정보를 담을 DTO라고 보면된다.
인증을 받지않은 사용자는 기본 생성자로 아래와 같이 설정하고 아래와 같이 오버라이딩해준다.
password 부분은 이미 암호화되어 db에 들어가겠지만 당장 password를 불러와 쓸 일은 없을 것같고 숨겨서 안좋을건 없을것 같아 LoginInfo에 담지 않도록 수정했다.
@Getter
@ToString
public class LoginInfo implements UserDetails {
private Long number;
private String id;
private String name;
private String password;
private Collection<GrantedAuthority> roles;
public LoginInfo() {
this.number = -1L;
this.name = "anonymous";
this.password = "secret";
}
public LoginInfo(Member member) {
this.number = member.getNumber();
this.id = member.getId();
this.name = member.getName();
//this.password = member.getPwd();
roles = new ArrayList<GrantedAuthority>();
roles.add(new SimpleGrantedAuthority(member.getMemberType().name()));
}
public boolean isLoggedIn() {
return (number != -1 && name != "anonymous");
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return name;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
UserDetailServiceImpl.class를 작성한다. Member의 특정 키 값을 통해 LoginInfo로 변환해 불러올 수 있다.
@Service
@RequiredArgsConstructor
public class UserDetailServiceImpl implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return new LoginInfo(memberRepository.selectMemberById(username));
}
}
JWT 관련된 작업들을 편리하게 수행해 줄 함수들을 작성한다.
아까 dependency에 추가했던 SpringSecurity와 jsonwebtoken이 편리하게 사용할 수 있도록 제공해주므로 가져다가 잘 쓰기만하면 된다.
토큰 만료시간은 적절히 잘 설정해서 쓰면된다. 지금 코드에서는 테스트를 위해 짧게 설정되었지만 accessToken은 30분 또는 1시간, refreshToken은 1주일 또는 2주일 이런식으로 acessToken은 짧게, refreshToken은 비교적 길게 설정하면 된다.
@Component
public class JwtTokenProvider {
@Value("spring.jwt.secret")
private String secretKey;
public final static long TOKEN_VALIDATION_SECOND = 1000L * 60;
// accessToken의 만료 시간 설정
public final static long REFRESH_TOKEN_VALIDATION_SECOND = 1000L * 120;
// refreshToken의 만료시간 설정
final static public String ACCESS_TOKEN_NAME = "accessToken";
final static public String REFRESH_TOKEN_NAME = "refreshToken";
private final UserDetailsService userDetailsService;
private final RedisService redisService;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
public ValidRefreshTokenResponse validateRefreshToken(String accessToken, String refreshToken)
{
List<Object> findInfo = redisService.getListValue(refreshToken);
String userPk = getUserPk(accessToken);
if (findInfo.size() < 2) {
return new ValidRefreshTokenResponse(null, 401, null);
}
if (userPk.equals(findInfo.get(0)) && validateToken(refreshToken))
{
UserDetails findMember = userDetailsService.loadUserByUsername((String)findInfo.get(0));
List<String> roles = findMember.getAuthorities().stream().map(authority -> authority.getAuthority()).collect(Collectors.toList());
String newAccessToken = createAccessToken((String)findInfo.get(0), roles);
return new ValidRefreshTokenResponse((String)findInfo.get(0), 200, newAccessToken);
}
return new ValidRefreshTokenResponse(null, 403, null);
}
// Jwt 토큰 생성
public String createAccessToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk);
claims.put("roles", roles);
Date now = new Date();
String accessToken = Jwts.builder()
.setClaims(claims) // 데이터
.setIssuedAt(now) // 토큰 발행일자
.setExpiration(new Date(now.getTime() + TOKEN_VALIDATION_SECOND)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘, secret값 세팅
.compact();
return accessToken;
}
public String createRefreshToken() {
Date now = new Date();
String accessToken = Jwts.builder()
.setIssuedAt(now) // 토큰 발행일자
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_VALIDATION_SECOND)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘, secret값 세팅
.compact();
return accessToken;
}
// Jwt 토큰으로 인증 정보를 조회
public Authentication getAuthentication(String token) {
LoginInfo userDetails = ((LoginInfo)userDetailsService.loadUserByUsername(this.getUserPk(token)));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// Jwt 토큰에서 회원 구별 정보 추출
public String getUserPk(String token) {
try
{
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
catch (ExpiredJwtException e)
{
//e.printStackTrace();
return "Expired";
}
catch (JwtException e)
{
//e.printStackTrace();
return "Invalid";
}
}
public Cookie getCookie(HttpServletRequest req, String cookieName)
{
Cookie[] cookies = req.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals(cookieName))
return cookie;
}
return null;
}
// Request의 Header에서 token 파싱
public String resolveToken(HttpServletRequest req, String headerName) {
return req.getHeader(headerName);
}
// Jwt 토큰의 유효성 + 만료일자 확인
public boolean validateToken(String jwtToken) {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
}
public Long remainExpiration(String token)
{
try {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getExpiration().getTime() - new Date().getTime();
}
catch (ExpiredJwtException e) {
return -1L;
}
}
public Boolean isLoggedOut(String accessToken)
{
if (accessToken == null)
return false;
return redisService.getStringValue(accessToken) != null;
}
}
토큰을 암호화 할 때 사용하는 비밀키로 사용자가 적절히 설정해서 사용하면 된다.
이 key가 노출이 되면 절대 안되므로 비밀스럽게 잘 보관한다.
refreshToken의 유효성을 검증하며 새로운 accessToken을 발급하는 함수
redis에 refreshToken을 key로 찾은 데이터(userPK)가 없으면 발급 하지 못한다.
그리고 여기서 accessToken에 들어있던 userPk 정보와 redis에서 찾은 userPK와 비교를 해 검증을 한다. 다르다면 위변조가 되었다고 간주하면 된다.
public ValidRefreshTokenResponse validateRefreshToken(String accessToken, String refreshToken)
{
List<Object> findInfo = redisService.getListValue(refreshToken);
String userPk = getUserPk(accessToken);
if (findInfo.size() < 2) {
return new ValidRefreshTokenResponse(null, 401, null);
}
if (userPk.equals(findInfo.get(0)) && validateToken(refreshToken))
{
UserDetails findMember = userDetailsService.loadUserByUsername((String)findInfo.get(0));
List<String> roles = findMember.getAuthorities().stream().map(authority -> authority.getAuthority()).collect(Collectors.toList());
String newAccessToken = createAccessToken((String)findInfo.get(0), roles);
return new ValidRefreshTokenResponse((String)findInfo.get(0), 200, newAccessToken);
}
return new ValidRefreshTokenResponse(null, 403, null);
}
claims에는 토큰의 payload에 들어갈 데이터를 넣어준다.
accessToken은 유저의 PK와 세부 권한인 roles가 들어간다.
public String createAccessToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk);
claims.put("roles", roles);
Date now = new Date();
String accessToken = Jwts.builder()
.setClaims(claims) // 데이터
.setIssuedAt(now) // 토큰 발행일자
.setExpiration(new Date(now.getTime() + TOKEN_VALIDATION_SECOND)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘, secret값 세팅
.compact();
return accessToken;
}
refreshToken은 단지 accessToken을 재발급 받기 위해 사용하고 refreshToken이 누구의 것인지는 서버의 저장소인 redis에 담겨져 그것과 비교하여 유효성을 검증할 것이기 때문에 userPK같은 claims가 들어가면 안된다.
public String createRefreshToken() {
Date now = new Date();
String refreshToken = Jwts.builder()
.setIssuedAt(now) // 토큰 발행일자
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_VALIDATION_SECOND)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘, secret값 세팅
.compact();
return refreshToken;
}
로그아웃이 되었는지 되지 않았는지 확인하는 함수
로그아웃을 하면 redis에 accessToken을 추가해 로그아웃되어 더 이상 사용할 수 없는 토큰인지 아닌지 구별해내기 때문에 redis에 존재하면 로그아웃 된 사용자로 간주한다.
public Boolean isLoggedOut(String accessToken)
{
if (accessToken == null)
return false;
return redisService.getStringValue(accessToken) != null;
}
스프링 시큐리티를 사용하기 위해 설정을 해준다.
Rest API 기반 프로젝트이기 때문에 Rest API 서버가 아니라면 설정이 살짝 다를 것이다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/member", "/member/login_jwt/**", "/member/login/**", "/member/auth").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
}
}
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private JwtTokenProvider jwtTokenProvider;
// Jwt Provider 주입
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
// Request로 들어오는 Jwt Token의 유효성을 검증(jwtTokenProvider.validateToken)하는 filter를 filterChain에 등록
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
String accessToken = null;
Cookie[] cookies = ((HttpServletRequest) request).getCookies();
if (cookies != null)
accessToken = jwtTokenProvider.getCookie((HttpServletRequest) request, JwtTokenProvider.ACCESS_TOKEN_NAME).getValue();
if (!jwtTokenProvider.isLoggedOut(accessToken)) {
try {
if (accessToken != null && jwtTokenProvider.validateToken(accessToken)) {
Authentication auth = jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (ExpiredJwtException e) {
//재발급
}
}
filterChain.doFilter(request, response);
}
}
필터는 GenericFilterBean을 상속받고 doFilter를 오버라이딩한다.
토큰을 쿠키에 저장하기때문에 accessToken을 쿠키에서 가져오고 해당 accessToken이 로그아웃된 accessToken인지 먼저 확인한다.
로그인되지 않았다면 토큰의 유효성을 검증하고 유효하다면 권한을 준다.
여기서 setAuthentication으로 설정된 인증 정보는
컨트롤러단에서 @AuthenticationPrincipal 어노테이션으로 아래 예시와 같이 사용할 수 있다.
@GetMapping("/auth")
public LoginInfo get(@AuthenticationPrincipal LoginInfo loginInfo) {
return loginInfo;
}
토큰 재발급 응답 정보가 담길 DTO를 작성 해준다.
@Getter
@ToString
public class ValidRefreshTokenResponse {
private String userPk;
private int status;
private String accessToken;
public ValidRefreshTokenResponse(String userPk, int status, String accessToken) {
this.userPk = userPk;
this.status = status;
this.accessToken = accessToken;
}
}
로그인 시 발급받은 Token 정보를 담을 DTO를 작성해 준다.
@Data
@ToString
@NoArgsConstructor
public class TokenInfo {
private String result;
private String message;
private String accessToken;
private String refreshToken;
public TokenInfo(String result, String message, String accessToken, String refreshToken) {
this.result = result;
this.message = message;
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
입력 정보와 유저 정보가 일치하면 토큰을 발급하고 반환한다.
@Override
@Transactional(readOnly = true)
public TokenInfo login_jwt(LoginRequest loginRequest) {
Member member = memberRepository.selectMemberById(loginRequest.getId());
if (member != null && member.getId().equals(loginRequest.getId()) && passwordEncoder.matches(loginRequest.getPwd(), member.getPwd())) {
List<String> roleList = Arrays.asList(member.getMemberType().name());
String accessToken = jwtTokenProvider.createAccessToken(member.getId(), roleList);
String refreshToken = jwtTokenProvider.createRefreshToken();
return new TokenInfo("success", "create token success", accessToken, refreshToken);
} else
return new TokenInfo("fail", "create token fail", null, null);
}
로그인이 성공하면 아래와 같이 쿠키를 생성해주고
redis에 refreshToken을 Key로, userPk를 넣어준다.
코드에는 accessToken도 같이 들어가 있는데 이건 없어도 크게 상관 없을것 같다.
쿠키에는 이전 포스팅에도 말했었던 Secure, HttpOnly같은 옵션들이 있고 그 외에도 MaxAge같은 만료시간을 지정하는 옵션도 있다.
현재는 테스트의 편의를 위해 주석처리를 해놨다.
@PostMapping("/login_jwt/{id}")
public ResponseEntity<TokenInfo> login_jwt(@RequestBody LoginRequest loginRequest, HttpServletResponse response) {
TokenInfo loginResponse = memberService.login_jwt(loginRequest);
if (loginResponse.getResult().equals("fail")) {
return new ResponseEntity<TokenInfo>(HttpStatus.CONFLICT);
} else {
ArrayList<String> data = new ArrayList<>();
data.add(loginRequest.getId());
data.add(loginResponse.getAccessToken());
Cookie accessTokenCookie = new Cookie(JwtTokenProvider.ACCESS_TOKEN_NAME, loginResponse.getAccessToken());
Cookie refreshTokenCookie = new Cookie(JwtTokenProvider.REFRESH_TOKEN_NAME, loginResponse.getRefreshToken());
// accessTokenCookie.setMaxAge((int) JwtTokenProvider.TOKEN_VALIDATION_SECOND);
// accessTokenCookie.setSecure(true);
// accessTokenCookie.setHttpOnly(true);
// refreshTokenCookie.setMaxAge((int) JwtTokenProvider.REFRESH_TOKEN_VALIDATION_SECOND);
// refreshTokenCookie.setSecure(true);
// refreshTokenCookie.setHttpOnly(true);
response.addCookie(accessTokenCookie);
response.addCookie(refreshTokenCookie);
redisService.setStringValue(loginResponse.getRefreshToken(), data, JwtTokenProvider.REFRESH_TOKEN_VALIDATION_SECOND);
return new ResponseEntity<TokenInfo>(loginResponse, HttpStatus.OK);
}
}
@CookieValue 어노테이션으로 request로 부터 쿠키를 불러올 수 있다.
accessToken과 refreshToken을 받아 validateRefreshToken으로 재발급을 해주어 쿠키에 추가해준다.
@PostMapping("/refresh")
public ResponseEntity refresh(HttpServletResponse response,
@CookieValue(value = "accessToken") String accessToken
, @CookieValue(value = "refreshToken") String refreshToken) {
if (accessToken == null || refreshToken == null)
return new ResponseEntity<>(HttpStatus.UNAUTHORIZED);
ValidRefreshTokenResponse result = jwtTokenProvider.validateRefreshToken(accessToken, refreshToken);
if (result.getStatus() == 200) {
response.addCookie((new Cookie("accessToken", result.getAccessToken())));
return new ResponseEntity(result, HttpStatus.OK);
}
return new ResponseEntity(HttpStatus.BAD_REQUEST);
}
accessToken과 refreshToken을 쿠키에서 가져와서
accessToken은 더이상 사용할 수 없는 토큰으로 만들기 위해 redis에 블랙리스트로 등록한다. 만료 시간은 accessToken의 남은 만료시간과 똑같이 설정해주면 된다.
그리고 refreshToken은 redis로 부터 지워준다.
@PostMapping("/logout_jwt")
public ResponseEntity<TokenInfo> logout_jwt(@AuthenticationPrincipal LoginInfo principal,
@CookieValue(value = "accessToken") String accessToken
, @CookieValue(value = "refreshToken") String refreshToken
) {
if (accessToken == null || !jwtTokenProvider.validateToken(accessToken) || refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
Long remainExpiration = jwtTokenProvider.remainExpiration(accessToken);
if (remainExpiration >= 1) {
redisService.del(refreshToken);
redisService.setStringValue(accessToken, "true", remainExpiration);
return new ResponseEntity(HttpStatus.OK);
}
return new ResponseEntity(HttpStatus.UNAUTHORIZED);
}
구글링을 통해 이 글 저 글 다 읽어보면서 도대체 refreshToken을 통해서 accessToken을 어떻게 재발급 받으라는거야? 왜 재발급 API를 또 따로 만들어 주는거야? 했는데 결론은 프론트 엔드에서 처리해야한다 이다.
프론트에서 인증이 필요한 요청마다 사전에 /refresh 로 요청을 보내 accessToken을 재발급 받고 이 후에 원래 하려던 요청을 하는것이다.
여기서 또 추가적으로 고려해야할 사항은 accessToken의 만료시간이 1초남아서 /refresh 에서는 재발급 받을 필요가 없다고 간주하여 재발급을 해주지 않았고, 그 뒤에 1초가 지나서 원래 하려던 요청을 하게 된다면 재발급을 받지 못해 인증에 실패하는 경우가 발생할 수 있다.
이 경우에는 재발급을 받을때 accessToken이 완전히 만료되었는지 확인하는 것이아닌. 10초라던지 적게 남았을 때부터 재발급을 받을 수 있도록 처리하면 해결할 수 있는 부분인 것같다.
아까 WebSecurityConfig.class 에서 REST API는 csrf로부터 안전하기 때문에 disable해도 상관없고한다 라고 써놨지만 그것은 나도 검색해서 찾은 설명이고.. REST API는 왜 CSRF로 부터 안전한것인가? 이전 포스팅에서 cookie는 CSRF에 취약하다고 그렇게 말했는데 그건 또 뭐야? 말이 안되잖아? 라는 생각이 들었다.
이런저런 검색, 고수들이 존재하는 카카오톡 오픈채팅에서 물음표 살인마급 질문과 지극히 개인적인 추측 등으로 결론을 내렸는데, 결국은 csrf 설정을 해줘야한다로 결론 내렸다.
Should cookies be used in a RESTful API?
위 글을 보면 외쿡인들이 REST API는 무상태성이니 뭐니하면서 쿠키를 사용하면 안된다라고 하는 것같은데 (사실 영어를 잘 못해서 잘 모르겠음) 이런 글 들을 보니까
'아! REST API는 Cookie를 원래 사용하지 않아야 되고 그것을 전제로 CSRF로 부터 안전하니까 disable해도 상관없다고 한건가?' 라는 생각이 들었다.
어찌됐든 나는 Cookie를 사용하기로 했고, CSRF에 취약한건 맞고, 그것을 보완할 몇가지 보안전략이 있고, Cookie를 사용하지 않고 다른 것을 사용해도 Trade-off가 존재할 것이라고 생각했기 때문에 그냥 Cookie를 사용하고 CSRF 보안 설정을 해주자 라고 결론을 내렸다!
플젝을 다 만든것도 아니고 만들다가 중간에 쓴거라 코드 정리도 이쁘게 안되어있고해서 많이 좀 지저분한 것 같다.
그리고 글을 쓰다보니 글을 이정도로 길게 써본적도 없고 하다보니까 어떻게 써야 이해하기 쉬울까? 했는데 역시 글 쓰기에는 재능이 없는 것같다.
복습하는 의미에서 글을 썼는데 빠진부분도 많고 실수한 부분도 많을것 같긴하지만 나처럼 이리저리 헤매면서 시간을 허비하고 있을 사람들을에게 이정표 역할이되어 조금이나마 도움이 되었으면 좋겠다.