Spring

Spring Security Session기반 인증 방식 VS Spring Security + JWT토큰 인증 방식

Lea Hwang 2023. 4. 12. 14:12

Spring Security Session 기반 인증 방식

 

디렉토리 구조

  • 📁config
    • 📁 auth
      • © CustomUserDetails
      • © CustomUserDetailsService
    • © SecurityConfig
  • 📁 controller
    • © IndexController
  • 📁 model
    • © User
  • 📁 repository
    • ⓘUserRepository

 

 

1. SecurityConfig

@Configuration
public class SecurityConfig  {

    @Bean 
    public BCryptPasswordEncoder encodePwd() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.authorizeRequests()
                .antMatchers("/user/**").authenticated()
                .antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
                .anyRequest().permitAll()
                .and()
                .formLogin()
                .loginPage("/auth/login") // GET
                .loginProcessingUrl("/auth/login") // POST
                .defaultSuccessUrl("/");
        return http.build();
    }
}

.loginProcessingUrl("/auth/login")

해당 주소 호출 시 시큐리티가 낚아채서 로그인 진행 (POST)합니다. 이는 개발자가 Controller에 로그인 진행 메서드를 따로 만들지 않아도 됨을 의미합니다. 

 

 

 

2. CustomUserDetails 

UserDetails 인터페이스를 구현한 클래스입니다. 

 

위에서 시큐리티가 로그인 진행을 완료하면 시큐리티 session을 만들어서 ContextHolder에 저장합니다. 

session은 타입이 정해져 있는데, Authentication 타입 객체여야 합니다. 

그리고 Authentication 안에는 User 정보가 있고 이 또한 UserDetails 타입 객체여야합니다. 

💡 [정리]
시큐리티 세션에 세션 정보를 저장해 주는데 ➡ session은 Authentication 타입 객체여야 한다.
그리고 Authentication 안 유저는 UserDetails타입 객체여야 한다. 

시큐리티 세션(내부 Authentication(내부 UserDetails))

 

 

이번에는 Authentication객체를 만들어보겠습니다. 만든 후 시큐리티 세션에 넣으면 됩니다. 

3. CustomUserDetailsService

@RequiredArgsConstructor
@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        User userEntity = userRepository.findByUsername(username);

        if(userEntity == null) {
            return null;
        }else {
            return new CustomUserDetails(userEntity);
        }
    }
}

/login 요청이 오면 자동으로 UserDetailsService타입의 loadUserByUsername함수가 실행됩니다.

 

그 후 CustomUserDetails 리턴시 시큐리티 세션(내부 Authentication(내부 UserDetails))이런 식으로 넣어지게 되는 것입니다. 

 

 

전체 Flow

위에서 시큐리티 설정파일에 loginProcessingUrl("/auth/login")을 등록했습니다.

 

회원가입 후 로그인을 버튼을 클릭하면,

login.html 일부 코드

<form th:action="@{/auth/login}" method="POST" >
    <div>
        <div>
            사용자 이름 또는 비밀번호를 확인해 주세요.
        </div>
    </div>
    <input type="text" name="username" placeholder="유저네임" required="required" autocomplete="off" /><br/>
    <input type="password" name="password" placeholder="비밀번호" required="required" /><br/>
    <button type="submit">로그인</button><br/>
</form>

시큐리티가 낚아채서 로그인 진행 (POST)하게 됩니다. 

 

자동으로 UserDetailsService타입의 loadUserByUsername함수가 실행되어 DB에 같은 username이 있다면, 시큐리티 세션 내부의 Authentication 객체에 들어가게 됩니다. 

 

그리고 Authentication 객체 안에 있는 유저는 UserDetails 타입이어야 합니다.

[정리]
최초 클라이언트가 ID, PW로 로그인 요청 시 ➡
서버에서 세션을 만들고 세션 ID를 클라이언트에게 응답 ➡
클라이언트는 로컬 스토리지에 세션 ID를 저장하고 앞으로 서버에게 요청시 세션ID를 같이 실어서 보냄 ➡
서버는 세션ID 확인 후 인증 페이지로 안내함

 

 

 

 


 

 + 권한 처리 (ROLE_ADMIN)

