Post

Oauth2 적용

Oauth2 적용

OAuth2

STOMP를 활용하면 header에 token 값을 넣어 인증을 할 수 있다는 글을 많이 보게 되었다.

실제로 적용하는 곳은 많지 않았지만 해당 기능을 적용할 수 있다면 한 번쯤 직접 구현해보는 것도 의미가 있다고 생각했다.

이를 위해 Spring Security 적용이 필요했는데 기존 프로젝트에는 이미 Security가 적용되어 있었다.

기존 방식과 다른 로그인 흐름을 실험해보고 싶어서 OAuth2 방식을 활용하게 되었다.

Authentication은 인증을 의미하고 Authorization은 인가를 의미한다.

공식 문서





Security Flow

Spring Security에서 별도의 설정 없이 기본 formLogin 방식을 사용할 경우

다음과 같은 흐름으로 인증이 진행된다.


image

클라이언트로부터 요청이 들어오면

Servlet Filter 단계에서 SecurityFilterChain으로 요청 처리가 위임된다.

SecurityFilterChain 내부에서 UsernamePasswordAuthenticationFilter가 인증을 담당하게 된다.

UsernamePasswordAuthenticationFilter는 AuthenticationFilter에 해당하는 구현체이다.


AuthenticationFilter는 요청을 가로채

사용자 입력 정보를 기반으로 UsernamePasswordAuthenticationToken 객체를 생성한다.

이 시점의 Authentication 객체는 아직 검증되지 않은 상태이다.

AuthenticationFilter는 HttpServletRequest에서 username과 password를 추출하여 토큰을 생성하고

생성된 토큰은 AuthenticationManager에게 전달된다.


AuthenticationManager는 인터페이스이며 일반적으로 ProviderManager 구현체가 사용된다.

ProviderManager는 인증 처리를 위해 AuthenticationProvider에게 토큰을 전달한다.

AuthenticationProvider는 전달받은 UsernamePasswordAuthenticationToken을 기반으로 UserDetailsService를 호출한다.


UserDetailsService는 DB에서 사용자 정보를 조회하여 UserDetails 객체를 생성한다.

생성된 UserDetails 객체는 다시 AuthenticationProvider로 전달된다.

AuthenticationProvider는 사용자 인증을 수행하고 성공 시 권한 정보가 포함된 Authentication 객체를 생성한다.

ProviderManager는 인증이 완료된 Authentication 객체를 AuthenticationFilter로 반환하고

AuthenticationFilter는 이를 SecurityContextHolder의 SecurityContext에 저장한다.




UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilter는 Form 기반 인증 방식에서 사용되는 필터이다.

로그인 요청 시 전달되는 username과 password 파라미터를 파싱하여

인증용 토큰을 생성하고 AuthenticationManager에게 위임한다.

Spring Boot 환경에서 HttpSecurity 설정에 http.formLogin()을 사용하면 기본적으로 이 필터가 사용된다.

1
2
3
4
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.formLogin();
}


OAuth 로그인을 사용하는 경우

UsernamePasswordAuthenticationFilter 대신 OAuth2LoginAuthenticationFilter가 호출된다.

두 필터의 공통 상위 클래스는 AbstractAuthenticationProcessingFilter이며

Spring Security는 이를 기준으로 로그인 방식을 분기한다.

즉, 로그인 방식에 따라 UsernamePasswordAuthenticationFilter 또는 OAuth2LoginAuthenticationFilter가 동작한다.





OAuth2 Flow

OAuth2 로그인 흐름은 최초 인증 요청 URL 호출로 시작된다.

http://localhost:8080/oauth2/authorize/kakao


Spring Security OAuth2의 기본 요청 경로는 /oauth2/authorization/{registrationId} 이다.

1
<a href="/oauth2/authorization/kakao">kakao</a>

Image


frontend와 backend의 도메인이 동일한 경우 상대 경로를 사용할 수 있지만

도메인이 다른 경우 backend 주소를 직접 명시해야 한다.

참고 글 : 배포 후 oauth2 수정


ex) <a href="https://backend.haedal.com/oauth2/authorization/kakao">kakao</a>


링크를 클릭하면 카카오 로그인 페이지로 이동하게 되고

Resource Server는 client_id와 redirect_uri 정보를 검증한다.

