[Spring] 보안에 대한 고민 : JWT 토큰?
공부용 게시글이라 영양가가 없습니다.
↑링크 추가
개요
그렇게 옛날은 아니지만 아무것도 모르던 비바 시절...
보안상에 아주 치명적인 코드를 잘만 짜고 다녔었다.
https://myunji.tistory.com/160?category=1154148
내가 주석으로 표시해놓은 주소를 보면 알겠지만 유저의 아이디가 get 방식으로 그대로 노출되어있다.
이럼 큰일난다. 왜? 유저가 저 주소 그대로 치고 뒤에 아이디만 대충 바꾸면 잘못 얻어걸린 어떤 유저의 데이터가 날아갈지도 모르기 때문이다.
뭐 예를 들어
localhost:3001/api/incor-note-content/0?note_sn=1&stu_id=samdol2
이런 주소를 입력했는데 samdol2가 정말 있는 유저였다면? 초등학생도 뚫어버릴 수준의 보안이 되는 것이다.
예전에 생활코딩에서 노드에 대해 배웠을 때 이 부분에 대해 지적했었던 것 같은데, 당시의 나는 아는 것도 없고 보안...신경써야 하나...하고 넘긴 것 같았다. 이제 그 업보를 제대로 맞기 전에 공부를 하자.
https://webfirewood.tistory.com/115
일단 이런 블로그를 찾았다.
완벽하게 이해하진 못했지만 대충 이해했다.
위 블로그를 조금씩 이해하며 코드를 짰는데 저건 access token 구현이고 refresh token이라는게 따로 있다고 한다.
대충 내가 검색한걸 정리하면...
1. 보안을 위해 access token의 유효기간은 짧아야 함.
2. 그럼 로그아웃이 잦아서 ux가 떨어짐
3. 유효기간이 긴 refresh token을 사용하면 로그인 유지를 더 오래 할 수 있음
인 것 같다. 그럼 refresh token도 구현해봐야겠다.
https://tansfil.tistory.com/59?category=255594
이 블로그의 설명이 좋은 것 같다!
열심히 구글링을 하던 중 좋은 블로그의 글을 발견했다.
뭔가...된 것 같다. 지금 정말 기능만 공부하려고 대충 구현했지만 위에가 access, 아래가 refresh token이다.
데베에도 refresh token이 저장됐다!
재발급도 잘 되는 것 같다!
그리고 이 이후에
이런 글을 봤는데, 난 정말 알 수가 없었다.
도대체 저 @AuthenticationPrincipal이 보내는게 뭔 줄 알고 토큰을 통해 사용자를 찾는단 말인가??
수많은 구글링과 삽질을 통해 아주 어이없는 해답을 알아냈다.
그냥 내가 지금까지 한거에서
@GetMapping("/resource")
public UserTest resource(@AuthenticationPrincipal UserTest userTest){
return userTest;
}
이렇게 하고
받아놨던 access token을 보내면 알아서 된다.
내 생각보다 스프링은 더 똑똑하고 난 더 멍청했다.
여기까지 하고 나니까 이게 궁금해졌다. access token 만료되면 refresh token이 잘 작동하나..?
private final long ACCESS_TOKEN_VALID_TIME = 1 * 60 * 1000L; //유효시간 1분
유효시간을 1분으로 바꿔버리고
access token 받고 기다렸다...1분동안
안되는거 보니 만료된걸까?
만료됐나보다! 이거 나중엔 exception 뱉는걸로 해야겠다...
재발급하면
다시 나온다!
공부한 내용을 대충 정리한 것. 추후에 따로 게시물로 정리할 예정
//JWT access token 생성
public String createAccessToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk); //JWT payload에 저장되는 정보단위
claims.put("roles", roles); //key - value 쌍으로 저장
Date now = new Date();
return Jwts.builder()
.setClaims(claims) //정보 저장
.setIssuedAt(now) //토큰 발행 시간
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME)) //토큰 만료 시간
.signWith(SignatureAlgorithm.HS256, secretKey) //사용할 암호화 알고리즘
.compact();
}
//JWT refresh token 생성
public String createRefreshToken(String value){
Claims claims = Jwts.claims();
claims.put("value", value);
Date now = new Date();
return Jwts.builder()
.setClaims(claims) //정보 저장
.setIssuedAt(now) //토큰 발행 시간
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_VALID_TIME)) //토큰 만료 시간
.signWith(SignatureAlgorithm.HS256, secretKey) //사용할 암호화 알고리즘
.compact();
}
이건 access token과 refresh token을 생성하는 메소드이다.
access token에선 setSubject로 pk를 저장하고, 음 둘 다 그렇게 만들어진 토큰에 발행 시간과, 만료 시간, 사용할 암호화 알고리즘 등을 더해서 만든다. 사실 아직 잘 모르겠다.
아무튼 알고리즘은 HS256을 사용했고, 비밀키는 application.yml에 잘 보관했다.
//JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
//토큰에서 회원 정보 얻어내기
private String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
//Request의 Header에서 token 값 가져오기. "X-AUTH-TOKEN" : "TOKEN값"
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
//토큰의 유효성과 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date()); //만료일자
} catch (Exception e) { //유효성
return false;
}
}
//refresh token 정보 얻어내기
public Claims getClaimsFromJwtToken(String jwtToken) throws JwtException {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken).getBody();
}
이건 토큰을 검증하는 메소드들이다. 이것들을 어디서 사용하냐면
package jpa.myunjuk.common;
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 token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
//유효한 토큰인지 확인
if (token != null && jwtTokenProvider.validateToken(token)) {
//토큰이 유효하면 토큰으로부터 유저 정보 반환
Authentication authentication = jwtTokenProvider.getAuthentication(token);
//SecurityContext에 Authentication 객체를 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
여기서 사용한다.
먼저 resolveToken 메소드로 X-AUTO-TOKEN을 key로 하는 헤더를 받아와 String token에 저장하고
널 체크와 validateToken 메소드로 토큰의 유효성을 확인한다.
만약 그렇다면 getAuthentication 메소드로 토큰에 있는 유저 정보를 반환하고, 그 반환한 객체를 SecurityContext에 저장한다.
이것들을
package jpa.myunjuk.common;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
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.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
//암호화에 필요한 PasswordEncoder를 Bean 등록
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
//authenticationManager를 Bean 등록
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception{
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.httpBasic().disable() //rest api만 고려하면 해제해도 되는듯?
.csrf().disable() //csrf 보안 토큰 diable 처리
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //토큰 기반 인증이라 세션은 사용하지 않음
.and()
.authorizeRequests() //요청에 대한 권한 체크
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.antMatchers("/**").permitAll() //그외 나머지 요청은 누구나 접근 가능
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class); //JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣음
}
}
여기에 등록해야 쓸 수 있나보다.
한편 spring security에서 유저정보를 저장하기 위해선 UserDetails라는 인터페이스를 구현해야 한다.
package jpa.myunjuk.domain;
import lombok.*;
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;
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserTest implements UserDetails {
@Id
@GeneratedValue
private Long id;
@Column(length = 100, nullable = false, unique = true)
private String email;
@Column(length = 300, nullable = false)
private String password;
@Setter
private String refreshTokenValue;
@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;
}
}
그래서 user domain이 이렇게 되고...
package jpa.myunjuk.service;
import jpa.myunjuk.repository.UserTestRepository;
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;
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final UserTestRepository userTestRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userTestRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
}
}
UserDetailsService의 메소드를 구현해서 DB에서 사용자를 찾도록 한다.
컨트롤러와 서비스 부분은...나중에 보충 게시물에서 따로 쓰도록 하겠다.