role 필드는 회원가입 시 기본적으로 ROLE_USER로 되어있습니다. 

하지만 ROLE_ADMIN 처리를 하고 싶을 때는 어떻게 할 수 있을까요?

 

여러 방법이 있겠지만, 여기서는 권한 처리가 주요 내용이 아니므로 가장 쉽게 처리해 보도록 하겠습니다.

1. 일반적으로 회원가입

2. 워크벤치에서 수동으로 role 필드를 ROLE_ADMIN으로 수정

3. IndexController 해당 메서드 위 @Secured("ROLE_ADMIN")처리

@Secured("ROLE_ADMIN")
@GetMapping("/admin")
public @ResponseBody String admin() {
    return "어드민 페이지입니다.";
}

 

 

 

 

 


여기까지 해서 "Spring Security Session 기반 로그인 로직"에 대해 알아보았습니다. 

이번에는 세션 말고 JWT 토큰을 이용한 로그인에 대해 살펴보도록 하겠습니다.

 

 

Spring Security + JWT토큰 인증 방식

 

바로 들어가기 전 JWT에 대해 간단히 알아보겠습니다.

JWT란?

Json Web Token의 약자로 Json 객체로 어떤 정보를 안전하게 전송하기 위한 방식입니다. 

이 정보는 디지털 서명이 되어있으므로 신뢰할 수 있습니다. 

 

서명하는 방법은 HS256 (HMAC with SHA-256) 해쉬 알고리즘 또는 RS256 (RSA Signature with SHA-256) 알고리즘을 사용합니다. 

 

공개키와 개인키를 사용하는 RS256보다 HS256이 보편적으로 많이 사용되므로 아래 코드는 HS256 방식으로 설명하겠습니다. 

 

 

이렇게 서명된 토큰은 그 안에 포함된 클레임의 무결성을 확인할 수 있게 해 줍니다. 

데이터베이스 무결성(Database Integrity)
데이터베이스에서 데이터가 정확하고 일관성 있게 유지되는 것을 의미합니다.
즉, 데이터베이스에서 데이터는 정확하게 저장되어야 하며, 데이터의 변경 및 삭제는 데이터베이스 내의 다른 데이터와 일관성 있게 이루어져야 합니다.

이를 위해 데이터베이스에는 무결성 제약 조건(Integrity Constraints)이 존재합니다.
데이터베이스에 저장된 데이터가 항상 일관성 있게 유지될 수 있도록 하는 규칙입니다.
예를 들어, 데이터베이스의 특정 테이블에서는 한 필드에 대해 중복된 데이터가 저장되지 않도록 제약 조건을 설정할 수 있습니다. 또는 다른 테이블에 저장된 데이터와 관련된 데이터가 삭제될 때, 이를 참조하는 데이터도 함께 삭제되도록 제약 조건을 설정할 수 있습니다.

무결성은 데이터베이스의 중요한 개념 중 하나이며, 데이터베이스의 신뢰성과 안정성을 유지하기 위해 매우 중요합니다. 따라서 데이터베이스 관리자는 무결성 제약 조건을 적절하게 설정하고, 이를 규칙적으로 검증하여 데이터베이스 내의 데이터가 항상 일관성 있게 유지되도록 해야 합니다.

 

 

JWT 구조

xxxxxx.yyyy.zzzz

순서대로

  • 헤더
  • 페이로드
  • 시그니처(서명)

 

헤더

토큰의 타입을 지정(JWT), 해싱 알고리즘(HS256)을 지정합니다.

 

페이로드 

개인 클레임 사용해서 정보를 담습니다. (예: id, username...)

 

시그니처

HMAC으로 암호화합니다. 사용하는 secret key는 서버만 알고 있는 비밀 값입니다.

HS256 : HMAC SHA256 시크릿 키를 가지고 해시로 암호화 ➡ 이는 복호화할 수 없음

 

 

 