검증이 성공하면 Resource Server는 Resource Owner(사용자)에게 권한 허용 여부를 요청한다.

Resource Owner가 권한을 허용하면

Resource Server는 authorization code를 발급하여 redirect_uri를 통해 Client에게 전달한다.

이 시점에 Resource Owner는 authorization code가 발급되었다는 사실을 인지하지 못한다. 브라우저 URL만 바뀔 뿐, 사용자 입장에서는 로그인 완료처럼 보인다.


redirect 예시는 다음과 같다.

http://localhost:8080/login/oauth2/code/kakao?code=xxxxxx


이 단계까지가 “해당 사용자가 카카오에 존재하는 사용자인지” 검증하는 과정이다.

이후에는 이 code를 가지고 실제 Access Token을 요청한다.

grant_type, client_id, redirect_uri, code 값을 포함하여 요청한다.

1
2
3
4
5
6
curl -v -X POST "https://kauth.kakao.com/oauth/token" \
 -H "Content-Type: application/x-www-form-urlencoded" \
 -d "grant_type=authorization_code" \
 -d "client_id=${REST_API_KEY}" \
 --data-urlencode "redirect_uri=${REDIRECT_URI}" \
 -d "code=${AUTHORIZE_CODE}"

Access Token을 발급받으면 OAuth2 인증 과정은 완료되고 이는 곧 로그인 성공을 의미한다.


이후 과정은 커스텀 인가 처리 단계이며 본 프로젝트에서는 OAuth2 + JWT 방식을 사용하였다.

OAuth2 로그인 이후의 흐름은 다음과 같다.

  • CustomOAuthService : 인증된 사용자 정보를 확인하고, DB에 없으면 신규 사용자로 저장
  • OAuth2SuccessHandler : CustomOAuthService 성공 시 실행, JWT 토큰을 생성하고 redirect_uri로 이동 (JWT는 QueryString에 포함하여 전달)
  • OAuth2FailureHandler : CustomOAuthService에서 예외 발생 시 실행




JWT

JWT는 사용자 정보(Claim)를 토큰 자체에 담아두는 인증 방식이다.

덕분에 매 요청마다 인증 서버에 검증 요청을 보낼 필요 없이 각 서버에서 토큰을 직접 검증할 수 있다.

이를 통해 Stateless 아키텍처 구성이 가능해지며 서버 자원을 효율적으로 사용할 수 있다.


흐름을 정리하면 다음과 같다.

  1. 클라이언트가 Auth Server에 로그인을 요청한다.
  2. 인증이 성공하면 Auth Server는 JWT 토큰을 발급한다.
  3. 이후 클라이언트는 API 요청 시 Authorization Header에 JWT 토큰을 포함하여 전달한다.
  4. 애플리케이션 서버는 인증 서버를 거치지 않고, JWT 토큰의 유효성을 직접 검증한 뒤 요청을 처리한다.


다만 JWT 방식에는 주의할 점이 있다.

JWT는 매 요청마다 헤더에 포함되기 때문에 네트워크 트래픽이 증가할 수 있으며

토큰에 사용자 정보가 담겨 있어 보안 관리가 중요하다.

특히 탈취된 토큰은 서버에서 즉시 차단하기 어렵기 때문에

반드시 만료 시간을 설정하여 토큰의 유효 기간을 제한해야 한다.





Code

본 프로젝트에서는 Kakao, Google OAuth 로그인을 적용하였다.

아래 예시는 Kakao OAuth 기준으로 작성되었다.


코드 구성을 간단히 정리하면 다음과 같다.

  • SecurityConfig : OAuth2 로그인 설정 및 JWT 필터 등록
  • CustomOAuthService : OAuth2 로그인 성공 후 사용자 정보 처리 (loadUser() 호출)
  • OAuth2SuccessHandler : 로그인 성공 시 JWT 생성 및 redirect
  • JwtTokenProvider : JWT 생성 및 검증 로직
  • JwtAuthenticationFilter : 매 요청마다 JWT 유효성 검사

모든 과정은 Spring Security Filter 내부에서 처리되기 때문에 별도의 Login Controller는 존재하지 않는다.




OAuth 2.0 설정

1
2
3
4
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-configuration-processor'

OAuth 관련 설정 정보는 외부에 노출되면 안 되기 때문에

