[Spring Boot] Spring Security+JWT 맛보기 (1) - JWT 이해

CNH·2024년 2월 25일

개발

목록 보기
14/17

개요

옛날에는 로그인/회원가입 관련 기능 개발 시 세션 방식을 많이 사용했다고 한다. 그런데 이제는 이런저런 이유로 JWT 토큰이라는 것을 사용한다고 하는데.. JWT 토큰과 Spring Security 개념이 섞이니까 쉽게 설명한다는 블로그들도 어렵.. 기에 일단 가장 아주 간단한 기능부터 해보고자 한다.

목표

  1. JWT 이해 240310 추가
  2. 로그인 시 JWT 토큰 발급
  3. 게시물 조회 시 토큰이 있는 경우에만 통과
  4. @AuthenticationPrincipal을 사용하여 게시물 조회 시 클라이언트에서 주는 토큰으로 사용자 이름 뽑아내기

구현

0. JWT 이해

(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. PayloadBase64로 인코딩 되어있기 때문에, 반대로 Base64로 디코딩하면 정보가 쉽게 보이기 때문이다.
그럼 이제 3. Signature를 어떻게 사용하는지 알아보자. 우선 클라이언트가 보낸 Payload와 서버만이 갖고 있는 Secret Key를 가지고 Header에 있는 알고리즘으로 Signature를 만들어보자. 그 다음 클라이언트가 보내준 Signature과 비교했을 때 일치하면 이는 서버가 만든 토큰이 맞는 것이다. (보안 통과) 왜냐면 Secret Key는 서버 혼자서만 갖고 있기 때문.

이렇게 만든 JWT 토큰을 가지고 다양한 곳에서 쓸 수 있는데, 여기처럼 로그인에서도 쓸 수 있는 것이다. 로그인을 구현하는 방법에는 세션을 이용하는 방법과, JWT를 이용하는 방법이 있다. 서버가 여러 대인 환경에서(로드밸런싱) 세션을 이용하게 되면, 각 서버의 세션이 저장되어 있는 세션DB 같은 것을 새로 만들어야 하고, 이는 DB가 터지게 할 수 있으며 매 요청마다 DB에 접근해야한다는 단점이 있다. 이를 보완하기 위해 나온 것이 JWT이며, JWT의 Payload에는 유저 정보도 있기 때문에 굳이 DB를 또 갔다 올 필요가 없는 것이다.

여기까지가 배경 지식이고, 이제 실제로 이용해보면 된다. 하지만 공부를 하니 조금 헷갈리는 것은, JWT의 장점이 DB를 갔다오지 않는 것이라면, 내가 구현한 것은 틀리게 되어버린다.. 난 Payload에다가는 유저의 email만 넣어놓고, 서버에서 이를 확인할 때도 email을 가지고 DB에서 한 번 더 확인하는 작업을 거치는데... 이렇게 되면 나도 DB를 한 번 거치게 되어버린다. JWT가 지향하는 것은 유저 이메일, 닉네임 등을 전부 Payload에 넣고 따로 DB에 갔다올 필요가 없게 하는것인가!?!?

그러나 잠깐 더 생각해보니, 서버가 여러 대인 경우 세션을 쓰면 세션 테이블(SessionID, userID)에 한 번 갔다오고, 이 userID로 유저 테이블에 가서 유저 정보를 조회해 와야한다. 그치만 내가 구현한 JWT 방식으로 해도 세션테이블에 갔다오는 과정이 생략되니까(그냥 서버에서 JWT인증 확인하면 되니까), DB에 갔다오는 횟수는 줄였다고 말할 수 있는건가..?!

1. Dependency, 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)

2. 기본 Controller, Service 클래스, JWT 클래스 설정

최소한의 메소드만 만들자. 회원가입은 필요없고, 로그인 시에도 인증하는 과정(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;
    }
}

3. Spring Security 관련 설정

이제 드디어 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);
    }
}

4. 실험

이제 로그인 후 실험해보자. 실제로 ID/PW를 검사하는 로직은 없기에 아래처럼 실행하자.

여기에서 받은 토큰을 헤더에 넣고 /test로 실험해보자.

휴!! 위처럼 잘 실행되는 것을 알 수 있다. 저기 AUTH-TOKEN에 토큰을 잘못 넣으면 403오류가 난다. 즉, 위의 목표에서 2번까지는 이루었다고 할 수 있다.

5. @AuthenticationPrincipal 적용

UserController에서 test메소드를 아래와 같이 @AuthenticationPrincipal를 추가해 수정헤보자.

    @GetMapping("/test")
    public String test(@AuthenticationPrincipal User user){
        log.info("test완료 : {}",user);
        return "test완료 " + user.getEmail();
    }

아래 스샷처럼 간단하게 사용자의 정보를 간단하게 뽑아낼 수 있다.

아마도 SecurityContextHolder.getContext().setAuthentication(authentication); 덕분에 가능하지 않았나, 싶다.
이로써 3번까지 완료!!


정리

그림으로 정리해 보았다.

profile
끄적끄적....

0개의 댓글