JWT에 대해 정말 간단히 알아보았습니다. 코드를 쳐보기 전에 이론적으로 어떤 로직으로 돌아가는 건지 알아보겠습니다. 

Spring Security + JWT토큰 인증 방식 - 이론 편

우선 왜 session방식이 아닌 JWT토큰 방식을 선택했는지, 즉 session방식의 단점과 JWT토큰 방식의 장점에 대해 알아보겠습니다. 

 

session방식 vs JWT토큰 인증 방식의 장단점

session방식의 장점

  • 서버 측에서 세션을 관리하기 때문에 사용자 정보를 저장하거나 처리하는 데 필요한 모든 정보를 저장할 수 있습니다.
  • 세션 정보가 서버 측에 저장되므로 브라우저나 모바일 앱의 메모리를 절약할 수 있습니다.

session방식의 단점

  • 서버의 확장성이 제한됩니다. 각 서버가 사용자 세션 정보를 유지하고 있어야 하므로, 서버 수가 증가하면 세션 복제 및 동기화 문제가 발생할 수 있습니다.
  • 세션 정보를 저장하고 검색하는 데 필요한 리소스가 많아질 수 있습니다.
  • AJAX를 사용해서 클라이언트가 JS로 서버에게 요청 시 , 쿠키세션 정책은 기본적으로 동일 도메인에서 요청 시에 쿠키가 서버로 날아갑니다.
    • 이를 보완하기 위해 http Basic 방식(클라이언트가 서버에 요청 시 header의 Authorization: ID, PW를 담아서 보냄)으로 확장성을 높일 수 있겠지만 이 또한 ID, PW가 암호화가 안되어있어 노출 위험이 있습니다. 

 

 

 JWT는?

Authorization에 토큰(JWT)을 넣는 방식입니다. 이 또한 노출이 되면 안 되지만 그래도 토큰 자체가 ID, PW가 아니고 유효시간이 존재해서 위험 부담이 적습니다. 

ID, PW을 가지고 토큰을 만드는 방식을 Bearer Token 방식이라고 합니다.

 

JWT 토큰 인증 방식의 장점

  • 상태를 저장하지 않기 때문에 서버의 확장성이 좋습니다.
  • 모바일 앱과 같이 세션에 대한 저장 공간이 없는 경우 유용합니다.
  • JWT는 일반적으로 더 간단하고 적은 오버헤드로 작동합니다.
  • 토큰에는 권한 정보가 포함되어 있으므로 서버에서 권한 확인에 필요한 데이터베이스나 다른 인프라에 대한 호출을 줄일 수 있습니다.
  • 토큰이 서명되어 있기 때문에 변조될 가능성이 적습니다.

JWT 토큰 인증 방식의 단점

  • 토큰에 대한 전송 보안을 유지해야 하므로 HTTPS를 사용해야 합니다.
  • 토큰의 크기가 커질 경우, 네트워크 전송 및 저장소에 대한 부담이 증가합니다.

 

 

 

🤔JWT토큰을 서버에서 언제 만들어주나? 대략적인 로직

클라이언트에서 ID, PW를 Post요청시 ➡ 정상적으로 로그인이 완료되면 서버는 토큰을 만들어 주고*

클라이언트에게 토큰을 응답해줍니다. 클라이언트는 JWT를 저장합니다.**➡

클라이언트는 요청할 때마다 header의 Authorization에 value값으로 토큰을 가지고 서버로 옵니다. ➡

서버 쪽으로토큰이 넘어오면 이 토큰이 내가 만든 토큰인지만 검증하면 됩니다.*** (방법 : RSA, HS256)

 

* 서버가 session을 만들어 주는 게 아닌, 인증이 완료되면 JWT토큰을 만들어주는 것입니다. 

  header : 토큰의 타입을 지정(JWT), 해싱 알고리즘(HS256)

  payload : {username: apple}

  signature : haader + payload + 서버만 알고 있는 키값(star) ➡ HS256으로 암호화 (ABC)

그 후 각각 Base64로 인코딩합니다. 

 

 