application-oauth.properties 파일로 분리하여 관리한다.

1
spring.profiles.include=oauth
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# kakao about uri
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri = https://kapi.kakao.com/v2/user/me


# kakao certification need application information
spring.security.oauth2.client.registration.kakao.client-name=kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.client-id = 


# kakao certification uses method & userinfo scope
spring.security.oauth2.client.registration.kakao.client-authentication-method=POST
spring.security.oauth2.client.registration.kakao.client-secret = 
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.scope=profile_nickname, account_email

Image

redirect-uri는 {baseUrl}/login/oauth2/code/{registrationId} 형식을 따르기 때문에

카카오의 경우 login/oauth2/code/kakao로 작성했다.




Domain

User Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class User implements UserDetails {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   @Column(name = "user_id")
   private Long id;

   @Column(nullable = false)
   private String nickname;

   @Column(nullable = false)
   private String password;

   @Column(nullable = false)
   private String email;

   @Enumerated(EnumType.STRING)
   @Column(nullable = false)
   private UserRole role;

   @Column(nullable = false)
   private boolean enabled = true;

   @Column(nullable = false)
   private String socialId;

   private String social;


   @Builder
   public User(String socialId, String email, String password, UserRole role, String username, String nickname, String social) {
      this.socialId = socialId;
      this.email = email;
      this.password = password;
      this.username = username;
      this.nickname = nickname;
      this.role = role == null ? UserRole.USER : role;
      this.social = social;
   }

   @Override
   public Collection<? extends GrantedAuthority> getAuthorities() {
      return null;
   }

   @Override
   public String getUsername() {
      return null;
   }

   @Override
   public boolean isAccountNonExpired() {
      return false;
   }

   @Override
   public boolean isAccountNonLocked() {
      return false;
   }

   @Override
   public boolean isCredentialsNonExpired() {
      return false;
   }

   @Override
   public boolean isEnabled() {
      return this.enabled;
   }
}

User 엔티티는 UserDetails 인터페이스를 구현한다.

Spring Security가 인증 객체로 User를 사용하려면 UserDetails 구현이 필요하기 때문이다.

각 필드의 역할은 다음과 같다.

  • socialId : OAuth Provider에서 제공하는 사용자 고유 식별 값
  • social : kakao, naver, google 등 로그인 제공자 구분
  • enabled : 계정 활성화 여부 (false면 로그인 차단 가능)




UserRole Enum

1
2
3
4
5
6
7
8
9
10
11
12
13
@Getter
@AllArgsConstructor
public enum UserRole {
   USER,
   ADMIN;

   public static UserRole of(String name) {
      for (UserRole role : values()) {
         if (role.name().contains(name)) return role;
      }
      throw new BadConstantException();
   }
}

사용자 권한을 Enum으로 관리한다.

of() 메소드는 JWT Claim에서 꺼낸 권한 문자열을 Enum 타입으로 변환할 때 사용된다.




UserMapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Component
@RequiredArgsConstructor
@Slf4j
public class UserMapper {

   public static User ofKakao(OAuth2User oAuth2User, String nickname) {
      var attributes = oAuth2User.getAttributes();
      Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
      Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");

      return User.builder()
         .socialId(String.valueOf(attributes.get("id")))
         .email((String) kakaoAccount.get("email"))
         .password("")
         .username((String) properties.get("nickname"))
         .nickname(nickname)
         .social("kakao")
         .build();
   }

   public static User of(OAuth2User oAuth2User, String nickname) {
      var authority = oAuth2User.getAuthorities();
      String auth = authority.toString().replace("[", "").replace("]", "");

      return User.builder()
         .password("")
         .nickname(nickname)
         .role(UserRole.of(auth))
         .build();
   }
}

OAuth Provider에서 전달받은 사용자 정보를 User 엔티티로 변환하는 역할을 한다.

카카오는 사용자 정보가 kakao_account, properties 등 중첩된 Map 구조로 내려오기 때문에

Provider마다 별도로 파싱 로직을 작성해야 한다.




UserDetailService / UserDetailServiceImpl

1
2
3
public interface UserDetailService extends UserDetailsService {
   UserDetails loadUserByUsername(String nickname) throws UserNotFoundException;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
@RequiredArgsConstructor
@Slf4j
public class UserDetailServiceImpl implements UserDetailService {

