1. 서론
Spring security의 SecurityContext 객체는 Authentication 객체(사용자 인증 정보)를 가지고 있으며, 이 사용자 인증 정보는 Authentication 객체 안에 UserDetails 또는 OAuth2User(소셜 로그인 시) 객체로 저장된다. 그렇다면 Spring security는 어떠한 내부 프로세스를 가지고 사용자 인증을 진행하는 걸까? 아울러 이는 어떻게 JWT와 연계되는가? 로그인과, 로그인 이후 진행되는 프로세스를 프로젝트 코드와 함께 살펴보자.
2. 로그인 시(소셜X)
AuthorizingController.java
...
@Log4j2
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthorizingController {
private final UrcarcherOAuth2Service urcarcherOAuth2Service;
private final VanillaAuthorizingService vanillaAuthorizingService;
private final JwtCookieProvider jwtCookieProvider;
private final JwtTokenProvider jwtTokenProvider;
@PostMapping("/login")
public ResponseEntity<TokenDTO> login(@RequestBody LoginRequestDTO loginRequestDTO, HttpServletResponse response) {
String memberId = loginRequestDTO.getMemberId();
String password = loginRequestDTO.getPassword();
String on = loginRequestDTO.getAgree(); // 인증 정보를 기억하는지 여부 체크
TokenDTO tokenDTO = vanillaAuthorizingService.loginChk(memberId, password);
// 인증 정보 기억 체크 후 로그인을 했다면 Cookie 형태로 Refresh 토큰 발급, 아닌 경우는 Access Token만 발급
if(on != null) response.addCookie(jwtCookieProvider.createCookieForRefreshToken(tokenDTO.getRefreshToken()));
return ResponseEntity.ok(tokenDTO);
}
...
}
먼저 프론트 단에서 "/api/auth/login" uri로 백엔드에 request를 보내면, 상기된 Controller의 login 메서드와 Mapping된다. 프론트에서 전달한 아이디와 비밀번호는 @RequestBody Annotation에 의해 자바 객체로 변환되며, 이것이 유효한 사용자 정보인지 Service 단에서 인증을 진행하게 된다.
VanillaAuthorizingService.java
...
@Service
@RequiredArgsConstructor
public class VanillaAuthorizingService implements UserDetailsService {
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Override
public AuthorizedUser loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findById(username)
.map(this::createUserDetails)
.orElseThrow(()-> new UsernameNotFoundException("존재하지 않는 회원입니다."));
}
private AuthorizedUser createUserDetails(Member member) {
return AuthorizedUser.builder()
.member(member)
.build();
}
@Transactional
public TokenDTO loginChk(String memberId, String password) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberId, password);
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
TokenDTO tokenDTO = jwtTokenProvider.generateToken(authentication);
return tokenDTO;
}
...
}
loginChk() 메서드를 주목하자. 첫 줄은 다음과 같다.
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberId, password);
먼저 등장하는 class인 UsernamePasswordAuthenticationToken부터 알아보자. UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받으며, AbstractAuthenticationToken는 Authentication을 상속받는다. 결과적으로 UsernamePasswordAuthenticationToken은 Authentication을 상속받는다는 얘기이다. 즉, UsernamePasswordAuthenticationToken은 인증을 받기 전 Authentication 객체라고 보아도 무방하다.
...
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
...
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}
...
public static UsernamePasswordAuthenticationToken authenticated(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
}
...
}
UsernamePasswordAuthenticationToken의 생성자를 각각 보면 setAuthenticated 메서드가 실행되는 것을 알 수 있다(인증 여부). 이는 인증이 완료된 후 추후 인증된 생성자로 Authentication 객체를 만들 수 있다는 것을 의미한다(authenticated() 메서드로 새로이 인증된 Authentication 객체를 만든다).
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
private final AuthenticationManagerBuilder authenticationManagerBuilder; 로 주입받은 AuthenticationManagerBuilder 객체는 Spring boot의 Auto configuration을 통해 AuthenticationManager 객체를 손쉽게 호출할 수 있게 한다.(Dependency Injection) 이때, AuthenticationManager는 기본적으로 Interface인데, 정확히는 이것의 구현체인 ProviderManager 객체를 불러오게 된다. 따라서 authenticationManagerBuilder.getObject()는 결국 ProviderManager 객체를 의미하며, 인증을 위해 ProviderManager의 authenticate() 메서드를 실행하는 것이다.
...
public class AuthenticationManagerBuilder
extends AbstractConfiguredSecurityBuilder<AuthenticationManager, AuthenticationManagerBuilder>
implements ProviderManagerBuilder<AuthenticationManagerBuilder> {
...
@Override
protected ProviderManager performBuild() throws Exception {
if (!isConfigured()) {
this.logger.debug("No authenticationProviders and no parentAuthenticationManager defined. Returning null.");
return null;
}
ProviderManager providerManager = new ProviderManager(this.authenticationProviders,
this.parentAuthenticationManager);
if (this.eraseCredentials != null) {
providerManager.setEraseCredentialsAfterAuthentication(this.eraseCredentials);
}
if (this.eventPublisher != null) {
providerManager.setAuthenticationEventPublisher(this.eventPublisher);
}
providerManager = postProcess(providerManager);
return providerManager;
}
...
AuthenticationManagerBuilder는 AbstractConfiguredSecurityBuilder<A, B>와 같은 형태의 추상 클래스를 상속받고, 또 이 추상 클래스는 AbstractSecurityBuilder<A>인 추상 클래스를 상속받는다. AbstractSecurityBuilder가 가지고 있는 getObject()는 A를 return하는 final 메서드이므로 결국 위 코드에서 A에 해당하는 AuthenticationManager를 return하는 것이다. 이때 내가 궁금했던 점은 코드 상에서 AuthenticationManager에 ProviderManager가 주입되는 시점이었는데, 코드를 뜯어가며 살펴보았지만 아직 의문점을 해소하지 못했다. 미약하게나마 추측해봤을 때, overriding된 performBuild()를 통해 ProviderManager가 주입되는 것 같다. 하지만 정확한 이해가 동반되지 못했으므로 일단 개인적인 숙제로 남겨두려 한다.
[Spring boot] OAuth 2.0(Google) & JWT를 활용한 인증/로그인 구현 (3)에서 Spring security의 동작 흐름을 보면, FilterChainProxy에 SecurityFilterChain를 넘겨주는 과정에서 WebSecurityConfiguration는 HttpSecurity Bean을 주입시킨다. 이 때, HttpSecurity는 HttpSecurityConfiguration에 의해 Spring bean으로 정의된다.
...
@Configuration(proxyBeanMethods = false)
class HttpSecurityConfiguration {
...
private AuthenticationConfiguration authenticationConfiguration;
...
@Autowired
void setAuthenticationConfiguration(AuthenticationConfiguration authenticationConfiguration) {
this.authenticationConfiguration = authenticationConfiguration;
}
...
@Bean(HTTPSECURITY_BEAN_NAME)
@Scope("prototype")
HttpSecurity httpSecurity() throws Exception {
...
authenticationBuilder.parentAuthenticationManager(authenticationManager());
...
}
...
private AuthenticationManager authenticationManager() throws Exception {
return this.authenticationConfiguration.getAuthenticationManager();
}
...
여기서 httpSecurity()의 authenticationBuilder.parentAuthenticationManager(authenticationManager()); 문장을 보면, authenticationManager() 메서드가 실행이 되며, 이는 setter DI된 AuthenticationConfiguration의 getAuthenticationManager()를 반환한다.
...
@Configuration(proxyBeanMethods = false)
@Import(ObjectPostProcessorConfiguration.class)
public class AuthenticationConfiguration {
...
@Bean
public AuthenticationManagerBuilder authenticationManagerBuilder(ObjectPostProcessor<Object> objectPostProcessor,
ApplicationContext context) {
LazyPasswordEncoder defaultPasswordEncoder = new LazyPasswordEncoder(context);
AuthenticationEventPublisher authenticationEventPublisher = getAuthenticationEventPublisher(context);
DefaultPasswordEncoderAuthenticationManagerBuilder result = new DefaultPasswordEncoderAuthenticationManagerBuilder(
objectPostProcessor, defaultPasswordEncoder);
if (authenticationEventPublisher != null) {
result.authenticationEventPublisher(authenticationEventPublisher);
}
return result;
}
...
public AuthenticationManager getAuthenticationManager() throws Exception {
if (this.authenticationManagerInitialized) {
return this.authenticationManager;
}
AuthenticationManagerBuilder authBuilder = this.applicationContext.getBean(AuthenticationManagerBuilder.class);
if (this.buildingAuthenticationManager.getAndSet(true)) {
return new AuthenticationManagerDelegator(authBuilder);
}
for (GlobalAuthenticationConfigurerAdapter config : this.globalAuthConfigurers) {
authBuilder.apply(config);
}
this.authenticationManager = authBuilder.build();
if (this.authenticationManager == null) {
this.authenticationManager = getAuthenticationManagerBean();
}
this.authenticationManagerInitialized = true;
return this.authenticationManager;
}
...
AuthenticationConfiguration은 AuthenticationManagerBuilder Bean을 생성한다(default인 싱글톤 방식으로 정의됨). 그리고 getAuthenticationManager() 메서드는 AuthenticationManagerBuilder Bean을 가지고 와서 build() 시키고 AuthenticationManager 객체를 반환한다.
여기서 우리가 주목할 점은 Spring Container에 존재하는 AuthenticationManagerBuilder Bean이 build() 된다는 사실이다.
...
public abstract class AbstractSecurityBuilder<O> implements SecurityBuilder<O> {
private AtomicBoolean building = new AtomicBoolean();
private O object;
@Override
public final O build() throws Exception {
if (this.building.compareAndSet(false, true)) {
this.object = doBuild();
return this.object;
}
throw new AlreadyBuiltException("This object has already been built");
}
public final O getObject() {
if (!this.building.get()) {
throw new IllegalStateException("This object has not been built");
}
return this.object;
}
}
AbstractSecurityBuilder를 상속받는 AuthenticationManagerBuilder가 build() 메서드를 실행하면, AbstractSecurityBuilder인 부모 필드 object 변수에 doBuild()가 저장되고, doBuild()는 AuthenticationManagerBuilder의 performBuild() 메서드를 실행시키고 그 결과값(ProviderManager)을 반환한다(5. AbstractConfiguredSecurityBuilder & AbstractSecurityBuilder 참고). 따라서 object 변수에 ProviderManager가 저장된다.
AuthenticationManagerBuilder은 싱글톤 bean으로 정의되었고, HttpSecurity가 bean으로 생성되는 과정에서 AuthenticationManagerBuilder가 build되므로, VanillaAuthorizingService.java에서 bean 주입시킨 AuthenticationManagerBuilder 객체는 부모 필드(AbstractSecurityBuilder) object 변수에 ProviderManager를 가지고 있다. 그러므로 authenticationManagerBuilder.getObject()가 ProviderManager 객체를 반환할 수 있는 것이다.
...
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
for (AuthenticationProvider provider : getProviders()) { // 적합한 AuthenticationProvider 찾기
if (!provider.supports(toTest)) {
continue;
}
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
provider.getClass().getSimpleName(), ++currentPosition, size));
}
try {
// AuthenticationProvider 구현체의 authenticate() 수행
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException ex) {
prepareException(ex, authentication); // 이벤트 발생 시키기
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw ex;
}
catch (AuthenticationException ex) {
lastException = ex;
}
}
// 상기 코드에서 인증이 실패할 경우 parent의 AuthenticationProvider에게 인증을 위임해서 한번 확인해보기
if (result == null && this.parent != null) {
// Allow the parent to try.
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
}
catch (ProviderNotFoundException ex) {
// ignore as we will throw below if no other exception occurred prior to
// calling parent and the parent
// may throw ProviderNotFound even though a provider in the child already
// handled the request
}
catch (AuthenticationException ex) {
parentException = ex;
lastException = ex;
}
}
if (result != null) {
if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
// Authentication is complete. Remove credentials and other secret data
// from authentication
((CredentialsContainer) result).eraseCredentials();
}
// If the parent AuthenticationManager was attempted and successful then it
// will publish an AuthenticationSuccessEvent
// This check prevents a duplicate AuthenticationSuccessEvent if the parent
// AuthenticationManager already published it
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
// Parent was null, or didn't authenticate (or throw an exception).
if (lastException == null) {
lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound",
new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}"));
}
// If the parent AuthenticationManager was attempted and failed then it will
// publish an AbstractAuthenticationFailureEvent
// This check prevents a duplicate AbstractAuthenticationFailureEvent if the
// parent AuthenticationManager already published it
if (parentException == null) {
prepareException(lastException, authentication);
}
throw lastException;
}
...
어찌됐든 ProviderManager의 authenticate()가 실행과정을 이어서 살펴보자. ProviderManager는 AuthenticationProvider 객체 목록을 가지고 있는데, 이 중 하나라도 인증이 성공할 시(적합한 AuthenticationProvider 탐색) 인증이 완료된 Authentication을 반환하게 된다. 여기서 AuthenticationProvider는 Interface이며, 그 구현체로는 DaoAuthentiactionProvider, AnonymousAuthenticationProvider 등이 존재한다. 즉, 사용자 검증을 직접적으로 수행하는 객체는 AuthenticationProvider라고 할 수 있다. 하나의 서비스에 대해 클라이언트에 따라 다양한 접근 방식이 필요할 수 있기 때문에, 개발자는 이에 대한 구현체를 만들어서 맞춤형 AuthenticationProvider를 다양하게 둘 수 있다.
...
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
...
@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
@Override
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try { // DaoAuthenticationProvider가 가지고 있는 UserDetailsService 구현체로부터 loadUserByUsername() 메서드 수행
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
...
...
public abstract class AbstractUserDetailsAuthenticationProvider
implements AuthenticationProvider, InitializingBean, MessageSourceAware {
...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username); // 유저 정보가 캐싱되어 있는지?
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
...
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
// 비밀번호 일치하는지 체크(DaoAuthentiactionProvider가 Overriding해서 구현)
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
...
return createSuccessAuthentication(principalToReturn, authentication, user);
}
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
UserDetails user) {
// Ensure we return the original credentials the user supplied,
// so subsequent attempts are successful even with encoded passwords.
// Also ensure we return the original getDetails(), so that future
// authentication events after cache expiry contain the details
UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken.authenticated(principal,
authentication.getCredentials(), this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
result.setDetails(authentication.getDetails());
this.logger.debug("Authenticated user");
return result;
}
...
AuthenticationProvider 구현체 중 가장 기본적으로 설정 돼 있는 DaoAuthentiactionProvider를 예시로 보자. DaoAuthentiactionProvider는 AbstractUserDetailsAuthenticationProvider를 상속받으며 authenticate() 메서드는 이 추상 클래스 내에 존재한다.(따라서 메서드 상속받음.) UserCache에 존재하는 User의 정보가 없다면,(Spring security의 인증 프로세스에 대하여 UserCache를 개발자가 커스터마이징하여 캐싱 해놓는다면 각 인증마다 DB를 거칠 필요가 없으므로 성능 향상을 기대할 수 있다고 한다.) retrieveUser() 메서드가 실행되고 UserDetailsService 구현체의 loadUserByUsername() 메서드를 실행하여 DB에서 아이디로 조회한 User 정보를 반환한다. 다소 길게 돌아왔지만 글 초반 본 프로젝트 Service단에 존재하는 overriding된 loadUserByUsername() 메서드가 바로 이것이다. 이후 반환된 User의 정보에서 DaoAuthentiactionProvider에 구현된 추상메서드인 additionalAuthenticationChecks()로 비밀번호 유효 여부를 체크한다. 이상이 없다면 예외를 발생시키지 않고 코드가 진행되어 createSuccessAuthentication() 메서드를 실행시키고 인증된 Authentication 객체가 반환된다. 즉, authenticate() 메서드의 매개변수로 입력된, 인증되기 전의 Authentication 객체가 검증이 완료되면 인증이된 Authentication 객체로 반환되는 것이다.
TokenDTO tokenDTO = jwtTokenProvider.generateToken(authentication);
return tokenDTO;
JwtTokenProvider.java
...
@Log4j2
@Component
public class JwtTokenProvider {
private final Key key;
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30L;
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 30L;
// private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 5L;
// private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 10L;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenDTO generateToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// String refreshToken = Jwts.builder()
// .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
// .signWith(key, SignatureAlgorithm.HS256)
// .compact();
return TokenDTO.builder()
.grantType("Bearer")
.accessToken(accessToken)
// .refreshToken(refreshToken)
.build();
}
...
이제 인증이 완료된 authentication 객체를 가지고 개발자가 커스터마이징한 jwtTokenProvider의 generateToken() 메서드를 수행시키면 본 어플리케이션에 대한 Access Token이 발급되는 것이다.
TokenDTO tokenDTO = vanillaAuthorizingService.loginChk(memberId, password);
// 인증 정보 기억 체크 후 로그인을 했다면 Cookie 형태로 Refresh 토큰 발급, 아닌 경우는 Access Token만 발급
if(on != null) response.addCookie(jwtCookieProvider.createCookieForRefreshToken(tokenDTO.getRefreshToken()));
return ResponseEntity.ok(tokenDTO);
결론적으로 Controller에서, Service 단에서 비즈니스 로직 수행 후 발급받은 토큰을 Response에 담아 보내어 프론트 단에 전달할 수 있다. 본 어플리케이션에서는 API Server로부터 Response Body에 담겨온 Access Token을 프론트에서 쿠키에 담아 저장하는 것으로 처리하였다.(소셜 로그인이 아닌 경우에만) 그 외의 경우는 모두 Response에 쿠키를 담아 보내어 쿠키로 토큰을 관리한다.(Access, Refresh Token 모두) 물론 이렇게 예외를 두지 않아도 Backend 단에서 Token에 대한 쿠키 처리를 전부 할 수 있다.
3. 요약
Service 단의 authenticationManagerBuilder.getObject().authenticate() 메서드가 실행되면, AuthenticationManager의 구현체인 ProviderManger의 authenticate()가 실행되는 것과 같으며, 이 메서드 내에서는 AuthenticationProvider 구현체의 authenticate() 메서드가 실행된다. 이 과정에서 UserDetailsService 구현체의 loadUserByUsername() 메서드가 호출됨에 따라 사용자를 검증하고, 인증 성공 시 Access Token을 발급할 수 있다.
Project Github Repo.
https://github.com/1nthebleakmidwinter/Urcarcher_Card-Service_ShinhanDS-Academy
References
- https://cjw-awdsd.tistory.com/45
- https://cheershennah.tistory.com/179
- https://limkydev.tistory.com/188
- https://chaewsscode.tistory.com/236 https://www.inflearn.com/community/questions/349502/authcontroller%EC%97%90%EC%84%9C-loadbyusername-%EB%A9%94%EC%84%9C%EB%93%9C-%EC%8B%A4%ED%96%89-%EA%B2%BD%EB%A1%9C?srsltid=AfmBOoprpSMuQFK6p8pTbHNowt1yh7tvJBTuBIt47c2HXUBEcg857NVI
- https://dpdpwl.tistory.com/140
- https://mangkyu.tistory.com/125
- https://wildeveloperetrain.tistory.com/294
- https://velog.io/@10000ji_/Spring-Security-Authentication-Provider-%EC%9D%B8%EC%A6%9D-%EC%A0%9C%EA%B3%B5%EC%9E%90-%EC%84%A4%EB%AA%85-%EB%B0%8F-%EA%B5%AC%ED%98%84
- https://sjiwon-dev.tistory.com/26
'Spring boot > Spring security' 카테고리의 다른 글
[Spring boot] OAuth(Google) & JWT Login (6) - JWT + 쿠키 (0) | 2024.11.11 |
---|---|
[Spring boot] OAuth(Google) & JWT Login (5) - OAuth 동작 ② (14) | 2024.11.06 |
[Spring boot] OAuth(Google) & JWT Login (4) - OAuth 동작 ① (1) | 2024.11.04 |
[Spring boot] OAuth(Google) & JWT Login (3) - Security 구성 및 동작 (5) | 2024.10.31 |
[Spring boot] OAuth(Google) & JWT Login (1) - 프로젝트 기본 구조 (7) | 2024.10.22 |