옛날에는 로그인/회원가입 관련 기능 개발 시 세션 방식을 많이 사용했다고 한다. 그런데 이제는 이런저런 이유로 JWT 토큰이라는 것을 사용한다고 하는데.. JWT 토큰과 Spring Security 개념이 섞이니까 쉽게 설명한다는 블로그들도 어렵.. 기에 일단 가장 아주 간단한 기능부터 해보고자 한다.
240310 추가@AuthenticationPrincipal을 사용하여 게시물 조회 시 클라이언트에서 주는 토큰으로 사용자 이름 뽑아내기
(240310 추가)
JWT는 아래와 같이 구성되어있다.1. Header
- 어떤 알고리즘으로 디지털 서명을 했는지를 표시
{ "alg":"HS256", "typ":"JWT" }2. Payload
- 어떤 유저인지 담긴 정보
{ "name":"Ricky Cho", "email":"zetta_byte@naver.com", "admin":True }3. Signature
- 우리 서버가 이 토큰을 만든 것이 맞는지 확인하는 용도
위 3개를
Base64라는 것으로 각각 인코딩 해서.으로 이으면 JWT가 완성되는 것이다. 다만2. Payload에는 비밀번호 같은 값을 담으면 안 된다. 왜냐하면2. Payload는Base64로 인코딩 되어있기 때문에, 반대로Base64로 디코딩하면 정보가 쉽게 보이기 때문이다.
그럼 이제3. Signature를 어떻게 사용하는지 알아보자. 우선 클라이언트가 보낸Payload와 서버만이 갖고 있는Secret Key를 가지고Header에 있는 알고리즘으로Signature를 만들어보자. 그 다음 클라이언트가 보내준Signature과 비교했을 때 일치하면 이는 서버가 만든 토큰이 맞는 것이다. (보안 통과) 왜냐면Secret Key는 서버 혼자서만 갖고 있기 때문.
이렇게 만든 JWT 토큰을 가지고 다양한 곳에서 쓸 수 있는데, 여기처럼 로그인에서도 쓸 수 있는 것이다. 로그인을 구현하는 방법에는 세션을 이용하는 방법과, JWT를 이용하는 방법이 있다. 서버가 여러 대인 환경에서(로드밸런싱) 세션을 이용하게 되면, 각 서버의 세션이 저장되어 있는 세션DB 같은 것을 새로 만들어야 하고, 이는 DB가 터지게 할 수 있으며 매 요청마다 DB에 접근해야한다는 단점이 있다. 이를 보완하기 위해 나온 것이 JWT이며, JWT의
Payload에는 유저 정보도 있기 때문에 굳이 DB를 또 갔다 올 필요가 없는 것이다.
여기까지가 배경 지식이고, 이제 실제로 이용해보면 된다. 하지만 공부를 하니 조금 헷갈리는 것은, JWT의 장점이 DB를 갔다오지 않는 것이라면, 내가 구현한 것은 틀리게 되어버린다.. 난
Payload에다가는 유저의Payload에 넣고 따로 DB에 갔다올 필요가 없게 하는것인가!?!?
그러나 잠깐 더 생각해보니, 서버가 여러 대인 경우 세션을 쓰면 세션 테이블
(SessionID, userID)에 한 번 갔다오고, 이userID로 유저 테이블에 가서 유저 정보를 조회해 와야한다. 그치만 내가 구현한 JWT 방식으로 해도 세션테이블에 갔다오는 과정이 생략되니까(그냥 서버에서 JWT인증 확인하면 되니까), DB에 갔다오는 횟수는 줄였다고 말할 수 있는건가..?!
application.properties 설정Dependency는 Spring Security 추가하고,
application.properties는 아래처럼 설정. (포트설정은 굳이 안해도 된다.)jwt.secret.key=namhyunisthebestbutwhythisworlddonthelpmeiamsofrustrated server.port=8081
또한 기본 클래스(
@SpringBootApplication붙어있는 클래스)에 다음과 같이 annotation을 수정해주자. 이거 안하면 Spring Security에서 제공하는 로그인 페이지로 이동해 우리 실험이 어려워진다;; 이걸 모르고 처음에 그냥 postman으로 냅다 쐈는데 반응없어서 당황 후 웹페이지에localhost:8080을 쳐서 알았다..@SpringBootApplication(exclude = SecurityAutoConfiguration.class)
최소한의 메소드만 만들자. 회원가입은 필요없고, 로그인 시에도 인증하는 과정(DB에서 ID/PW 체크)는 생략하자.
JwtTokenProvider는 JWT 토큰을 만들어주고, 검증하는 클래스이다. 만들 때는 만든 시간도 함께 넣어 일정 시간 뒤 유효하지 않은 토큰으로 만들 수 있도록 하자.
package com.example.securitytest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Set;
@RequiredArgsConstructor
@RestController
@Slf4j
public class UserController {
private final UserService userService;
private final JwtTokenProvider jwtTokenProvider;
@PostMapping("/login")
public JwtTokenResponse login(@RequestBody LoginUserRequest request, HttpServletResponse response){
log.info("controller login 진입");
User user = userService.login(request);
// jwtTokenProvider.setRefreshTokenForClient(response, user);
return jwtTokenProvider.makeJwtTokenResponse(user);
}
@GetMapping("/test")
public String test(User user){
log.info("test완료 : {}",user);
return "test완료";
}
}
package com.example.securitytest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.sql.Date;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Optional;
import java.util.Set;
@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {
public User login(LoginUserRequest request){
log.info("login 진입 {}",request);
User user = User.builder()
.email(request.email())
.password(request.password())
.build();
log.info("login 완료 {}",user);
return user;
}
}
package com.example.securitytest;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
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 java.security.Key;
import java.util.Date;
import java.util.Set;
enum JwtCode {
DENIED, ACCESS, EXPIRED
}
@Component
@Slf4j
public class JwtTokenProvider {
private final UserDetailsService userDetailsService;
private String secretKey;
public JwtTokenProvider(@Value("${jwt.secret.key}")String secretKey,
UserDetailsService userDetailsService){
this.secretKey = secretKey;
this.userDetailsService = userDetailsService;
}
public static long tokenValidTime = 3 * 60 * 60 * 1000L; // 3시간
private String tokenType = "Bearer";
public String resolveToken(HttpServletRequest request) {
return request.getHeader("AUTH-TOKEN");
}
public JwtCode validateToken(String token) {
if(token == null){
return JwtCode.DENIED;
}
try{
Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
return JwtCode.ACCESS;
}catch(ExpiredJwtException e){
return JwtCode.EXPIRED;
}catch(JwtException | IllegalArgumentException e){
log.info("잘못된 JWT 서명입니다.");
}
return JwtCode.DENIED;
}
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPrimaryKey(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
private String getUserPrimaryKey(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public JwtTokenResponse makeJwtTokenResponse(User user) {
String accessToken = makeAccessToken(user.getEmail(), user.getRoles());
return JwtTokenResponse.builder()
.accessToken(accessToken)
.tokenType(tokenType)
.build();
}
private String makeAccessToken(String email, Set<Role> roles) {
Claims claims = Jwts.claims().setSubject(email);
claims.put("roles", roles);
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidTime))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
}
User클래스도 만들자. 그냥 만드는게 아니라 Spring Security에서 쓰는UserDetails클래스를 상속받아 만들어야 한다.
package com.example.securitytest;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.io.Serializable;
import java.util.*;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class User implements UserDetails, Serializable {
private String email;
private String password;
@Builder.Default
private Set<Role> roles = new HashSet<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.email;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
기타 Request, Response 클래스도 만들어주자.
package com.example.securitytest;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Builder;
import org.springframework.web.multipart.MultipartFile;
public record SignUpRequest(
@NotNull(message = "이메일을 입력해 주세요.")
String email,
@NotNull(message = "비밀번호를 입력해 주세요")
String password
) {
@Builder
public SignUpRequest{
}
}
package com.example.securitytest;
import lombok.Builder;
public record UpdatedUserResponse(
String email
) {
@Builder
public UpdatedUserResponse {
}
}
package com.example.securitytest;
import org.springframework.security.core.GrantedAuthority;
public enum Role implements GrantedAuthority {
USER("ROLE_USER", "유저권한"),
ADMIN("ROLE_ADMIN", "관리자권한");
private String authority;
private String description;
private Role(String authority, String description){
this.authority = authority;
this.description = description;
}
@Override
public String getAuthority() {
return authority;
}
}
이제 드디어 Security를 이용해보자.
WebSecurityConfig어느 메소드가 내가 원하는 필터에 걸리게 할지를 설정해주는 것 같다. 여기서 우리가 볼 부분은.requestMatchers("/test").hasRole("USER")여기와.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)여기.requestMatchers에는 내가 필터를 걸어 검사하고 싶은 클래스/메소드 등을 추가해주면 되고,addFilterBefore를 통해 내가 걸고 싶은 필터(ex. Header를 검사한다던지)를 만들어준다.
package com.example.securitytest;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(manage -> manage.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/test").hasRole("USER")
.anyRequest().permitAll())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.cors(Customizer.withDefaults());
return http.build();
}
// @Bean
// public AuthenticationEntryPoint authenticationEntryPoint(){
// return new CustomAuthenticationEntryPoint();
// }
}
아래
JwtAuthenticationFilter에서는, 클라이언트에서 보낸 요청에서 헤더에 있는 토큰을 꺼낸 다음, 이 토큰이 유효한 토큰인지를 검사한 후, 유효한 토큰이라면 인증정보를 꺼내와서SecurityContextHolder에 담아준다.SecurityContextHolder에 담아주면 이후의 요청은 이 인증정보를 기억한다는 뜻인 것 같다.
package com.example.securitytest;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
JwtCode code = jwtTokenProvider.validateToken(token);
log.info("doFilterInternal 진입 : {}",code);
if(code == JwtCode.ACCESS){
log.info("doFilterInternal access");
Authentication authentication = jwtTokenProvider.getAuthentication(token);
log.info("doFilterInternal authentication : {}",authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
또한 Spring Security를 쓰기 위해서는
Role이 필요한 것 같다. 즉 이 클라이언트가 일반 사용자인지, 관리자인지 등을 파악해야하는 것 같다.
package com.example.securitytest;
import org.springframework.security.core.GrantedAuthority;
public enum Role implements GrantedAuthority {
USER("ROLE_USER", "유저권한"),
ADMIN("ROLE_ADMIN", "관리자권한");
private String authority;
private String description;
private Role(String authority, String description){
this.authority = authority;
this.description = description;
}
@Override
public String getAuthority() {
return authority;
}
}
또한 위의
JwtTokenProvider클래스에getAuthentication메소드가 있는데, 이 메소드는 게시글 조회 등의 기능에서 이 사람이 인증된 사용자라는 것을 인증할 때 사용하는 메소드다. 그런데getAuthentication메소드를 보면userDetailsService.loadUserByUsername라는 메소드를 사용하는데, 이userDetailsService는 Spring에서 제공하는userDetailsService이다. 그런데 이는UserDetailsService는 인터페이스이므로 이를 상속받는 구현체를 하나 만들어줘야 한다. 원래는 여기서 DB에 갔다 와서 이 사용자의 이메일이 DB에 있는지 등을 검사해줘야 하지만 우리는 그 로직은 생략하기로 했으니 Dummy User를 만들어서 return시켜주자. 이때Role를 추가 안해주면 오류가 난다. 여기서 이Role이라는 게 중요하다는 것을 깨달았다.
package com.example.securitytest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;
import java.util.HashSet;
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("loadUserByUsername : {}",username);
HashSet hs = new HashSet<Role>();
hs.add(Role.USER);
return new User(username, "", hs);
}
}
이제 로그인 후 실험해보자. 실제로 ID/PW를 검사하는 로직은 없기에 아래처럼 실행하자.
여기에서 받은 토큰을 헤더에 넣고
/test로 실험해보자.
휴!! 위처럼 잘 실행되는 것을 알 수 있다. 저기
AUTH-TOKEN에 토큰을 잘못 넣으면403오류가 난다. 즉, 위의 목표에서2번까지는 이루었다고 할 수 있다.
UserController에서test메소드를 아래와 같이@AuthenticationPrincipal를 추가해 수정헤보자.@GetMapping("/test") public String test(@AuthenticationPrincipal User user){ log.info("test완료 : {}",user); return "test완료 " + user.getEmail(); }아래 스샷처럼 간단하게 사용자의 정보를 간단하게 뽑아낼 수 있다.
아마도SecurityContextHolder.getContext().setAuthentication(authentication);덕분에 가능하지 않았나, 싶다.
이로써 3번까지 완료!!
그림으로 정리해 보았다.