뛰슈 - JWT
세션과 JWT의 차이는?
세션기반의 인가 방식은 사용자의 정보가 서버의 세션 저장소에 저장되는 방식이고, SessionID는 브라우저에 쿠키 형태로 저장되지만, 실제 인증 정보는 서버에 저장된 것이다.
JWT는 토큰기반 인증으로 JSON Web Token이다. 이 방식은 클라이언트가 직접 들고 있는 방식이다.
인증 정보가 토큰의 형태로 브라우저의 로컬 스토리지에 저장된다.
세션의 경우는 Cookie헤더에 세션 ID만 보내서 트래픽이 적지만, JWT의 경우는 인증정보와 토큰 발급시각, 만료시각, 토큰의 ID등이 담겨 있어서 세션보다 많은 트래픽을 사용한다.
보안성 측면에서는 세션과 토큰의 탈취시 토큰이 더 위험하다. 세션의 경우 서버에서 무효처리를 하면되지만, 토큰은 클라이언트가 모든 인증정보를 가지고 있기 때문에 토큰 만료 전까지 피해를 입을 수 밖에 없다.
하지만 이런 문제가 있음에도 토큰을 사용하는 이유는 확장성 때문이라고 한다.
확장성이 있는 토큰을 사용하는 이유
웹 어플리케이션의 서버 확장 방식은 수평 확장을 사용한다. 이때 세션 기반의 인증 방식은 세션 불일치 문제를 겪게 된다.
이를 해결하기 위해서는 Sticky Session, Session Clustering, 세션 스토리지 외부 분리 등의 작업을 해야한다고 한다.
그에 반해 서버에 인증 방식을 저장하지 않는 토큰은 세션 불일치 문제에서 자유롭다. 이로인해 HTTP의 stateless를 그대로
활용할 수 있고, 높은 확장성을 가진다고 한다.
그래서 프로젝트에 JWT를 반영해보았다.
JWT의 구성
Header: signature를 해싱하기 위한 알고리즘 정보를 담는다.
Payload: 서버와 클라이언트가 주고받는, 시스템에서 실제로 사용될 정보에 대한 내용
Signature: 토큰의 유효성 검증 문자열
JWT, JWS, JWE, JWK?
JWS(JSON Web Signature) 서버에서 인증을 증거로 인증 정보를 서버의 private key로 서명한 token
JWE(JSON Web Encryption) 서버와 클라이언트 간 암호화된 데이터 token
JWK(JSON Web Key) JWE에서 payload encryption에 쓰인 키 token
JWT(JSON Web Token) JWS or JWE
장점
Base64 URL Safe Encoding > URL, Cookie, Header 모두 사용 가능
단점
Payload의 정보가 많아지면 네트워크 사용량 증가, 데이터 설계 고려 필요
TokenProvider
먼저 initializingBean을 implements 해서 afterPropertiesSet을 Override한 Token Provider를 구현한다.
이렇게 한 이유는 빈이 생성되어 주입 받은 후에 secret값을 Base64 Decode해서 Key 변수에 할당하기 위해서이다.
@Slf4j
@Component
public class TokenProvider implements InitializingBean {
private static final String AUTHORITIES_KEY = "auth";
private final String secret;
private final long tokenValidityMilliseconds;
private Key key;
public TokenProvider(@Value("${jwt.secret}") String secret, @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
this.secret = secret;
this.tokenValidityMilliseconds = tokenValidityInSeconds * 1000;
}
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String createToken(Authentication authentication){
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityMilliseconds);//만료 시간 설정
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY,authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
public Authentication getAuthentication(String token){
Claims claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());//권한 정보
User principal = new User(claims.getSubject(),"",authorities);//유저 객체
return new UsernamePasswordAuthenticationToken(principal,token,authorities);
}
public boolean validateToken(String token){
try{
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
}catch (io.jsonwebtoken.security.SecurityException | MalformedParametersException e){
log.info("잘못된 JWT 서명입니다.");
}catch (ExpiredJwtException e){
log.info("만료된 JWT 토큰입니다.");
}catch (UnsupportedJwtException e){
log.info("지원되지 않는 JWT 토큰입니다.");
}catch (IllegalArgumentException e){
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
createToken
Authentication 인증 객체로, 인증이 완료된 사용자 정보를 담고 있다.
여기서 getAuthorities()를 통해서 사용자의 권한 정보를 가져온다. 사용자가 가진 모든 권한을 나타내는 컬렉션이다.
map(GrantedAuthority::getAuthority)로 사용자의 권한 정보에서 권한 이름만 추출하여 새로운 컬렉션을 생성
GrantedAuthority는 권한 정보를 나타내는 인터페이스이다.
이렇게 얻은 권한 이름을 쉼표로 구분하여 문자열로 합친다음 Claim으로 권한 정보를 포함한다.
그리고 현재시간에 토큰 유효기간을 더해서 만료 시간을 계산한다.
이렇게 계산한 값과 인증정보를 가지고 토큰을 발급한다.
JWT의 주제(Subject)를 사용자 이름으로 설정하고, Claim에 AUTHORITIES_KEY로 정의한 키에 권한 정보를 설정한다.
그리고 JWT를 서명하는데 사용할 암호화 키와 알고리즘을 설정한다.
getAuthentication(String token)
JWT토큰을 디코딩하고, 토큰에 포함된 사용자 정보와 권한 정보를 기반으로 Authentication 객체를 생성하는 메서드이다.
이렇게 생성된 객체는 인증을 위해 사용된다.
먼저, 주어진 토큰을 파싱하여 그 안에 포함된 클레임 정보를 추출한다. parseClaimsJws()메서드를 사용하여 토큰의 시그니처를 검증하고, getBody로 클레임 정보를 얻는다.
토큰의 클레임에서 권한 정보를 추출하고, 쉼표로 구분된 권한 이름을 문자열 배열로 분할한다.
권한 이름을 SimpleGrantedAuthority 객체로 변환하여 컬렉션으로 만든 후 이를 이용하여 Spring Security에서 사용되는 사용자 객체User를 만든다.
이렇게 생성한 사용자와 인증된 토큰, 권한 정보를 포함한 UsernamePasswordAuthenticationToken을 반환한다.
validateToken(String token)
JWT 토큰의 유효성 검사 메서드이다.
주어진 토큰을 파싱하고, 서명을 검증한 후에 클레임 정보를 얻는다.
서명이 잘못되거나 잘못된 파라미터가 주어진 경우 SecurityException, MalformedParametersException 발생
토큰이 만료된 경우는 ExpiredJwtException 발생
지원되지 않는 토큰의 경우는 UnsupportedJwtException 발생
잘못된 형식의 토큰의 경우는 illegalArgumentException 발생
유효성 검증 결과를 true, false로 반환
JwtFilter
GenericFilterBean을 extends해서 doFilter를 Override해서 실제 필터 로직은 doFilter내부에 작성한다.
doFilter는 토큰의 인증정보를 SecurityContext에 저장하는 역할을 수행한다.
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {
public static final String AUTHORIZATION_HEADER = "Authorization";
private TokenProvider tokenProvider;
public JwtFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
if(StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)){
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);;
log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
}else {
log.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
}
chain.doFilter(request,response);
}
private String resolveToken(HttpServletRequest request){
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")){
return bearerToken.substring(7);
}
return null;
}
}
resolveToken(HttpServletRequest request)
HTTP요청 헤더에서 값을 가져온다. 가져온 헤더 값이 비어있지 않고, Bearer로 시작하는지 확인한다.
JWT토큰은 Bearer 이후 부분에 있음으로 이 부분을 제외한 토큰 부분만을 가져온다.
doFilter
이렇게 resolveToken에서 가져온 토큰을 가지고 유효성 검사를 진행하고, 사용자 인증 정보를 얻어온다.
SecrityContextHolder에 인증 정보를 설정한다. 그리고 다음 필터 또는 서블릿으로 요청을 전달한다.
이 메서드는 HTTP 요청을 가로채어 JWT 토큰을 검증하고, 인증 정보를 설정하는 역할을 한다.
이렇게 설정한 Filter와 Provider를 SecurityConfig에 적용하기 위해서 JwtSecurityConfig를 정의한다.
JwtSecurityConfig
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private TokenProvider tokenProvider;
public JwtSecurityConfig(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public void configure(HttpSecurity http) {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
JwtFilter를 UsernamePasswordAuthenticationFilter앞에 등록하여 JWT 필터가 먼저 수행되고, 기본 인증 필터가 실행되도록 한다.
이렇게 필터를 등록하고, 유효한 자격증명을 제공하지 않고 접근하려고 하는 경우 401Unauthorized에러를 리턴할 JwtAuthenticationEntryPoint를 정의하고, 필요한 권한이 존재하지 않는 경우에 403Forbidden에러를 리턴하는 JwtAccessDeniedHandler를 정의한다.
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
이렇게 정의한 예외들과 JwtConfig, TokenProvider를 SecurityConfig에 등록한다.
@EnableWebSecurity
@EnableMethodSecurity
@Configuration
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
public SecurityConfig(TokenProvider tokenProvider, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAccessDeniedHandler jwtAccessDeniedHandler) {
this.tokenProvider = tokenProvider;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// token을 사용하는 방식이기 때문에 csrf를 disable합니다.
.csrf(AbstractHttpConfigurer::disable)
.exceptionHandling(exceptionHandling -> exceptionHandling
.accessDeniedHandler(jwtAccessDeniedHandler)
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
)
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers(PathRequest.toH2Console()).permitAll()
.requestMatchers(new AntPathRequestMatcher("/login"), new AntPathRequestMatcher("/sign-up")).permitAll()
.antMatchers("/board").permitAll()
.antMatchers("/board/**").authenticated()
.anyRequest().authenticated()
)
// 세션을 사용하지 않기 때문에 STATELESS로 설정
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// enable h2-console
.headers(headers ->
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
)
.apply(new JwtSecurityConfig(tokenProvider));
return http.build();
}
}
이전에는 websecurityconfigureradapter를 사용하였지만 이제는 deprecated되었기 때문에 이를 SecurityFilterChain으로 바꾸어 주었다. 이렇게 필터와 provider를 등록하여서 JWT토큰을 이용한 로그인을 위한 토큰 발급이 가능하게 만든다.
그 다음에 로그인 관련 서비스를 구현한다.
@Slf4j
@Service
public class LoginService implements UserDetailsService {
private final MemberJpaRepository memberJpaRepository;
@Autowired
public LoginService(MemberJpaRepository memberJpaRepository) {
this.memberJpaRepository = memberJpaRepository;
}
@Override
public UserDetails loadUserByUsername(String memberName) throws UsernameNotFoundException {
return memberJpaRepository.findOneWithAuthoritiesByMemberId(memberName)
.map(member -> createMember(memberName,member))
.orElseThrow(()->new UsernameNotFoundException(memberName + " -> 데이터베이스에서 찾을 수 없습니다."));
}
private org.springframework.security.core.userdetails.User createMember(String memberName, Member member){
if (!member.isActivated()) {
throw new RuntimeException(memberName + " -> 활성화되어 있지 않습니다.");
}
List<GrantedAuthority> grantedAuthorities = member.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAuthority().getAuthorityName()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(member.getMemberId(),
member.getPassword(),
grantedAuthorities);
}
}
여기에 사용된 권한과 관련된 부분을 member에 추가하고, signup부분에도 추가한다.
Authority
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "authority")
public class Authority {
@Id
@Column(name = "authority_name", length = 50)
private String authorityName;
@OneToMany(mappedBy = "authority")
private Set<MemberAuthority> memberAuthorities = new HashSet<>();
}
AuthorityJpaRepository
@Repository
public interface AuthorityJpaRepository extends JpaRepository<Authority,String> {
}
Member
@Getter
@Builder
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
@NotEmpty
@Column(length = 30, name = "memberId")
private String memberId;
@Column(length = 10, name = "name")
private String name;
@Column(name = "password", length = 100)
private String password;
@Column(name = "activated")
private boolean activated;
@OneToMany(mappedBy = "member")
private Set<MemberAuthority> authorities = new HashSet<>();
MemberAuthority
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MemberAuthority {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "memberId")
private Member member;
@ManyToOne
@JoinColumn(name = "authority_name")
private Authority authority;
}
기존에 구현을 위해 참고한 강의는 권한을 ManyToMany로 구현하였다. 하지만 학습과정에서 ManyToMany를 사용하는 것을 권하지 않는다고 하여서 ManyToOne, OneToMany로 풀어서 구현하였다.
MemberAuthorityJpaRepository
@Repository
public interface MemberAuthorityJpaRepository extends JpaRepository<MemberAuthority,Long> {
}
TokenDto
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TokenDto {
private String token;
}
AuthorityDto
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthorityDto {
private String authorityName;
}
위와 같이 구현을 마쳤다.