   private final UserRepository userRepository;

   @Override
   public UserDetails loadUserByUsername(String email) {
      log.info("[loadUserByUsername] email : {}", email);
      return userRepository.findByEmail(email)
         .orElseThrow(UserNotFoundException::new);
   }
}

JWT 토큰에서 추출한 email로 DB에서 사용자를 조회하는 역할을 한다.

Spring Security의 기본 메소드명이 loadUserByUsername이지만

실제로는 email 기준으로 조회하기 위해 오버라이드하여 구현했다.




UserRepository

1
2
3
public interface UserRepository extends JpaRepository<User, Long> {
   Optional<User> findByEmail(String email);
}




Config

SecurityConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

   private final JwtTokenProvider jwtTokenProvider;
   private final CustomOAuthService oAuthService;
   private final OAuth2SuccessHandler successHandler;
   private final Oauth2FailureHandler failureHandler;

   @Bean
   public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

      http.csrf().disable();

      http.authorizeRequests()
         .antMatchers("/css/**", "/login/**", "/oauth2/**").permitAll()
         .antMatchers("/admin/**").hasAuthority("ADMIN")
         .anyRequest().authenticated();

      http.oauth2Login()  // OAuth2 로그인 설정 시작점
         .userInfoEndpoint() // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때 설정 담당
         .userService(oAuthService)  // OAuth2 로그인 성공 시, 후작업을 진행할 UserService 인터페이스 구현체 등록
         .and()
         .successHandler(successHandler)
         .failureHandler(failureHandler);

      http.addFilterBefore(
         new JwtAuthenticationFilter(jwtTokenProvider),
         UsernamePasswordAuthenticationFilter.class
      );

      return http.build();
   }
}

FilterChain 구성과 인증 및 인가 정책을 정의한다.

addFilterBefore로 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 등록했다.

JWT 검증을 먼저 수행해야 이미 인증된 요청을 다시 form 로그인으로 처리하는 문제를 막을 수 있기 때문이다.


hasAuthorityhasRole은 둘 다 권한 기반 접근 제어이지만 차이가 있다.

hasRole은 내부적으로 ROLE_ 접두어를 자동으로 붙이기 때문에

hasRole을 사용할 경우 DB에도 ROLE_ADMIN 형태로 저장되어야 한다.

1
2
3
alter table user 
change column role 
role enum('ROLE_USER','ROLE_ADMIN');




PasswordEncoderConfiguration

1
2
3
4
5
6
7
8
@Configuration
public class PasswordEncoderConfiguration {

   @Bean
   public PasswordEncoder passwordEncoder() {
      return PasswordEncoderFactories.createDelegatingPasswordEncoder();
   }
}

PasswordEncoder를 SecurityConfig 안에 함께 선언하면

SecurityConfig가 PasswordEncoder를 참조하고 PasswordEncoder가 다시 SecurityConfig를 참조하는 순환 참조 문제가 발생할 수 있다.

이를 방지하기 위해 별도 클래스로 분리했다.





OAuth 흐름

사용자가 소셜 로그인을 완료하면, 내부적으로 다음 순서로 동작한다.

  1. AbstractAuthenticationProcessingFilter 에서 OAuth2 로그인 과정이 시작된다.
  2. OAuth2LoginAuthenticationFilter 가 인증을 수행하고, OAuth2AuthenticationToken이 생성된다.
  3. OAuth2LoginAuthenticationProvider 가 authenticate를 수행한다.
  4. OAuth2UserServiceloadUser가 호출된다. (기본 구현체는 DefaultOAuth2UserService)
  5. 커스텀 구현체인 CustomOAuthService 가 호출된다.

image

image




