Spring Security 가이드 (with. Spring boot 3.0) - 스프링 시큐리티란, 동작 과정, 사용 방법, JWT 발급
개발환경은 Spring Boot 3.0.x + Spring Security 6.x 입니다
토이 프로젝트에서 로그인-회원가입 기능을 개발해야하는데 "스프링 시큐리티"를 적용할까 말까 하다가, 이왕 할때 해보자라는 생각으로 "스프링 시큐리티를 적용"해보고자 합니다.
솔직히 프레임워크에 대해서 깊게 공부하고 싶지는 않으니까 "간단, 간단 겉핥기 식 - 기능 구현" 에 초점을 맞춰 정리해볼려구 합니다!
👏🏻 목표는 1. 로그인 성공시 JWT 토큰 반환 + 2. 생성한 User Entity 기반의 회원가입입니다.
[목차]
- 스프링 시큐리티란
- 스프링 시큐리티 동작과정
- 스프링 시큐리티 사용 가이드
- JWT 사용해서 인증-인가 해주기 (custom 한 jwt filter 생성)
1. Spring Security 란
Spring Security 란 Spring 에서 제공해주는 인증(Authentication) 과 인가(Authorization) 에 대한 처리를 위임하는 별도의 프레임워크 입니다.
따라서 일종의 프레임워크에 지나지 않기때문에, 인증-인가를 구현하는데 있어 필수적인 것은 아닙니다.
Spring Security는 '인증'과 '인가'에 대한 부분을 Filter 흐름에 따라 처리합니다. Filter는 Dispatcher Servlet으로 가기 전에 적용되므로 가장 먼저 URL 요청을 받습니다. Spring Security는 보안과 관련해서 체계적으로 많은 옵션을 제공해주기 때문에 개발자 입장에서는 일일이 보안관련 로직을 작성하지 않아도 된다는 장점이 존재합니다.
2. Spring Security 동작 과정
스프링 인증관련 아키텍쳐 구조 그림입니다.
간단하게 스프링 시큐리티 프레임워크가 "인증" 과 "인가"를 어떻게 처리해주는지 흐름을 이해해보고자 합니다.
위에서도 말했다싶이 Spring Security 는 요청이 Dispatcher Servlet 으로 가기 전에 Filter 단에서 요청을 가로채 "인증" 과 "인가"에 대한 처리를 해줍니다.
따라서 이런 전체적인 흐름이 되어지겠져
여기서 Client Http Request 와 Spring Security 간의 흐름에대해서 살펴보겠습니다.
- 사용자가 로그인 정보(id, password) 로 로그인을(인증) 요청
- AuthenticationFilter가 (1) 의 정보를 인터셉트하여 UsernamePasswordAuthentication Token (Authentication 객체) 을 생성하여 AuthenticationManager에게 Authentication 객체를 전달
- AuthenticationManager 인터페이스를 거쳐 AuthenticationProvider에게 (2) 의 정보 전달 (Authentication 형태), 등록된 AuthenticationProvider(들)을 조회하여 인증을 요구
- AuthenticationProvider 는 UserDetailsService를 통해 입력받은 (3) 의 사용자의 정보를 DB에서 조회
- supports() 메소드를 통해 실행 가능한지 체크
- authenticate() 메소드를 통해 DB에 저장된 이용자 정보와 입력한 로그인 정보 비교
- DB 이용자 정보: UserDetailsService의 loadUserByUsername() 메소드를 통해 불러옴
- 입력 로그인 정보: (3) 에서 받았던 Authentication 객체 (UsernameAuthentication Token)
- 👉 일치하는 경우 Authentication 반환
- QuthenticationManager 는 Authentication 객체를 AuthenticationFitler로 전달
- AuthenticationFilter는 전달받은 Authentication 객체를 LoginSuccessHandler 로 전송하고, SecurityContextHolder에 담음
- 성공 시 AuthenticationSuccessHandle,
실패 시 AuthenticationFailureHandle 실행
3. Spring Security 적용 및 사용하기
간단하게 사용! 에 초첨을 두었습니다.
1) build.gradle 의존성 추가
//Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
// 현재 프로젝트에서는 Rest API 규약으로 개발하여 Thymeleaf 문법을 사용하지 않기때문에 저는 적용해 주지 않았습니다.
//implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.1.RELEASE'
2) Config
- WebSecurityConfigurerAdapter 는 deprecated 되었습니다.(현재 사용버전 Spring Security 6.0.0) 따라서 FilterChain 의 역할을 하는 메소드를 직접 구현하여 Bean 으로 등록해 주어야합니다.
- 참고 : Spring Security Sample
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf().disable()
.formLogin().disable();
return httpSecurity
.authorizeHttpRequests(
authorize -> authorize
.requestMatchers("/users/**").permitAll()
.requestMatchers("/login").permitAll()
.anyRequest().authenticated()
)
//.httpBasic(Customizer.withDefaults()) Jwt 사용할 rest api 방식이면 해줄필요없음
.build();
}
}
해당 프로젝트 에서는 Rest API 의 형식으로 Json 으로만 데이터를 주고 받는 Stateless 한 통신방식을 사용할 예정이기 때문 formLogin과 csrf 설정이 불필요해 disable() 설정 해두었습니다.
이렇게만 설정해주어도 서버에 들어오는 모든 요청에대해 인증처리를 해줍니다.
4. Spring Security + JWT 사용하기
인증에대한 처리를 완료하였으니 이제 인가에 대한 처리를 해봅시다
저는 Json Web Token 을 사용하여 Client 와 Stateless 하면서도 보안적인 부분을 챙기고자 했습니다.
JWT 인증 - 인가와 관련된 포스팅들,, 혹시 해당 개념을 잘 모르신다면 한번 보고가셔도 좋을거 같아요
- 🥳 Spring Jwt Refresh Token - 인증 인가의 흐름
- 😃 [Spring] jwt란 - jwt 내부구조, 동작과정, 스프링에서 파싱하기
- 🤔 [Spring] Spring JWT 인코딩, 디코딩 하기 - Java Json 파싱
jwt gradle build - jjwt 라이브러리를 사용하였습니다.
//JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
유저가 회원가입을 하거나, 로그인을 하였을 때 stateless(무상태성) 하게 유저를 인증하기위한 방법 중 하나가 Token 입니다.
따라서 이런 유저가 인증을 이미 했다!를 증명하기 위한 수단을 JWT(Json Web Token) 을 응답값으로 보내주면 인증-인가의 하나의 흐름이 완성되는 것입니다.
1) JWT 발급하기
Spring(with. Java) 에서 JWT 를 생성해주는 방법입니다. 간단하게 어떻게 생성해주는지만 적어두겠습니다.
토큰에는 크게 3가지 정보가 담겨야한다고 생각합니다. (저만의 생각입니다.)
- 어떤 정보를 담을건지
- 얼마나 유효한지
- 어떻게 암호화 할건지
이에 대해서 자세하게 알고싶으시다면 JWT 의 3 부분 (Header, Payload, Signature) 으로 이루어진 구조에대해 검색해보세요!
@Component
@Slf4j
public class TokenProvider {
private static final long ACCESS_TOKEN_VALID_PERIOD = 1000L * 60 * 60 * 24 * 8; //8일
private final Key jwtSecretKey;
public TokenProvider(@Value("${jwt.secret-key}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
jwtSecretKey = Keys.hmacShaKeyFor(keyBytes);
}
public TokenResponse generateJWT(final User userInfo) {
final Date now = new Date();
final Date accessTokenExpireIn = new Date(now.getTime() + ACCESS_TOKEN_VALID_PERIOD);
final String accessToken = Jwts.builder()
.setSubject("authorization") // 토큰 용도
.claim("userUuid", userInfo.getUserUuid()) // Claims 설정
.claim("role", userInfo.getUserRole())
.setExpiration(accessTokenExpireIn) // 토큰 만료 시간 설정
.signWith(jwtSecretKey, SignatureAlgorithm.HS256) // HS256과 Key로 Sign
.compact(); // 토큰 생성
return TokenResponse.of(accessToken,accessTokenExpireIn.getTime());
}
}
[참고사항]
- ${jwt.secret-key}는 jwt 의 signature 부분을 암호화할 개인 시크릿키를 설정해주어야하기 때문에 별도의 yaml 파일에 저장된 값을 불러오는 과정입니다.
- 변수 자체에 @Value 로 어플리케이션이 실행될 때 값을 주입해주어도 되지만, 권장되는 방식은 아니며 테스트코드또한 작성하기 힘들어 생성자로 주입하는 방식을 선택했습니다.
- 토큰의 암호화 복호화를 위한 secret key로서 이후 HS256 알고리즘을 사용하기 위해, 256비트보다 커야합니다.
알파벳은 한단어 당 8bit 이므로 32글자 이상이면 됩니다.
- setSubject 는 jwt 의 목적같은걸 적어주시면 됩니다. 굳이 설정해주지 않아도 될것같다는 개인적인 생각입니다.
- claim 은 payload 에 들어갈 내용입니다. key - value 의 형식으로 지정해주면 됩니다. 따럿 Map<> 의 형태로 한번에 주입도 가능합니다.
- signWith 는 HS256 으로 인토딩하고 secretkey 를 이용한다는 의미입니다. 해당 signature 로 유효한 토큰인지를 판별합니다.
2) Spring Security에서의 Auth Filter 알아보기
위의 흐름에서 말했다시피, 서버에 들어오는 요청을 낚아채서 유요한 토큰을 가지고있는 인증된 요청인지를 판별해야합니다.
SpringSecurity는 기본적으로 순서가 있는 Security Filter 들을 제공하고, Spring Security가 제공하는 Filter 를 구현한게 아니라면 필터의 순서를 지정해 주어야 합니다.
👉 Spring Security 의 인증-인가 부분은은 책임 연쇄 패턴의 구조를 띄우고 있어 해당 Filter들을 차례대로 하나씩 거쳐가면서 사용자의 요청을 인증해 나가는 방식입니다. (책임연쇄패턴이란)
따라서, 모든 인증을 통과할 필요는 없으면 특정 커스텀하게 설정한 Filter 를 연쇄적으로 연결되어있는 체인의 중간에 삽입할 수 도있습니다.
📌 즉, 위에서 발급해주었던 Json Web Token 이 유효하면 Security 하다는 인증절차를 검증해줄 수 있는 저희만의 커스텀한 Filter 를 만들어 "UsernamePasswordAuthenticationFilter" 앞에서 해당 요청에 대한 인증 책임 절차를 진행할 것입니다.
📗 UsernamePasswordAuthenticationFilter 란
ID와 Password를 사용하는 실제 Form 기반 유저 인증을 처리하는 역할을 합니다.
- 인증 객체를 만들어서 Authentication 객체를 만들어 아이디 패스워드를 저장하고, AuthenticationManager에게 인증처리를 맡깁니다.
- Authentication 이란 - Authentication객체는 인증 시 id 와 password 를 담고 인증 검증을 위해 전달되어 사용됩니다.
- 인증 후 최종 인증 결과 (user 객체, 권한정보) 를 담고 SecurityContext 에 저장되어 전역적으로 참조가 가능합니다.
- 사용자의 인증 정보를 저장하는 토큰 개념입니다.
- Authentication 이란 - Authentication객체는 인증 시 id 와 password 를 담고 인증 검증을 위해 전달되어 사용됩니다.
- AuthenticationManager가 실질적인 인증을 검증 단계를 총괄하는 클래스인 AuthenticationProvider에게 인증 처리를 위임합니다. 그럼 AuthenticationProvider가 UserDetailsService와 같은 서비스를 사용해서 인증을 검증합니다.
- 최종적으로 인증을 성공한 경우, 인증에 성공한 결과를 담은 인증객체(Authentication)를 생성한 다음에 SecurityContext에 저장합니다.
↓
각 Filter 들의 세부적인 책임은 (여기) 를 참고
3) JWT Filter 설정하기
⚙️ Spring_Security_Configure.Class
- filter 의 코드 작성은 아래에서 보고 먼저 Custom 하게 제작한 Filter 를 UsernamePasswordAuthenticationFilter 전에 위치를 시켜야겠죠
- Security Config 를 작성한 메서드에 addFilterBefore(A filter, B filter) 를 이용합니다.
- B 체인 전에 A 체인을 추가하는 역할을 하는 메소드입니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtFilter jwtFilter;
private static final String[] PERMIT_URL_ARRAY = {
/* swagger v2 */
"/v2/api-docs",
"/swagger-resources",
"/swagger-resources/**",
"/configuration/ui",
"/configuration/security",
"/swagger-ui.html",
"/webjars/**",
/* swagger v3 */
"/v3/api-docs/**",
"/swagger-ui/**",
"docs/**",
"/", // root 와 현결해놓음
/* 회원가입 */
"/users/join/**",
"/login",
"/error". //이거 꼭 추가 추천
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf().disable();
return httpSecurity
.authorizeHttpRequests(
authorize -> authorize
.requestMatchers(PERMIT_URL_ARRAY).permitAll()
.requestMatchers(HttpMethod.GET, "/ideal-types").permitAll()
.requestMatchers(HttpMethod.GET, "/interests").permitAll()
.anyRequest().authenticated()
.and()
//여기
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
)
//.httpBasic(Customizer.withDefaults()) 토큰 사용한다면 없어도 됨
.build();
}
}
📌 이렇게하면 클라이언트 요청 시 JWT 인증을 하기 위해 제작한 커스텀 필터안 jwtFilter가 UsernamePasswordAuthenticationFilter 이전에 실행됩니다.
- 이전에 실행된다는 뜻은 jwtFilter를 통과하면 UsernamePasswordAuthenticationFilter 이후의 필터는 통과한 것으로 본다는 뜻 입니다.
- 쉽게 말해서 LoginForm 에서 진행되던 Username + Password를 통한 인증을 Jwt를 통해 수행한다는 것 입니다.
⚙️ jwt_custom_filter.Class
- Spring Security 인증 과정에 본인이 만든 필터를 끼워넣기 위해서는 Filter 타입의 객체를 인자로 주입해야합니다.
- 보통 구글링을 하면 JWT Filter를 만들 때 GenericFilterBean 을 상속받은 Filter 를 만들거나, 그 하위 클래스를 상속받아야한다고 말하는데, GenericFilterBean 이 Filter를 상속받고 있습니다.
저는 GenericFilterBean 을 상속받는 수많은 하위 구현체 중에 OncePerRequestFilter 를 상속받아 Custom 한 JWTFilter 를 만들어볼려 합니다.
💡 OncePerRequestFilter 를 사용한 이유
OncePerRequestFilte란 모든 서블릿에 일관된 요청을 처리하기 위해 만들어진 필터입니다.
이 추상 클래스를 구현한 필터는 사용자의 한번에 요청 당 딱 한번만 실행되는 필터를 만들 수 있습니다.
이러한 기능을 구현한 추상클래스를 상속받는 이유는 ↓
서버는 클라이언트로 부터 요청을 받으면 서블릿을 생성해 요청을 처리합니다.
Filter 는 DispatcherServlet 앞에서 동작하고 서블릿에서 다른 url 로 Redirect 를 해서 다른 서블릿으로 요청하는 디스패치가 일어난다면, 이미 인증한 요청의 큰 흐름에 대해 1번도 인증-인가의 필터를 거치게됩니다. 이러한 이유로 Spring Security 에서 1개의 요청에 1번의 인증-인가 처리를 진행하는 효율적인 리소스관리를 위해서 OncePerRequestFilter 를 사용해 주었습니다.
참고 : https://minkukjo.github.io/framework/2020/12/18/Spring-142
4) 최종 코드
- 👏🏻 이제 진짜 코드를 봅시다. 시나리오는 다음과 같습니다.
- 들어오는 요청을 filter 에서 낚아채 HTTP Header 의 Authorization 으로 들어온 Bearer Token 을 조회합니다.
- 조회한 Token 에 대한 유효성 검사를 진행합니다.
- 마지막으로 Token 이 유효하다면, 필요한 정보를 담아 Authentication 객체를 생성합니다.
- 그런다음 Authentication 객체를 SecurityContextHolder 에 담으면, 그 다음 인증 시 Spring Security Filter Chain 중 가장 앞 단에 있는 SecurityContextPersistenceFilter에서 인증을 시도한 사용자가 이전에 세션에 저장한 이력이 있는지 확인합니다.
- 만약 인증 이력이 존재한다면 SecurityContext를 꺼내와서 SecurityContextHolder 에 저장합니다 따라서, SecurityContext 를 따로 생성하지 않습니다.
- 이렇게 됨으로써 → jwt 로 인증한 후, 현재 로그인한 유저정보를 조회할 때 다시 인증절차를 거칠필요가 없어지겠죠?
- 모든 작업이 마쳐서 최종적으로 클라이언트에게 인증하기 직전에는 항상 Clear SecurityContext가 실행된다고 합니다.
⚙️ JwtFilter.class
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(final HttpServletRequest request,
final HttpServletResponse response, final FilterChain filterChain)
throws ServletException, IOException {
final String token = getParseJwt(request.getHeader("Authorization"));
if (token != null && tokenProvider.validateToken(token)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication auth = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
private String getParseJwt(final String headerAuth) {
if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer")) {
return headerAuth.substring(7);
}
return null;
}
}
⚙️ TokenProvider.class
@Component
@Slf4j
public class TokenProvider {
private static final long ACCESS_TOKEN_VALID_PERIOD = 1000L * 60 * 60 * 24 * 8;
private final Key jwtSecretKey;
private final UserService userService;
public TokenProvider(@Value("${jwt.secret-key}") String secretKey,
final UserService userService) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
jwtSecretKey = Keys.hmacShaKeyFor(keyBytes);
this.userService = userService;
}
public TokenResponse generateJWT(final User userInfo) {
final Date now = new Date();
final Date accessTokenExpireIn = new Date(now.getTime() + ACCESS_TOKEN_VALID_PERIOD);
final String accessToken = Jwts.builder()
.setSubject("authorization")
.claim("userUuid", userInfo.getUserUuid())
.claim("role", userInfo.getUserRole())
.setExpiration(accessTokenExpireIn)
.signWith(jwtSecretKey, SignatureAlgorithm.HS256)
.compact();
return TokenResponse.of(accessToken,accessTokenExpireIn.getTime());
}
public boolean validateToken(final String token) {
try {
Jwts.parserBuilder().setSigningKey(jwtSecretKey).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
LogWriteUtils.logInfo(String.format("exception : %s, message : 잘못된 JWT 서명입니다.", e.getClass().getName()));
} catch (ExpiredJwtException e) {
LogWriteUtils.logInfo(String.format("exception : %s, message : 만료된 JWT 토큰입니다.", e.getClass().getName()));
} catch (UnsupportedJwtException e) {
LogWriteUtils.logInfo(String.format("exception : %s, message : 지원되지 않는 JWT 토큰입니다.", e.getClass().getName()));
} catch (IllegalArgumentException e) {
LogWriteUtils.logInfo(String.format("exception : %s, message : JWT 토큰이 잘못되었습니다.", e.getClass().getName()));
}
return false;
}
public Claims parseClaims(final String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(jwtSecretKey).build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
public Authentication getAuthentication(final String token) {
// 토큰 복호화
Claims claims = parseClaims(token);
LogWriteUtils.logInfo("token_claims : " + claims.toString());
if (claims.get("role") == null) {
throw new BadCredentialsException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
final Collection<? extends GrantedAuthority> authorities = Stream.of(
claims.get("role").toString())
.map(SimpleGrantedAuthority::new)
.toList();
final String userUuid = claims.get("userUuid").toString();
//token 에 담긴 정보에 맵핑되는 User 정보 디비에서 조회
final User user = userService.findByUserUuidForAuthToken(userUuid);
//Authentication 객체 생성
return new UsernamePasswordAuthenticationToken(user, userUuid, authorities);
}
}
성공적인 응답 결과값!
+
마지막으로 다시한번 스프링 시큐리티의 흐름에대해 정리해보고 마무리 하겠습니다..
사실 매우 간단하게 빠른 기능구현 + 필요한 부분에 이유있는 코딩을 하고싶었기 때문에 빼먹은게 많습니다. (다하고 다시 보는와중에 깨달았지만요..ㅎㅎ)
- 사용자가 로그인 정보(id, password) 로 로그인을(인증) 요청
- AuthenticationFilter가 (1) 의 정보를 인터셉트하여 UsernamePasswordAuthentication Token (Authentication 객체) 을 생성하여 AuthenticationManager에게 Authentication 객체를 전달
- AuthenticationManager 인터페이스를 거쳐 AuthenticationProvider에게 (2) 의 정보 전달 (Authentication 객체), 등록된 AuthenticationProvider(들)을 조회하여 인증을 요구
- AuthenticationProvider 는 UserDetailsService를 통해 입력받은 (3) 의 사용자의 정보를 DB에서 조회
- supports() 메소드를 통해 실행 가능한지 체크
- authenticate() 메소드를 통해 DB에 저장된 이용자 정보와 입력한 로그인 정보 비교
- DB 이용자 정보: UserDetailsService의 loadUserByUsername() 메소드를 통해 불러옴
- 입력 로그인 정보: (3) 에서 받았던 Authentication 객체 (UsernameAuthentication Token)
- 👉 일치하는 경우 Authentication 반환
- QuthenticationManager 는 Authentication 객체를 AuthenticationFitler로 전달
- AuthenticationFilter는 전달받은 Authentication 객체를 LoginSuccessHandler 로 전송하고, SecurityContextHolder에 담음
- 성공 시 AuthenticationSuccessHandle,
실패 시 AuthenticationFailureHandle 실행
➡️ 이게 정상적인 흐름이지만, 해당 예제에서는 jwtFilter 에서 AuthenticationFilter 역할인 유효성 검사까지하고, AuthenticationManager 인터페이스를 통하지않고 바로 Provider 객체를 직접 구현하여 Authentication 객체를 반환했습니다...
이렇게 간단하게(간단한가..?) Spring Boot 3.0 + Spring Security 6.x + JWT 를 이용한 인증-인가 절차에 대해서 알아보았습니다.
이게 정확하고 효율적으로 리소스를 사용해가는 인증 절차 코드를 짠건지는 솔직히 자신이 없습니다.
잘못된 내용이나, 더 좋고 올바른 방향이 있다면 댓글 남겨주시면 정말 감사드리겠습니다 🙏
아우! 기빨리네요 간단하게 할려고해도 어렵다 Spring Security !!
끝 !
====================== 🫡 Spring Security 시리즈======================
- 👉 Spring Security 가이드 (with. Spring boot 3.0) - 스프링 시큐리티란, 동작 과정, 사용 방법, JWT 발급
- ✔️ Spring Security Exception Handling - Filter 단 예외 처리하기
- ✔️ [Spring Security] 존재하지 않는 API 호출 시 404 대신 401 or 403 을 반환할 때
- ✔️ @AuthenticationPrincipal 유닛 테스트 - Custom Mock User 삽입하기
참고
- Spring 공식문서 튜토리얼 : https://spring.io/guides/gs/securing-web/
- Spring 공식 팀 github guide - MVC Security : https://github.com/spring-projects/spring-security-samples/tree/main/servlet/java-configuration/hello-mvc-security
- 연로그 : https://yeonyeon.tistory.com/204
- 망나니 개발자 : https://mangkyu.tistory.com/77
- Spring Security 와 JWT : https://velog.io/@haden/%EC%8A%A4%ED%94%84%EB%A7%81-7-Spring-Security%EC%99%80-JWT#jwt--json-web-token
- Spring Security Filter Chain 흐름 및 각각의 역할 : https://gngsn.tistory.com/160