** 클라이언트가 JWT를 받아서 로컬스토리지 같은 곳에 저장

JWT을 저장할 때는, 일반적으로 브라우저의 로컬 스토리지, 세션 스토리지, 또는 쿠키 중에서 선택합니다.

로컬 스토리지와 세션 스토리지는 브라우저에 내장된 클라이언트 측 저장소입니다.
로컬 스토리지는 사용자가 직접 삭제하지 않는 한 영구적으로 유지되며, 세션 스토리지는 브라우저 세션이 유지되는 동안에만 유지됩니다.

쿠키는 브라우저를 종료해도 유지될 수 있으며, 만료 날짜가 지나면 삭제됩니다.

일반적으로, 로컬 스토리지와 세션 스토리지는 보안 측면에서 취약할 수 있으므로, 보안 상의 이유로 쿠키를 사용하는 것이 권장됩니다.

그러나 중요한 정보를 담고 있는 JWT의 경우, 쿠키도 보안 위협이 될 수 있으므로 적절한 방법으로 저장해야 합니다.
예를 들어, HTTPS와 같은 보안 프로토콜을 사용하고, CSRF 공격에 대비한 보호 기능을 추가하면 JWT을 안전하게 저장할 수 있습니다.

 

 

*** 서버는 유효한 토큰인지 검증 

클라이언트로 받은 header와 payload + star HS256으로 암호화 진행해서 ABC가 나오면 인증 된 것입니다. 그럼 payload에 있는 username을 DB에서 찾아서 보여주게 됩니다.

 

 

 

 


 

이젠 코드로 알아볼 차례입니다.

 

Spring Security + JWT토큰 인증 방식 - 코드 편

 

디렉토리 구조

  • 📁 config
    • 📁 auth ⬅ 위와 내용 동일
      • © CustomUserDetails
      • © CustomUserDetailsService
    • 📁 jwtFilter
      • © JwtAuthenticationFilter
      • © JwtAuthorizationFilter
      • ⓘ JwtProperties
    • © CorsConfig
    • © SecurityConfig
  • 📁 controller
    • © RestApiController
  • 📁 dto
    • © LoginRequestDto
  • 📁 model
    • © User
  • 📁 repository
    • ⓘ UserRepository

 

build.gradle에 JWT관련 라이브러리 추가

dependencies {
    implementation 'com.auth0:java-jwt:4.4.0'
}

 

 

시큐리티 설정파일 SecurityConfig

@Configuration
@EnableWebSecurity // 시큐리티 활성화해서 기본 스프링 필터체인에 등록
public class SecurityConfig {

    @Autowired
    private CorsConfig corsConfig;
    
    @Autowired
    private UserRepository userRepository;

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin().disable() 
                .httpBasic().disable() 
                .apply(new CustomFilterApply()) 
                .and()
                .authorizeRequests(authroize -> authroize.antMatchers("/api/v1/user/**")
                        .access("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
                        .antMatchers("/api/v1/admin/**")
                        .access("hasRole('ROLE_ADMIN')")
                        .anyRequest().permitAll()) 
                .build();
    }
    
    public class CustomFilterApply extends AbstractHttpConfigurer<CustomFilterApply, HttpSecurity> {
        @Override
        public void configure(HttpSecurity http) throws Exception {
            AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
            http
                    .addFilter(corsConfig.corsFilter())
                    .addFilter(new JwtAuthenticationFilter(authenticationManager))
                    .addFilter(new JwtAuthorizationFilter(authenticationManager, userRepository));
        }
    }
}

세션을 사용하지 않고 JWT를 사용할 것이므로 몇 가지 추가 설정을 해보겠습니다.

1. 세션을 사용하지 않고, stateless 서버로 만들겠다.

.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

 

2. formLogin 사용 안 함

.formLogin().disable() 

 

3. 기본적인 http 로그인 방식 사용 안 함
.httpBasic().disable()

 

4. JWT 관련 필터 적용 
.apply(new CustomFilterApply()) 

  • 모든 요청은 CorsConfig 필터를 타는데, 여기서 모든 요청을 허용했기에 CORS 정책에서 벗어나게 됨

 

 