CustomOAuthService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuthService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

   private final UserRepository userRepository;
   private final PasswordEncoder passwordEncoder;

   @Override
   public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

      OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate =
         new DefaultOAuth2UserService();

      OAuth2User oAuth2User = delegate.loadUser(userRequest);

      // OAuth2 서비스 id (구글, 카카오, 네이버)
      String registrationId =
         userRequest.getClientRegistration().getRegistrationId(); // kakao

      // OAuth2 로그인 진행 시 키가 되는 필드 값(PK)
      String userNameAttributeName =
         userRequest.getClientRegistration()
            .getProviderDetails()
            .getUserInfoEndpoint()
            .getUserNameAttributeName();  // kakao는 id

      // SuccessHandler가 사용할 수 있도록 등록
      OAuth2Attribute attribute =
         OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

      User user = userRepository.findByEmail(attribute.getEmail())
         .orElseGet(() -> userRepository.save(UserMapper.ofKakao(oAuth2User, attribute.getName())));

      if (!user.isEnabled()) {
         throw new OAuth2AuthenticationException(new OAuth2Error("Not Found"));
      }

      // 로그인한 user를 return
      return new DefaultOAuth2User(
         Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())),
         attribute.convertToMap(), // {name=kakao에서 설정한 이름, id=xx, key=email, email=xx@kakao.com, picture=xx}
         "email"
      );
   }
}

OAuth Provider에서 전달받은 사용자 정보를 가공하고 DB에 사용자 정보가 없으면 신규 사용자로 저장한다.

DefaultOAuth2UserService만 사용하면 사용자 정보를 가져오는 것까지만 처리된다.

DB 저장, 신규/기존 사용자 분기, 계정 상태 확인 등 추가 처리가 필요하기 때문에 커스텀 구현체를 작성하였다.




OAuth2Attribute

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Builder(access = AccessLevel.PRIVATE)
@Getter
public class OAuth2Attribute {

   private Map<String, Object> attributes;  // OAuth2 반환하는 유저 정보 Map
   private String attributeKey;
   private String email;
   private String name;
   private String picture;

   static OAuth2Attribute of(String provider, String attributeKey, Map<String, Object> attributes) {
      switch (provider) {
         case "google":
            return ofGoogle(attributeKey, attributes);
         case "kakao":
            return ofKakao(attributeKey, attributes);
         case "naver":
            return ofNaver(attributeKey, attributes);
         default:
            throw new RuntimeException();
      }
   }

   Map<String, Object> convertToMap() {
      Map<String, Object> map = new HashMap<>();
      map.put("key", attributeKey);
      map.put("email", email);
      map.put("name", name);
      map.put("picture", picture);
      return map;
   }
}

OAuth Provider별로 사용자 정보의 구조가 다르기 때문에 Provider마다 파싱 방식이 달라진다.

Spring Boot는 Google과 Facebook만 기본 지원하기 때문에 Kakao와 Naver는 이처럼 직접 구현이 필요하다.

convertToMap()은 Provider별로 다른 구조를 하나의 통일된 Map으로 변환하여

이후 코드에서 Provider에 관계없이 동일하게 사용할 수 있도록 한다.




OAuth2SuccessHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {

   private final JwtTokenProvider jwtProvider;

   @Value("${oauth.redirection.url}")
   private String REDIRECTION_URL;

   @Override
   public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
      throws IOException {

      OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
      String email = (String) oAuth2User.getAttributes().get("email");
      String token = jwtProvider.createToken(User.builder().email(email).build());

      response.sendRedirect(REDIRECTION_URL + "?token=" + token);
   }
}

OAuth2 로그인 성공 후 JWT를 생성하고, 클라이언트로 redirect한다.

JWT를 QueryString(?token=...)에 담아 전달하는 이유는

redirect 응답에서는 Header에 값을 담기 어렵기 때문이다.




JwtAuthenticationFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

   private final JwtTokenProvider jwtTokenProvider;
   private final String BEARER = "Bearer ";

   @Override
   protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {

      String token = request.getHeader(HttpHeaders.AUTHORIZATION);

      if (token != null && token.startsWith(BEARER)) {
         token = token.substring(BEARER.length());
         if (jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
         }
      }

      filterChain.doFilter(request, response);
   }
}

매 요청마다 Authorization Header에서 JWT 토큰을 꺼내 유효성을 검사한다.

OncePerRequestFilter를 상속받아 같은 요청에서 필터가 중복 실행되지 않도록 보장한다.

토큰이 유효하면 Authentication 객체를 SecurityContext에 저장하고 이후 요청은 인증된 사용자로 처리된다.





reference
Kakao developers REST API docs
[Spring Boot] OAuth2 + JWT + React 적용해보리기
Spring Security 와 OAuth 2.0 와 JWT 의 콜라보
Spring Security - UsernamePasswordAuthenticationFilter 란

This post is licensed under CC BY 4.0 by the author.