이전 포스팅에서는 SpringSecurity의 formLogin을 사용한 세션 기반 인증에 대해 살펴봤다.
세션 방식은 구현이 간단하지만, 현대의 분산 시스템 환경에서는 한계가 있다.
특히 MSA(Microservice Architecture)와 같이 서버가 여러 개로 분산된 환경에서는 세션 정보를 동기화하기 어렵다는 문제가 있다.
이러한 문제를 해결하기 위해 많은 개발자들이 JWT(JSON Web Token)를 사용한 인증 방식을 선택한다.
이번 포스팅에서는 Spring Security에서 JWT를 활용한 인증 방식을 구현하는 방법을 자세히 알아보도록 하자 👀
JWT가 등장하게된 배경은 무엇일까?
이전 포스팅에서 정리했던 기존의 세션 기반 인증은 서버에서 세션을 유지해야한다는 특징이 있다.
따라서, 여러개의 서버를 사용하는 MSA 환경에서는 세션 기반으로 다른 서버에서 인증을 수행하는 것이 어렵다.
즉, 세션 기반 인증은 확장성이 떨어진다는 문제가 있다.
이러한 문제를 해결하기 위해 JWT를 활용한 인증 방식이 등장하게 된 것이다 🚀
JWT는 Header(헤더), Payload(페이로드), Signature(서명) 으로 구성된다.
이는 jwt.io 페이지에서 간단하게 확인할 수 있다.
각 부분은 다음과 같은 역할을 수행한다.
토큰의 타입 (typ: JWT)을 정의하고, 사용할 암호화 알고리즘 (alg: HS256)을 정의하는 부분이다.
토큰에 담길 데이터(Claim)를 포함하는 부분이다.
여기서 클레임이란 이름 : 값
형식의 정보를 담고 있는 영역을 의미한다.
Payload는 토큰에 데이터를 담는 영역이므로 이미 등록된 클레임과 사용자 정의 클레임으로 분류할 수 있다.
등록된 클레임(Registered Claims)
- iss(발급자), exp(만료 시간), sub(제목), aud(대상자) 등 미리 정해진 값
사용자 정의 클레임(Custom Claims)
- 사용자 ID, 권한 등 비즈니스 로직에 필요한 데이터
마지막으로 Signature는 Payload와 Header를 결합하여 비밀키(Secret Key)로 암호화한 값을 의미한다.
JWT를 처음 접한다면 이런 궁금증이 있을 수 있다.
Header와 Payload는 단순히 Base64Url 인코딩된 문자열이다.
즉, 토큰을 디코딩하면 누구든지 안에 있는 정보를 볼 수 있다!
하지만, Signature는 특정 비밀키(Secret Key)로 서명되어 있기 때문에 변조를 막을 수 있다.
즉, 토큰의 정보 자체는 노출될 수 있지만, 변조할 수는 없다.
따라서, Secret Key가 유출되지 않는 이상 JWT가 변조되기 어렵기 때문에 인증(Authentication)과정에서 사용할 수 있는 것이다!
아마 우리가 커스텀 로그인 기능을 개발하여 사용한다면 주로 API 요청을 통해 사용자 인증 과정을 거치게 될 것이다.
이 때, session기반의 인증이 아닌 토큰 기반의 인증을 처리하기 위해 이전에 작성했던 SecurityConfiguration
에서 정의했던 SecurityFilterChain 빈을 조금 수정해보자
Sample Code - Session 기반 인증 Disable
@Configuration @EnableWebSecurity public class SecurityConfiguration { @Autowired private JwtRequestFilter jwtRequestFilter; @Autowired private CustomUserDetailsService customUserDetailsService; @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth .requestMatchers("/loginProc", "/joinProc").permitAll() .requestMatchers("/api/**").hasAnyRole("ADMIN", "USER") .anyRequest().authenticated() ); http.formLogin(auth -> auth.disable()); http.csrf(auth -> auth.disable()); http.sessionManagement(auth -> auth.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.logout(auth -> auth.disable()); http.cors(Customizer.withDefaults()); http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } ... }
http를 이용하여 Security 설정을 커스터마이징하고 있는데 각각 어떤 목적으로 사용되는지를 살펴보자
http.formLogin(auth -> auth.disable());
- 이는 명시적으로 formLogin을 사용하지 않겠다는 것을 의미한다.
- 관련 설정을 아예 지워버려도 되지만, 혹시라도 formLogin 기반으로 동작하게 되면 관련 필터가 먼저 동작할 수 있기 때문에 disable을 시켜준다.
http.csrf(auth -> auth.disable());
- csrf는 크로스 사이트 변조를 막기위해 이전 사이트의 동작 내용을 담고 있는 csrf 토큰을 서버로 보내기 위해 존재한다.
- 하지만, API 요청 기반으로 처리된다면 이전 사이트의 내용과는 무관하게 독립적으로 수행되기 때문에 csrf 관련 설정도 disable 시켜주자
http.sessionManagement(auth -> auth.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
- 세션 정책을 Stateless 시켜주는 이유는 서버에 토큰의 세션 정보를 저장하지 않기 위함이다.
- 토큰 기반의 인증을 수행한다면 세션에 토큰 정보를 저장할 필요가 없기 때문이다
http.logout(auth -> auth.disable());
- JWT 토큰을 사용한다면 로그아웃 관련된 요청을 별도로 보낼 필요가 없다.
- 왜냐하면 JWT 토큰은 서버사이드에서 어느곳에서도 저장되지 않기 때문이다.
- 즉, 로그아웃을 원한다면 클라이언트 사이드에서 발급받은 토큰 정보를 삭제하기만 하면 되며, 서버 사이드에는 오직 토큰을 검증하는 로직만 존재할 뿐이다.
- 이를 이용하여 수평적 확장 (Scale-Out)에 용이하다는 특징이 있다!
http.cors(Customizer.withDefaults());
- 우리가 생성한 cors 규칙을 적용하기 위해서는 해당 코드를 추가해주면 된다!
- cors 관련 규칙을 모두 작성하고 이 코드를 누락하면 적용되지 않는 경우도 있다!!!!!!
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
- formLogin을 disable시키면서 관련 필터가 동작하지 않는다.
- 우리는 토큰 검증을 위한 별도의 필터(jwtRequestFilter)를 생성하고 , jwtRequestFilter를
UsernamePasswordAuthenticationFilter
이전에 실행하도록 설정한다.UsernamePasswordAuthenticationFilter
는 이전 포스팅에서도 언급했지만 기본 Security 필터이다.- 기본 필터보다 앞단에서 우리가 생성한 jwtRequestFilter가 동작하도록 하여 불필요한 내부 메서드 호출을 줄여줄 수 있다.
위 코드에서 jwtRequestFilter도 우리가 만들어야하고, JWT를 생성해주는 로직도 만들어야 한다.
먼저 JWT를 생성하는 로직을 작성해보자
우선 JWT를 생성하기 위해서는 비밀키가 필요하다.
이는 주로 설정 파일에서 정의하고 특정 인터페이스를 통해 가져와서 사용한다.
따라서, application.properties에 비밀키를 정의해보자
Sample Code
token.secret:My JWTToken's Secret is p@ssw0rd
위 처럼 token.secret에 우리가 사용하고자 하는 비밀키를 32자리의 특정 값으로 정의해주면 된다.
그렇다면 설정파일에 추가한 비밀키를 가져와서 JWT를 생성해보자
우선 코드를 살펴보자
Sample Code
@Component @Slf4j public class JwtUtils { private SecretKey hmacKey; private Long expirationTime; public JwtUtils(Environment env) { this.hmacKey = Keys.hmacShaKeyFor(env.getProperty("token.secret").getBytes()); this.expirationTime = Long.parseLong(env.getProperty("token.expiration-time")); } public String generateToken(UserEntity userEntity) { Date now = new Date(); String jwtToken = Jwts.builder() .claim("name", userEntity.getName()) .claim("email", userEntity.getEmail()) .subject(userEntity.getUsername()) // 서브젝트의 이름 설정 .id(String.valueOf(userEntity.getSeq())) .issuedAt(now) .expiration(new Date(now.getTime() + this.expirationTime)) .signWith(this.hmacKey, Jwts.SIG.HS256) .compact(); log.info(jwtToken); return jwtToken; } // 메서드 오버로딩 public String generateToken(UserDetails userDetails) { Date now = new Date(); String jwtToken = Jwts.builder() .claim("name", userDetails.getUsername()) .claim("role", userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList())) .subject(userDetails.getUsername()) // 서브젝트의 이름 설정 .id(String.valueOf(userDetails.hashCode())) .issuedAt(now) .expiration(new Date(now.getTime() + this.expirationTime)) .signWith(this.hmacKey, Jwts.SIG.HS256) .compact(); log.info(jwtToken); return jwtToken; } private boolean isTokenExpired(String token) { Date expiration = getExpirationDateFromToken(token); return expiration.before(new Date()); } private Date getExpirationDateFromToken(String token) { Claims claims = getAllClaimsFromToken(token); return claims.getExpiration(); } private Claims getAllClaimsFromToken(String token) { // JWT 라이브러리에서 가져오면 된다. Jws <Claims> jwt = Jwts.parser() .verifyWith(this.hmacKey) .build() .parseSignedClaims(token); return jwt.getPayload(); } public boolean validateToken(String token, UserEntity userEntity) { // 1. 유효 기간 확인 if(isTokenExpired(token)) { return false; } // 2. 토큰 내용 확인 String subject = getSubjectFromToken(token); String username = userEntity.getUsername(); return subject != null && username != null && subject.equals(username); } public String getSubjectFromToken(String token) { Claims claims = getAllClaimsFromToken(token); return claims.getSubject(); } }
우선 JwtUtils
에는 메서드 오버로딩을 사용하여 UserEntity 또는 UserDetails를 매개변수로 전달받아서 토큰을 생성하도록 작성했다.
또한, JwtUtils
에는 크게 2가지의 역할을 수행하는 메서드가 존재하는데 바로 토큰 생성과 토큰 유효성 검사 역할이다.
이는 각각 generateToken()
과 validateToken()
에 해당한다.
코드를 하나씩 살펴보자
private SecretKey hmacKey; private Long expirationTime; public JwtUtils(Environment env) { this.hmacKey = Keys.hmacShaKeyFor(env.getProperty("token.secret").getBytes()); this.expirationTime = Long.parseLong(env.getProperty("token.expiration-time")); }
- 필드로
SecretKey
객체를 사용하고 있다.- 이는 우리가 이전에 설정 파일에 등록한 32자리 값을 사용하여 hmac으로 암호화된 키값을 가진다.
- 의존성 주입을 사용하여
Environment
의 env를 사용해서 설정 파일의 값들을 불러오고 있다.
public String generateToken(UserDetails userDetails) { Date now = new Date(); String jwtToken = Jwts.builder() .claim("name", userDetails.getUsername()) .claim("role", userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList())) .subject(userDetails.getUsername()) // 서브젝트의 이름 설정 .id(String.valueOf(userDetails.hashCode())) .issuedAt(now) .expiration(new Date(now.getTime() + this.expirationTime)) .signWith(this.hmacKey, Jwts.SIG.HS256) .compact(); log.info(jwtToken); return jwtToken; }
- 토큰을 생성하는 메서드이다.
Jwts
라이브러리를 사용해서 JWT를 생성하고 있는 과정이다.- 이전에도 설명했다시피 JWT의 Payload에는 Claim이라는 키와 그에 매핑되는 값을 가질 수 있다고 정리했다.
- 따라서,
.claim()
메서드를 통해 우리가 JWT의 Payload 부분에 추가할 키와 값들을 정의하고 있다.- Payload에는 우리가 커스터마이징한 Claim이 아닌 default claim이 존재하는데 subject가 이에 해당된다.
- 이후, id값과 발생시간, 만료 시간을 설정해준다.
- 다음으로
.signWith()
메서드를 통해 암호화 과정을 거친다.- 이 때, 이전에 비밀키를 기반으로 생성한 hmackey를 사용하고 암호화 방법을 정의한다.
- 마지막으로 생성한 토큰을 return한다.
뭔가 주저리주저리 정리하게 된 것 같다.
(이런 방식 말고는 어떻게 정리해야할지 감이 안잡힌다..)
어쩄든, 우리가 시크릿키로 설정한 값을 이용하여 암호화 방식과 토큰의 정보들을 설정하고 반환하는 방식으로 토큰을 생성할 수 있다!
다음으로 유효성 검사를 수행하는 메서드를 살펴보자
public boolean validateToken(String token, UserEntity userEntity) { if(isTokenExpired(token)) { return false; } String subject = getSubjectFromToken(token); String username = userEntity.getUsername(); return subject != null && username != null && subject.equals(username); }
- 우선 토큰이 만료되었는지부터 확인한다.
- 다음으로 token과 userEntity를 기반으로 토큰 정보와 유저 정보가 일치하는지 확인한다.
그렇다면 토큰은 언제 생성되면 될까?
당연히 로그인이 성공된 이후 토큰이 발급되면 된다.
따라서, 토큰을 생성하는 로직은 로그인을 처리하는 컨트롤러에 정의하면 된다.
Sample Code
@RestController public class LoginController { @Autowired private AuthenticationManager authenticationManager; @Autowired private JwtUtils jwtUtils; @Autowired private CustomUserDetailsService userDetailsService; @PostMapping("/loginProc") @ResponseStatus(HttpStatus.OK) public String loginProc(@RequestBody LoginRequest loginRequest) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()) ); UserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(loginRequest.getUsername()); String jwtToken = jwtUtils.generateToken(userDetails); return jwtToken; } }
코드를 살펴보면 로그인 요청의 인증을 처리하고 사용자가 맞다면 토큰을 발급해준다.
여기서 우리는 한가지 주목해야할 부분이 있다.
위 코드를 보면 인증을 처리하는 과정에서 다음과 같은 코드가 존재한다.
Sample Code
Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()) );
Authentication은 기본적으로 최초 로그인 시 사용자 인증을 위한 내부 토큰을 생성하는 과정이다.
여기서 생성된 Authentication은 Spring Security가 사용자 검증을 위해 내부적으로 사용하기 때문에 실제 클라이언트에게 전달되지 않는다!
만약 인증에 성공하면 새로운 Authentication을 반환하고, 인증에 실패하면 에러를 발생시키는 방식으로 동작한다.
이는 분명히 JWT와는 다른 목적을 갖는다는 것을 기억하자
JWT는 우리가 API 요청을 할 때, 사용자를 인증하기 위함이다.
로그인 과정은 JWT를 사용하는 것이 아닌, 발급을 위한 과정이라고 보면 된다.
마지막으로 JWT를 포함한 요청에서 권한을 확인하는 Filter 코드를 작성해보자
SpringSecurity에서 필터를 생성하기 위해서는 OncePerRequestFilter
를 상속받아서 구현한다.
우선 코드를 살펴보자
Sample Code
@Component @Slf4j public class JwtRequestFilter extends OncePerRequestFilter { @Autowired private JwtUtils jwtUtils; @Autowired private UserRepository userRepository; @Autowired private CustomUserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { log.info("JwtRequestFilter >>>>"); String jwtToken = null; String subject = null; // Authorization 요청 헤더 포함 여부를 확인하고, 헤더 정보를 추출 String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { jwtToken = authorizationHeader.substring(7); subject = jwtUtils.getSubjectFromToken(jwtToken); } else { log.error("Authorization 헤더 누락 또는 토큰 형식 오류"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);> response.getWriter().write("Invalid JWT Token"); response.getWriter().flush(); return; } UserEntity userEntity = userRepository.findByUsername(subject); if (!jwtUtils.validateToken(jwtToken, userEntity)) { log.error("사용자 정보가 일치하지 않습니다. "); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Invalid JWT Token"); response.getWriter().flush(); return; } // 토큰의 검증이 끝나게 되면 사용자 인증 정보를 생성한다. UserDetails userDetails = this.userDetailsService.loadUserByUsername(subject); UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource() .buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); filterChain.doFilter(request, response); } @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { log.debug("shouldNotFilter >>>>>>>>>>"); String[] excludePath = { "/loginProc", "/joinProc" }; String uri = request.getRequestURI(); boolean result = Arrays.stream(excludePath).anyMatch(uri::startsWith); log.debug(">>>" + result); return result; } }
코드에서도 보다시피, OncePerRequestFilter
를 상속받아서 내부 메서드를 오버라이딩하여 필터를 구현할 수 있다.
실제 필터링을 수행하는 doFilterInternal()
부분을 살펴보자
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { jwtToken = authorizationHeader.substring(7); subject = jwtUtils.getSubjectFromToken(jwtToken); } else { log.error("Authorization 헤더 누락 또는 토큰 형식 오류"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);> response.getWriter().write("Invalid JWT Token"); response.getWriter().flush(); return; }
- JWT는 Http Request의 Header의 authorization쪽에 존재한다.
- 이때,
Bearer
이라는 prefix를 달고 넘어오기 때문에 요청에서 해당 부분이 존재하는지를 간단하게 확인하면 된다.- 만약 없다면 토큰이 누락된 것이기 때문에 에러 로그를 찍어주자
- 또한, response에는 헤더에 토큰이 없음을 보여주기 위해
.getWriter()
메서드를 사용하여 이를 명시해주면 된다.
UserEntity userEntity = userRepository.findByUsername(subject); if (!jwtUtils.validateToken(jwtToken, userEntity)) { log.error("사용자 정보가 일치하지 않습니다. "); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Invalid JWT Token"); response.getWriter().flush(); return; }
- 다음으로 토큰이 존재한다면, DB에서 유저 정보를 확인해보면 된다.
- 이때, 실습에서는 subject에 username을 넣었기 때문에 위처럼 코드를 작성했음을 유의하자
UserDetails userDetails = this.userDetailsService.loadUserByUsername(subject); UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource() .buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); filterChain.doFilter(request, response);
- 우선 SpringSecurity가 이해할 수 있는 사용자 정보 객체인 UserDetails에 맞춰 사용자 정보를 가져오자
- 다음으로 Authentication 객체를 생성하기 위해
UsernamePasswordAuthenticationToken
을 사용하여 생성한다.- 이 때, 유저 정보, 비밀번호, 권한을 넘겨주면 되는데 비밀번호는 어차피 우리가 사용할 일이 없으므로 null을 넘겨주자
- 그 다음으로 현재 HTTP 요청의 세부 정보를 Authentication 객체에 추가한다.
- 다음으로 SecurityContextHolder에 Authentication 객체를 저장한다.
- 마지막으로 다음 필터가 수행되도록 넘겨준다.
- SecuritycontextHolder는 세션에 저장된 SecurityContext를 꺼내와서 사용자 정보 (Authentication)을 사용할 수 있도록 해주는 객체라고 이전 포스팅에서 정리했다.
- 즉, 개발하는 과정에서 특정 정보를 이용하여 개발해야하는 경우를 대비하기 위해 SecurityContextHolder에 사용자 정보를 저장한다고 생각하면 된다.
간단하게 헤더에서 JWT 부분을 가져온 이후, 검증을 수행하고 개발하기 용이하도록 세션에 저장하는 과정을 수행한다고 이해하면 될 것 같다 😅
다음으로 shouldNotFilter()
부분을 살펴보자
@Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { log.debug("shouldNotFilter >>>>>>>>>>"); String[] excludePath = { "/loginProc", "/joinProc" }; String uri = request.getRequestURI(); boolean result = Arrays.stream(excludePath).anyMatch(uri::startsWith); log.debug(">>>" + result); return result; }
Filter가 모든 요청에 대해서 동작하게 되면 당연히 안된다.
왜냐하면 로그인을 하지 못한 경우에는 토큰이 없기 때문에 Filter가 로그인 요청도 막아버리면 안되기 떄문이다.
따라서, 로그인요청과 회원가입 요청에 대해서는 필터가 동작하지 않도록 특정 조건에 대하여 boolean 값을 반환하는 shouldNotFilter
메서드를 정의하면 된다.
이번 포스팅에서는 뭔가 주저리주저리 설명한 내용이 많은 것 같다.
코드만 봤을때는 뭔가 괴랄해보이지만, 실제로 로직을 이해하면 납득되는 내용들로만 구성되어있다.
이 부분을 조금 자세히 설명하고 싶어서 아무래도 코드를 설명하는 부분에서 글이 조금 길어진 것 같다.
간단하게 JWT를 생성하는 기능과 검증하는 기능, 그리고 JwtRequestFilter를 별도로 하나 생성해서 UsernamePasswordFilter 이전에 수행하도록 했음을 기억하자 👊
정말.. 정말 대단하십니다!!
Spring 쪽 포스팅을 쭉 읽어봤는데, 너무 도움됐습니다 성엽님..
항상 감사합니다!