CorsConfig 

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true); 
        config.addAllowedOrigin("*"); 
        config.addAllowedHeader("*"); 
        config.addAllowedMethod("*"); 

        source.registerCorsConfiguration("/api/**", config); 
        return new CorsFilter(source);
    }
}

1. 서버가 응답할 때, JSON을 JS에서 처리할 수 있게 할까? Yes

config.setAllowCredentials(true);  

 

2. 모든 ip에 응답을 허용
config.addAllowedOrigin("*"); 

 

3. 모든 header에 응답을 허용
config.addAllowedHeader("*"); 

 

4. 모든 post, get, put, delete, patch 요청을 허용하겠다.
config.addAllowedMethod("*"); 

 

➡ 모든 요청을 허용했으므로 CORS 정책에서 벗어나게 되었습니다. 

 

 

 

/login 요청 시 JWT 관련 Filter 생성

JwtAuthenticationFilter

스프링 시큐리티에는 UsernamePasswordAuthenticationFilter가 있습니다.(인터페이스) 이를 구현한 클래스가 JwtAuthenticationFilter입니다.

 

로직
클라이언트가 /login 요청 ➡ username, password를 Post로 전송 ➡
UsernamePasswordAuthenticationFilter 가 동작을 합니다.

 

로그인 시도를 위해 attemptAuthentication() 함수가 실행됩니다.

  • request에 있는 username과 password를 파싱* 해서 자바 Object로 받습니다.
  • username, password로 Token 생성합니다. (Bearer Token)
  • 위에서 만든 Bearer Token으로 로그인 시도
  • 리턴시 Authentication객체가 session영역에 저장
    • JWT 토큰을 이용하면 session을 만들 필요는 없지만 권한 관리를 시큐리티가 대신하도록 하기 위함

 

attemptAuthentication() 함수 실행 후 인증이 정상적으로 되었을 시 successfulAuthentication() 함수가 실행됩니다.

  • JWT 토큰을 만들어서 request 요청한 사용자에게 (JWT토큰을) response 해주면 됩니다.

 

 

이후 클라이언트가 요청 시 JWT토큰을 서버에 보내면
서버가 JWT토큰을 받아서 처리하는(민감 정보에 접근하는) 필터(JwtAuthorizationFilter)가 필요합니다. 

  • 해당 JWT토큰이 유효한지 서버가 판단

 

코드

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    
    private final AuthenticationManager authenticationManager;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {

        // 1. request에 있는 username과 password를 파싱해서 자바 Object로 받기
        ObjectMapper objectMapper = new ObjectMapper(); 
        LoginRequestDto loginRequestDto = null;
        try {
            loginRequestDto = objectMapper.readValue(request.getInputStream(), LoginRequestDto.class);
        } catch (Exception e) {
            e.printStackTrace();
        }

        // 2. username, password로 Token 생성
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(
                        loginRequestDto.getUsername(),
                        loginRequestDto.getPassword());
       
        // 3. 2번에서 만든 Token으로 로그인 시도
        Authentication authentication =
                authenticationManager.authenticate(authenticationToken);

        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
       
        // 4.리턴시 authentication객체가 session영역에 저장
        return authentication;
    }

   
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {

        CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal();

        // 1. JWT토큰 생성 (빌더패턴)
        String jwtToken = JWT.create()
                .withSubject(customUserDetails.getUsername())
                .withExpiresAt(new Date(System.currentTimeMillis()+JwtProperties.EXPIRATION_TIME))
                .withClaim("id", customUserDetails.getUser().getId())
                .withClaim("username", customUserDetails.getUser().getUsername())
                .sign(Algorithm.HMAC512(JwtProperties.SECRET));
       
        // 2. header에 JWT토큰을 넣어서 응답
        response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX+jwtToken);
    }

}

 

/login 요청하면서 username, password를 Post로 전송 시, UsernamePasswordAuthenticationFilter 가 동작을 합니다. 

 

