1. 서론
이전 포스팅(https://inthebleakmidwinter.tistory.com/6)에서 JWT를 생성하는 JwtTokenProvider와 이를 HTTP Cookie 형태로 포장시켜주는 JwtCookieProvider 객체에 대해 설명했다. 이번에는 Security Configuration을 시작으로 이 객체들을 이용해, 개발자가 커스텀한 필터(JwtAuthenticationFilter)를 거쳐 사용자를 검증하는 과정을 알아보자.
2. 필터체인 설정에서 .addFilterBefore() 메서드
SecurityConfig.java
...
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final JwtCookieProvider jwtCookieProvider;
private final UrcarcherOAuth2Service urcarcherOAuth2Service;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
private static final String[] BLACK_LIST = {"/api/paymentPlace/**, /api/exchange/**, /api/reserve/**"};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
...
// UsernamePasswordAuthenticationFilter 비활성화, formLogin은 기본적으로 세션을 이용하는 방식인데, JWT를 사용할 것이므로 필요없음
.formLogin(formLoginConfig->
formLoginConfig
.disable()
)
...
// UsernamePasswordAuthenticationFilter보다 앞 순위에 필터를 배치한다는 뜻, 우리는 이 필터가 비활성화 돼 있으므로
// 실질적으로 사용자는 이 대신 JwtAuthenticationFilter를 거치게 됨.
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, jwtCookieProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
본 프로젝트의 filterChain() 설정을 보면, 마지막에 .addFilterBefore() 메서드로 필터 설정이 진행되고 있다. HttpSecurity 객체의 addFilterBefore() 메서드를 살펴보자.
...
public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<DefaultSecurityFilterChain, HttpSecurity>
implements SecurityBuilder<DefaultSecurityFilterChain>, HttpSecurityBuilder<HttpSecurity> {
...
private List<OrderedFilter> filters = new ArrayList<>();
...
private FilterOrderRegistration filterOrders = new FilterOrderRegistration();
...
@Override
public HttpSecurity addFilterBefore(Filter filter, Class<? extends Filter> beforeFilter) {
return addFilterAtOffsetOf(filter, -1, beforeFilter);
}
private HttpSecurity addFilterAtOffsetOf(Filter filter, int offset, Class<? extends Filter> registeredFilter) {
Integer registeredFilterOrder = this.filterOrders.getOrder(registeredFilter);
if (registeredFilterOrder == null) {
throw new IllegalArgumentException(
"The Filter class " + registeredFilter.getName() + " does not have a registered order");
}
int order = registeredFilterOrder + offset;
this.filters.add(new OrderedFilter(filter, order));
this.filterOrders.put(filter.getClass(), order);
return this;
}
...
addFilterBefore()는 addFilterAtOffsetOf()를 return하고 있다. addFilterAtOffsetOf() 메서드 매개변수에서 이를 보면 새로 배치 시킬 filter에 JwtAuthenticationFilter 객체가 담기는 것이고 registeredFilter에 UsernamePasswordAuthenticationFilter Class 객체가 담기는 것이다. 메서드 내부 로직을 보면, 먼저 this.filterOrders.getOrder(registeredFilter); 필터의 순서가 담겨 있는 필드 변수인 filterOrders로부터 registeredFilter의 순서를 가져와 registeredFilterOrder에 저장한다. 순서 값이 작을수록 앞 순위가 되므로 int order = registeredFilterOrder + offset; offset의 값에 따라 registeredFilterOrder보다 커지거나 작아져 registeredFilter보다 전순위나 후순위에 배치된다. addFilterBefore() 메서드는 offset에 -1이 전달되므로 registeredFilter보다 전순위에 배치되는 것이다. order에 배치시킬 순서값이 담긴 뒤, 필드 변수인 filters에 filter를 순서 정보와 함께 담고, filterOrders에 filter에 해당하는 순서 정보를 새로 담는다.
...
@SuppressWarnings("serial")
final class FilterOrderRegistration {
private static final int INITIAL_ORDER = 100;
private static final int ORDER_STEP = 100;
private final Map<String, Integer> filterToOrder = new HashMap<>();
FilterOrderRegistration() {
Step order = new Step(INITIAL_ORDER, ORDER_STEP);
put(DisableEncodeUrlFilter.class, order.next());
put(ForceEagerSessionCreationFilter.class, order.next());
put(ChannelProcessingFilter.class, order.next());
order.next(); // gh-8105
put(WebAsyncManagerIntegrationFilter.class, order.next());
put(SecurityContextHolderFilter.class, order.next());
put(SecurityContextPersistenceFilter.class, order.next());
put(HeaderWriterFilter.class, order.next());
put(CorsFilter.class, order.next());
put(CsrfFilter.class, order.next());
put(LogoutFilter.class, order.next());
this.filterToOrder.put(
"org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter",
order.next());
this.filterToOrder.put(
"org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationRequestFilter",
order.next());
put(X509AuthenticationFilter.class, order.next());
put(AbstractPreAuthenticatedProcessingFilter.class, order.next());
this.filterToOrder.put("org.springframework.security.cas.web.CasAuthenticationFilter", order.next());
this.filterToOrder.put("org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter",
order.next());
this.filterToOrder.put(
"org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter",
order.next());
put(UsernamePasswordAuthenticationFilter.class, order.next());
order.next(); // gh-8105
put(DefaultLoginPageGeneratingFilter.class, order.next());
put(DefaultLogoutPageGeneratingFilter.class, order.next());
put(ConcurrentSessionFilter.class, order.next());
put(DigestAuthenticationFilter.class, order.next());
this.filterToOrder.put(
"org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter",
order.next());
put(BasicAuthenticationFilter.class, order.next());
put(RequestCacheAwareFilter.class, order.next());
put(SecurityContextHolderAwareRequestFilter.class, order.next());
put(JaasApiIntegrationFilter.class, order.next());
put(RememberMeAuthenticationFilter.class, order.next());
put(AnonymousAuthenticationFilter.class, order.next());
this.filterToOrder.put("org.springframework.security.oauth2.client.web.OAuth2AuthorizationCodeGrantFilter",
order.next());
put(SessionManagementFilter.class, order.next());
put(ExceptionTranslationFilter.class, order.next());
put(FilterSecurityInterceptor.class, order.next());
put(AuthorizationFilter.class, order.next());
put(SwitchUserFilter.class, order.next());
}
void put(Class<? extends Filter> filter, int position) {
this.filterToOrder.putIfAbsent(filter.getName(), position);
}
Integer getOrder(Class<?> clazz) {
while (clazz != null) {
Integer result = this.filterToOrder.get(clazz.getName());
if (result != null) {
return result;
}
clazz = clazz.getSuperclass();
}
return null;
}
private static class Step {
private int value;
private final int stepSize;
Step(int initialValue, int stepSize) {
this.value = initialValue;
this.stepSize = stepSize;
}
int next() {
int value = this.value;
this.value += this.stepSize;
return value;
}
}
}
HttpSecurity의 필드에서 build되기 전 filters는 비어 있는 List지만, filterOrders에는 생성자를 통해 FilterOrderRegistration 객체가 담긴다. 즉, filterOrders에는 HttpSecurity 객체가 build되기 전부터 Spring security에서 설정되는 필터들의 순서에 대한 정보가 미리 담겨 있다. filters에는 HttpSecurity 객체가 build되면서 Spring security가 관리하는 모든 필터가 담긴다. 좀 더 자세히 말하면, HttpSecurity가 build()를 호출하면 HttpSecurity의 부모 class인 AbstractConfiguredSecurityBuilder의 doBuild() 메서드가 실행되며 여기서 AbstractConfiguredSecurityBuilder 필드가 갖고 있는 configurer들이 모두 configure() 메서드를 실행하게 되고, 이 때 HttpSecurity 필드의 filters에 각 configurer에 설정된(정확히는 각 configurer의 부모 class인 AbstractAuthenticationFilterConfigurer의 필드에 담겨 있는) 필터가 담긴다.
이를 필터체인 설정(SecurityConfig.java)과 함께 연관지어 정리하면 다음과 같다.
- JWT를 사용하므로 필터체인 설정에서 formLogin() 메서드가 관리하는 FormLoginConfigurer를 비활성화(.disable())시킨다. 즉, HttpSecurity의 부모 필드에 설정된 configurer 목록에서 FormLoginConfigurer가 제외된다.
- FormLoginConfigurer는 UsernamePasswordAuthenticationFilter를 관리한다.
- addFilterBefore() 메서드로 개발자가 커스텀한 필터를 비활성화될 UsernamePasswordAuthenticationFilter보다 앞 순위가 되도록 filterOrders 순서 목록을 업데이트하고, 커스텀 필터를 filters에 추가한다.
- HttpSecurity가 빌드되면, configurer 목록에서 제외된 FormLoginConfigurer는 UsernamePasswordAuthenticationFilter를 필터 목록(filters)에 추가할 수 없다.
- filterOrders에는 처음부터 UsernamePasswordAuthenticationFilter의 우선 순위가 담겨 있으나 결국 filters에 담겨 있지 않아 UsernamePasswordAuthenticationFilter는 비활성화된 것이라고 볼 수 있으며, 이 자리를 대신 개발자가 JWT를 위해 커스텀한 필터인 JwtAuthenticationFilter가 자리를 꿰 찬 것이라고 보면 된다.
3. JwtAuthenticationFilter
그렇다면 JwtAuthenticationFilter는 UsernamePasswordAuthenticationFilter를 대체하고 어떤 역할을 하는가?
JwtAuthenticationFilter.java
...
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
private final JwtCookieProvider jwtCookieProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest http_request = (HttpServletRequest) request;
HttpServletResponse http_response = (HttpServletResponse) response;
// jwtCookieProvider 객체를 통해 request에 담겨오는 Cookie들로부터 Refresh Token을 추출한다.
String refreshToken = jwtCookieProvider.getRefreshToken(http_request);
// Access Token은 아래 resolveToken() 메서드로 부터 추출한다.
String token = resolveToken(http_request);
// Access Token이 존재한다면
if(token != null) {
// JwtTokenProvider를 통해 Access Token으로부터 일단 Authentication 객체 추출한다.
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// Access Token의 유효성을 검증한다. 유효한 토큰일 경우,
if(jwtTokenProvider.validateToken(token)) {
// SecurityContext에 Authentication 객체를 setting한다.
SecurityContextHolder.getContext().setAuthentication(authentication);
// Access Token이 유효하지 않을 경우,
} else {
// Refresh Token이 존재하면,
if(refreshToken != null) {
// Refresh Token의 유효성을 검증한다. 유효한 토큰일 경우,
if(jwtTokenProvider.validateToken(refreshToken)) {
// JwtTokenProvider를 통해 Access Token을 발급하고 이를 쿠키에 담아 response에 반영한다.
http_response.addCookie(jwtCookieProvider.createCookieForAccessToken(jwtTokenProvider.generateAccessToken(authentication)));
// SecurityContext에 Authentication 객체를 setting한다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
}
chain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
// 초기 헤더에서 Access Token을 추출하는 로직
// String bearerToken = request.getHeader("Authorization");
// if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
// return bearerToken.substring(7);
// }
// CookieUtils 객체로 URCARCHER_ACCESS_TOKEN 라는 이름을 가진 쿠키를 추출하고 token 값 return
Cookie accessTokenCookie = CookieUtils.getCookie(request, "URCARCHER_ACCESS_TOKEN").orElse(null);
if(accessTokenCookie != null) return accessTokenCookie.getValue();
return null;
}
}
- JwtAuthenticationFilter는 먼저 HTTP Cookie에 존재하는 Access Token과 Refresh Token을 가져온다.
- Access Token이 존재할 시 일단 Access Token으로부터 Authentication 객체를 추출한다.
- 그 후 Access Token의 유효성을 검증한다. 유효할 시 SecurityContext에 Authentication 객체를 setting한다.
- 유효하지 않을 시, Refresh Token의 유효성을 검사하고, 유효하다면 Access Token을 발급한다. 그 후 SecurityContext에 Authentication 객체를 setting한다.
따라서 사용자는 API 요청 시 무조건 JwtAuthenticationFilter를 거치게 되고, 이 과정에서 자신의 신원을 인증하고 자원에 대한 권한을 인가받을 수 있는 것이다. 아울러 Refresh Token을 통해 사용자가 다시 로그인할 필요 없이 Access Token을 발급받을 수 있도록하여 사용자 경험 수준을 향상시키고 보안을 확보할 수 있다.
4. 그 외
지금까지 Spring security와 JWT로 로그인 및 사용자 인증/인가 과정을 이루고 있는 뼈대를 살펴보았다. 중간 중간 Controller나 Service단에서 설명을 빼먹고 넘어간 부분들을 살펴보며 글을 마치겠다.
5. 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();
}
public Member InsertAfterEncodingPw(Member member) {
String password = passwordEncoder.encode(member.getPassword());
member.setPassword(password);
return memberRepository.save(member);
}
@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;
}
public Member getMemberByAuth(UserDetails userDetails) {
if(userDetails != null) return memberRepository
.findById(userDetails.getUsername())
.orElseThrow();
return Member.builder()
.role(MemberRole.GUEST)
.build();
}
}
...
@Configuration
public class PasswordEncoderConfig {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
...
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/signup")
public class SignupController {
private final SignupService sService;
private final MailService mService;
private final VanillaAuthorizingService vanillaAuthorizingService;
@PostMapping("/local")
public ResponseEntity<Member> signup(@RequestBody Member member) {
member.setRole(MemberRole.USER);
member.setPoint(0);
member.setProvider(MemberProvider.URCARCHER);
Member newMember = vanillaAuthorizingService.InsertAfterEncodingPw(member);
return ResponseEntity.ok(newMember);
}
...
VanillaAuthorizingService의 InsertAfterEncodingPw() 메서드는 BCryptPasswordEncoder를 Bean 주입받아 password를 암호화시켜 DB에 저장할 수 있도록 한다. BCryptPasswordEncoder는bcrypt 해싱 함수로 password를 인코딩하는 메서드와, 제출된 password와 DB에 저장된 암호화된 password의 일치 여부를 확인해주는 메서드를 제공한다. encode()가 바로 password를 암호화해주는 메서드이다. 반환 타입은 String이며 똑같은 password를 인코딩하더라도 매번 다른 문자열을 반환한다. 따라서 DB에 password가 저장될 때 DB에 원문 그대로 저장되지 않도록하여 보안 수준을 강화할 수 있다. 따라서 InsertAfterEncodingPw() 메서드는 사용자가 회원가입할 때 통신하는 signup() 메서드에 의해 호출된다.
그렇다면 사용자가 로그인을 할 때 제출한 password와 DB에 저장된 암호화된 password의 일치 여부 판정은 언제 이루어지는가? 2. 로그인 시(소셜X) 에서 ProviderManager가 가지고 있는 AuthenticationProvider을 통해 사용자 인증을 진행하는 과정을 다루었다. AuthenticationProvider 중 기본으로 설정돼 있는 구현체인 DaoAuthentiactionProvider를 보면, 그 과정에서 비밀번호 일치 여부 확인을 위해 additionalAuthenticationChecks() 메서드가 사용됐다.
...
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
...
public DaoAuthenticationProvider() {
this(PasswordEncoderFactories.createDelegatingPasswordEncoder());
}
public DaoAuthenticationProvider(PasswordEncoder passwordEncoder) {
setPasswordEncoder(passwordEncoder);
}
@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
...
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"));
}
}
...
...
public final class PasswordEncoderFactories {
private PasswordEncoderFactories() {
}
@SuppressWarnings("deprecation")
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
...
return new DelegatingPasswordEncoder(encodingId, encoders);
}
}
...
public class DelegatingPasswordEncoder implements PasswordEncoder {
...
public DelegatingPasswordEncoder(String idForEncode, Map<String, PasswordEncoder> idToPasswordEncoder) {
this(idForEncode, idToPasswordEncoder, DEFAULT_ID_PREFIX, DEFAULT_ID_SUFFIX);
}
public DelegatingPasswordEncoder(String idForEncode, Map<String, PasswordEncoder> idToPasswordEncoder,
String idPrefix, String idSuffix) {
...
this.idForEncode = idForEncode;
this.passwordEncoderForEncode = idToPasswordEncoder.get(idForEncode);
this.idToPasswordEncoder = new HashMap<>(idToPasswordEncoder);
this.idPrefix = idPrefix;
this.idSuffix = idSuffix;
}
...
@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
...
String id = extractId(prefixEncodedPassword);
PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
...
return delegate.matches(rawPassword, encodedPassword);
}
private String extractId(String prefixEncodedPassword) {
if (prefixEncodedPassword == null) {
return null;
}
int start = prefixEncodedPassword.indexOf(this.idPrefix);
if (start != 0) {
return null;
}
int end = prefixEncodedPassword.indexOf(this.idSuffix, start);
if (end < 0) {
return null;
}
return prefixEncodedPassword.substring(start + this.idPrefix.length(), end);
}
...
DaoAuthentiactionProvider의 생성자는 필드에 DelegatingPasswordEncoder를 set한다. additionalAuthenticationChecks() 메서드가 호출되면, 이는 필드에 set된 DelegatingPasswordEncoder의 matches() 메서드를 호출하며, 비밀번호에서 암호화된 방식의 id를 추출해(extractId() 메서드, 이 경우에서는 bcrypt가 return됨.) 해당하는 PasswordEncoder 객체(마찬가지로 이 경우에서는 BCryptPasswordEncoder)의 matches() 메서드가 실행된다. 따라서 이 때 제출된 password와 저장소에 저장된 암호화된 password와의 일치 여부 판정이 이때 이루어진다.
VanillaAuthorizingService의 getMemberByAuth() 메서드는 필자가 작성했던 Test 코드로, 실제 프로젝트에서는 사용되지 않았다.
6. UrcarcherOAuth2Service.java
...
@RequiredArgsConstructor
@Service
public class UrcarcherOAuth2Service extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
@Override
public AuthorizedUser loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Map<String, Object> oAuth2UserAttributes = super.loadUser(userRequest).getAttributes();
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest
.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
OAuth2Profile oAuth2Profile = OAuth2Profile.of(registrationId, oAuth2UserAttributes, userNameAttributeName);
Member member = memberRepository
.findByEmailAndProvider(oAuth2Profile.getEmail(), oAuth2Profile.getProvider())
.orElseGet(oAuth2Profile::toMember);
memberRepository.save(member);
return AuthorizedUser.builder()
.member(member)
.attributes(oAuth2UserAttributes)
.attributeName(userNameAttributeName)
.build();
}
public Member oauthNew(OAuthNewRequestDTO oAuthNewRequestDTO) {
Member oauthNewMember = findByEmailAndProvider(oAuthNewRequestDTO.getEmail(), MemberProvider.valueOf(oAuthNewRequestDTO.getProvider()));
oauthNewMember.setGender(oAuthNewRequestDTO.getGender());
oauthNewMember.setInformationConsent(oAuthNewRequestDTO.getInformationConsent());
oauthNewMember.setLocationConsent(oAuthNewRequestDTO.getLocationConsent());
oauthNewMember.setMatchingConsent(oAuthNewRequestDTO.getMatchingConsent());
oauthNewMember.setNationality(oAuthNewRequestDTO.getNationality());
oauthNewMember.setPhoneNumber(oAuthNewRequestDTO.getPhoneNumber());
oauthNewMember.setRegistrationNumber(oAuthNewRequestDTO.getRegistrationNumber());
oauthNewMember.setDateOfBirth(oAuthNewRequestDTO.getDateOfBirth());
oauthNewMember.setRole(MemberRole.USER);
return memberRepository.save(oauthNewMember);
}
private Member findByEmailAndProvider(String email, MemberProvider provider) {
return memberRepository.findByEmailAndProvider(email, provider).orElseThrow();
}
}
package com.urcarcher.be.blkwntr.auth.dto;
import lombok.Data;
@Data
public class OAuthNewRequestDTO {
private String email;
private String registrationNumber;
private String gender;
private String nationality;
private String phoneNumber;
private String informationConsent;
private String locationConsent;
private String matchingConsent;
private String provider;
private String dateOfBirth;
}
UrcarcherOAuth2Service는 VanillaAuthorizingService와 달리 소셜로그인과 관련된 비즈니스 로직을 처리한다. 처음 소셜 로그인을 한 회원의 경우, 추가 정보 입력을 위해 별도의 회원가입 과정을 거쳐야 하는데, 이를 처리하는 메서드가 oauthNew()이다. 먼저 Role이 GUEST상태인 Member를 조회한 뒤, 추가 정보를 프론트로부터 전달받고 Role을 USER로 전환시켜 소셜 유저로 완벽하게 set한다.
7. 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);
}
@PostMapping("/oauth/new")
public ResponseEntity<Member> oauthNew(@RequestBody OAuthNewRequestDTO oAuthNewRequestDTO) {
return ResponseEntity.ok(urcarcherOAuth2Service.oauthNew(oAuthNewRequestDTO));
}
@PostMapping("/logout")
public ResponseEntity<Object> logout(HttpServletResponse response) {
response.addCookie(jwtCookieProvider.deleteCookieForAccessToken());
try {
response.addCookie(jwtCookieProvider.deleteCookieForRefreshToken());
} catch (Exception e) {
// TODO: handle exception
}
return ResponseEntity.ok(null);
}
@GetMapping("/authorizing")
public ResponseEntity<SimpleUserInfo> authorizing(HttpServletRequest request, @AuthenticationPrincipal UserDetails userDetails) {
return ResponseEntity.ok(
SimpleUserInfo.builder()
.isAuthorized(jwtTokenProvider.validateToken(CookieUtils.getCookie(request, "URCARCHER_ACCESS_TOKEN").orElseThrow().getValue()))
.name(vanillaAuthorizingService.getMemberByAuth(userDetails).getName())
.memberId(userDetails != null ? userDetails.getUsername() : null)
.build());
}
}
package com.urcarcher.be.blkwntr.auth.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class SimpleUserInfo {
private Boolean isAuthorized;
private String name;
private String memberId;
}
AuthorizingController는 인증에 관한 API요청을 처리한다.
oauthNew() 메서드는 소셜 회원의 추가 정보 입력을 위한 요청을 처리하고,
logout() 메서드는 JwtCookieProvider를 이용해 Access Token과 Refresh Token을 제거함으로써 사용자가 정상적으로 로그아웃되도록 처리한다.
authorizing() 메서드의 경우 현재 로그인한 사용자의 간단한 정보와 인증 여부를 담은 객체를 반환한다. @AuthenticationPrincipal 어노테이션을 사용하면, SecurityContext에 Holding된 Authentication 객체에서 UserDetails 객체를 바로 추출해서 주입받을 수 있다.
이때 이 어노테이션만으로는 인증이 필요없는 메서드에서 인증 요구를 통한 null exception 처리를 하기 어려워지므로, 어노테이션과 ArgumentResolver를 커스터마이징하면 비효율적인 방식을 피할 수 있다고 한다.
Project Github Repo.
https://github.com/1nthebleakmidwinter/Urcarcher_Card-Service_ShinhanDS-Academy
References
'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 (2) - 자체 로그인 동작 구조 (6) | 2024.10.28 |