attemptAuthentication() :  로그인 시도를 위해 실행되는 함수

request에 담긴 username, password를 받아서 정상적으로 로그인 한 건지 확인합니다. 

HOW?

1. request에 있는 username과 password를 파싱 해서 자바 Object로 받기

💡 JSON 데이터 파싱하기 위해 ObjectMapper 사용
파싱이란? 다른 형식으로 저장된 데이터를 원하는 형식의 데이터로 변환하는 것.

ObjectMapper
JSON 컨텐츠를 Java 객체로 deserialization(역직렬화) 하거나 Java 객체를 JSON으로 serialization(직렬화) 할 때 사용하는 Jackson 라이브러리의 클래스입니다. 

방법)
Java Object → JSON
Java 객체를 JSON으로 serialization 하기 위해서는 ObjectMapper의 writeValue() 메서드를 이용한다.
  objectMapper.writeValue(JSON을 저장할 파일, 직렬화시킬 객체)

JSON → Java Object
JSON 파일을 Java 객체로 deserialization 하기 위해서는 ObjectMapper의 readValue() 메서드를 이용한다.
  objectMapper.readValue(JSON 형태의 문자열, 역직렬화시킬 클래스)

이번 포스팅에서는 JSON 데이터를 자바 Object로 파싱 하는 것이므로, readValue()를 사용해 보겠습니다. 

loginRequestDto = objectMapper.readValue(request.getInputStream(), LoginRequestDto.class);

 

 

2. username, password로 Token 생성

 

3. 2번에서 만든 Token으로 로그인 시도

Authentication authentication = authenticationManager.authenticate(authenticationToken);
  • authenticate() 함수가 호출 ➡ 인증 프로바이더가 CustomUserDetailsService의 loadUserByUsername(토큰의 첫 번째 파라미터)를 호출합니다.
  • 결과로 CustomUserDetails를 리턴 받아서 토큰의 두 번째 파라미터(credential)와 CustomUserDetails(DB값)의 getPassword()의 값이 동일하면 Authentication 객체를 만들어서 필터체인으로 리턴해준다.
    • Authentication 객체에는 로그인 정보가 담겨있습니다. 

 

4. authentication객체가 session영역에 저장

return authentication;

 

 

 

attemptAuthentication() 실행 후 인증이 정상적으로 되었을 시 successfulAuthentication() 함수가 실행됩니다.  

WHAT?

JWT토큰을 만든 후 ➡ request 요청한 사용자에게 JWT토큰을 실어서 response 해줍니다. 

 

1. JWT토큰 생성 (빌더패턴) - Hash 암호화방식

String jwtToken = JWT.create()
                .withSubject(customUserDetails.getUsername())
                .withExpiresAt(new Date(System.currentTimeMillis()+JwtProperties.EXPIRATION_TIME))
                .withClaim("id", customUserDetails.getUser().getId())
                .withClaim("username", customUserDetails.getUser().getUsername())
                .sign(Algorithm.HMAC512(JwtProperties.SECRET));
  • withSubject  : 토큰 이름
  • withExpiresAt : 언제 만료될지 설정
    • 짧게 설정, 탈취되어도 큰 최소한의 안전장치
  • withClaim  : 비공개 클레임
  • sign(.HMAC512(서버만 아는 secret 값))

 

2. header에 JWT토큰을 넣어서 응답

 response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX+jwtToken);

response.addHeader(키, 밸류)

response.addHeader("Authorization", "Bearer "+jwtToken)

 

 

 

 

이후 클라이언트가 요청 시 JWT토큰을 서버에 보내면
서버가 JWT토큰을 받아서 처리하는(민감 정보에 접근하는) 필터(JwtAuthorizationFilter)가 필요합니다. 

JwtAuthorizationFilter

시큐리티가 가지고 있는 filter 중 BasicAuthenticationFilter가 있습니다.  이 필터는 무조건 타게 되어있는데 여기서 토큰이 있는지 없는지 검사하게 됩니다. 

 

인증이나 권한이 필요한 주소 요청이 있다면, doFilterInternal 필터를 탑니다.

  • 요청 온 header 확인합니다. (없다면 return)
  • JWT 토큰을 검증해서 정상적인 사용자인지 확인
    • 서명이 정상적으로 되면 ➡ getClaim 해서 username을 가져옴
    • 인증은 토큰 검증 시 끝!
  • 여기서 추가로 Authentication 객체를 강제로 만들고 세션영역에 저장한 이유는, Spring Security가 수행하는 권한 처리*때문입니다. 
🤔 스프링 시큐리티에서 권한 처리란?
사용자가 인증을 통과하고 로그인이 성공하면 권한 처리가 수행되어 사용자가 수행할 수 있는 권한이 부여됩니다.

권한은 대개 "ROLE_" 접두어와 함께 정의되며, 사용자가 시스템에서 수행할 수 있는 특정한 작업 또는 역할을 나타냅니다.

스프링 시큐리티에서 권한 처리는 인증된 사용자의 권한을 결정하고, 이를 기반으로 보안 검사 및 인가를 수행합니다.

JWT 토큰을 사용하여 로그인 기능을 구현할 때, 스프링 시큐리티는 Authentication 객체를 생성하고 이를 세션 영역에 저장합니다. 이는 권한 처리 및 인가를 수행하기 위해 필요한 과정입니다. 인증이 완료된 사용자의 권한을 결정하기 위해 Authentication 객체는 사용자의 권한 정보를 포함하고 있으며, 이 정보를 이용하여 보안 검사 및 인가를 수행합니다.

 

 

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
   
    private UserRepository userRepository;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
        super(authenticationManager);
        this.userRepository= userRepository;
    }

    // 인증이나 권한이 필요한 주소요청이 있을 때
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        // 1. 요청온 header 확인
        String header = request.getHeader(JwtProperties.HEADER_STRING);

        if (header == null || !header.startsWith(JwtProperties.TOKEN_PREFIX)) {
            chain.doFilter(request, response);
            return;
        }

        // 2. JWT 토큰을 검증해서 정상적인 사용자인지 확인
        String jwtToken = request.getHeader(JwtProperties.HEADER_STRING)
                .replace(JwtProperties.TOKEN_PREFIX, "");

        String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(jwtToken)
                .getClaim("username").asString();

        if (username != null) {
            User userEntity = userRepository.findByUsername(username);

            CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);

            // Authentication 객체를 강제로 만듦 (vs. JwtAuthenticationFilter에선 로그인을 해서 만들어진 것)
            Authentication authentication = new UsernamePasswordAuthenticationToken(
                    customUserDetails,
                    userEntity.getPassword(), // 패스워드
                    customUserDetails.getAuthorities()); // 권한

            // 시큐리티의 세션 영역에 접근하여 Authentication 객체 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response); 
    }
}

 

 


 

 

😎 스프링 시큐리티 세션 기반 인증 방식 vs 스프링 시큐리티 + JWT토큰 인증 방식 
세션 방식
username, password 로그인이 정상 ➡ 서버에서 session ID 생성 ➡ 클라이언트에게 session ID 응답, 쿠키 스토리지에 저장 ➡ 앞으로 클라이언트가 요청 시 session ID를 쿠키에 심어서 서버에 요청

➡ 서버는 session ID가 유효한지 판단 ➡ 유효하면 (인증이 필요한) 페이지로 접근하게 시큐리티가 동작함

 

JWT토큰 방식
username, password 로그인이 정상 ➡ 인증 후, 서버에서 JWT 토큰을 생성 ➡ header에 넣어서 클라이언트로 JWT 토큰을 응답 ➡ 클라이언트가 요청할 때마다 JWT토큰을 가지고 요청 ➡
서버는 JWT토큰이 유효한지만 판단(필터로 처리)

 

 


 

Postman으로 확인

1. 회원가입

 

2. 로그인 ➡ JWT토큰 

 

3. 토큰을 가지고, 요청

 

 

 

 


참고: 

https://www.youtube.com/watch?v=oSwtqn3E-GA&list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah