학기 중에 진행했던 KUIT 동아리 스터디 활동의 연장선으로, 방학 기간 동안 프로젝트를 진행하게 되어 그 이야기를 블로그에 차근차근 담아보고자 한다.
일반 로그인 & 소셜 로그인 통합 구현
프로젝트 요구사항에 따라, 일반 로그인과 소셜 로그인 기능을 동시에 구현하여야 했다.
개발자 유미님의 영상들을 보며 인증&인가 로직의 전반적인 구조를 잡을 수 있었다. (압도적 감사드립니다..)
https://www.youtube.com/@xxxjjhhh/playlists
개별적으로 올라온 일반 로그인과 소셜 로그인의 통합과, 프로젝트 요구사항에 맞춘 로직의 추가 및 수정이 필요했다.
통합 과정에서 가장 헷갈렸던 개념은, `authentication.getPrincipal()` 메소드의 반환 객체 타입이 다르다는 점이었다.
Authentication 객체 내부의 Principal을 반환한다는 점에서는 동일했으나, 실제 객체 타입이 서로 달랐다.
일반 로그인에서는 UserDetails 인터페이스를 구현한 객체가, OAuth2 인증의 경우 OAuth2User 인터페이스를 구현한 객체가 반환되고 있었다. 처음에는 두 객체의 역할이 정립이 되지 않아 어떻게 통합할지 감이 잡히지 않았다.
결과적으론, 이 두 개의 객체는 일반 로그인이든, 소셜 로그인이든 해당 인증의 내부 로직 및 성공 메소드에 전달되는 DTO의 역할을 하고 있었다. 일단 로그인 방식별로 전체적인 인증 흐름을 살펴보자.
일반 로그인 인증 흐름
일반 로그인의 경우 UsernamePasswordAuthenticationFilter를 extends 하도록 우리가 커스텀한 LoginFilter에서 요청을 받아 `attemptAuthentication()` 메소드 내부의 `authenticationManager.authenticate()` 메소드를 통해 AuthenticationManager 내부에서 처리된다.
인증을 위한 대상이 되는 주체는 CustomUserDetailsService의 `loadByUsername()` 메소드에서 반환되며, 판별의 기준이 되는 username과 password는 CustomUserDetails 클래스의 `getUsername()`과 `getPassword()`를 통해 정해진다.
이후 AuthenticationManager 내부에서 비밀번호의 일치 여부를 판단, 로그인 성공 시 `successfulAuthentication()` 메소드가, 실패 시 `unsuccessfulAuthentication()` 메소드가 호출되는 흐름이다.
간단히 말하면, LoginFilter에서 요청을 받아 AuthenticationManager에서 처리, 성공 여부를 분기점으로 LoginFilter에서 요청에 대한 응답을 처리한다.
OAuth2 로그인 인증 흐름
OAuth2 로그인의 경우 사용자의 로그인 요청 시 서드파티(카카오, 구글 등)의 인증 서버로 사용자를 리다이렉트 시켜준다.
사용자가 인증 서버에서 로그인하고 권한을 승인하면, 인증 서버는 인증 코드와 함께 우리가 내부 개발자 플랫폼(카카오/구글 디벨로퍼 등)과 yml 파일에 미리 설정해 둔 redirect URI로 돌려보낸다.
스프링 시큐리티가 이 인증 코드를 사용하여 인증 서버로부터 액세스 토큰을 요청하고, 이 액세스 토큰을 받게 되면 CustomOAuth2UserService의 `loadUser()` 메소드가 호출된다. 여기서 우린 토큰에서 사용자 정보를 가져와 DB에서 해당 유저의 존재 여부 확인이나 정보 수정 등을 진행하고 결과적으론 CustomOAuth2User 객체를 생성하여 반환한다.
인증이 성공하면 CustomSuccessHandler의 `onAuthenticationSuccess()` 메소드가 호출되어, 응답을 처리한다.
UserDetails & OAuth2User
두 개의 인증 로직에서, 나는 UserDetails 객체와 OAuth2User 객체는 각각 로그인 인증 성공 시 성공 처리 메소드에 전달되는 Principal 이자 DTO의 역할을 수행한다고 이해했다. 이 내부의 정보를 사용하여 우리는 엑세스 토큰이나 리프레시 토큰을 생성하고 응답을 처리하는 등의 로직을 수행할 뿐이다.
즉, 우린 전달되는 이 객체들에서 정보를 추출하여 토큰을 구성하여 응답에 활용하면 된다.
추가적으로, 이 객체들이 JWTFilter에서도 사용되었다.
위에 설명한 UserDetails 객체와 OAuth2User 객체가 스프링 시큐리티로 부터 우리의 커스텀 로직에 정보를 전달하는 매개체 였다면, 이제는 우리가 요청에 포함된 엑세스 토큰에서 정보를 추출하여, UserDetails 객체나 OAuth2User 객체를 생성, SecurityContextHolder에 넘겨주는 매개체로 이 객체들을 사용하는 것이다.
하단은 JWTFilter의 `doFilterInternal()` 메소드 내부 로직이다.
Long userId = jwtUtil.getUserId(accessToken);
String email = jwtUtil.getEmail(accessToken);
String role = jwtUtil.getRole(accessToken);
String userType = jwtUtil.getUserType(accessToken);
AuthUserDTO authUserDTO = new AuthUserDTO();
authUserDTO.setUserId(userId);
authUserDTO.setEmail(email);
authUserDTO.setRole(role);
Authentication authToken = getAuthentication(userType, authUserDTO);
SecurityContextHolder.getContext().setAuthentication(authToken);
여기서 사용하는 `getAuthentication()` 메소드는 아래와 같이 정의했다.
private Authentication getAuthentication(String userType, AuthUserDTO authUserDTO) {
Authentication authToken = null;
if(userType.equals("basic")){
CustomUserDetails customUserDetails = new CustomUserDetails(authUserDTO);
authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
}
if(userType.equals("social")){
CustomOAuth2User customOAuth2User = new CustomOAuth2User(authUserDTO);
authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities());
}
return authToken;
}
위 로직을 보면 알 수 있듯이, 토큰으로부터 추출한 정보를 메소드에 인자로 넘기고, 해당 정보를 통해 CustomUserDetails 객체나 CustomOAuth2User 객체를 생성하고, 이를 Authentication 객체에 넣어 스프링 시큐리티에 전달한다.
내 경우에는 UserIdHolder라는 인터페이스를 하나 정의하여 CustomUserDetails 클래스와 CustomOAuth2User 클래스 둘 다 UserIdHolder를 implements 하여 어떤 형식의 Principal이든 인증 정보로 부터 userId를 가져올 수 있도록 구현하였다.
public interface UserIdHolder {
Long getUserId();
}
이렇게 등록한 Authentication 객체 내부의 Principal을 이후 컨트롤러 로직에서
@DeleteMapping("/withdraw")
public ResponseEntity<?> withdrawUser(@AuthenticationPrincipal UserIdHolder userIdHolder){
Long userId = userIdHolder.getUserId();
Optional<User> userOptional = userService.findUserByUserId(userId);
// 위 User 정보를 통한 메소드 내부 다른 로직 실행
}
같은 방식으로 꺼내어, 사용할 수 있도록 구현하였다.
개인적으로는 JWTFilter에서 CustomUserDetails 클래스와 CustomOAuth2User 클래스를 사용하지 않고, 제3의 Principal 역할을 할 클래스를 새로 정의하여 사용하는 것이 좋다고 생각한다! UsernamePasswordAuthenticationToken 객체 생성자의 인자로 일반 로그인이든 소셜 로그인이든 동일한 클래스의 객체를 생성하여 넘겨 구현하면 될 것이다.
물론 내 경우엔 두 개의 로직을 통합하는 과정에서 이런 구조로 구현이 되었지만, 추후 프로젝트에서 userId 이외의 정보가 컨트롤러 로직에서 필요한 경우엔 수정이 필요하다고 생각한다. 일반 로그인과 소셜 로그인 유저의 구분은 새로 정의한 클래스에 필드로 하나 추가해 주면 될 일이다.
마치며
일반 로그인과 소셜 로그인을 통합 구현하고 있는 분들이 이 포스팅을 보고 조금이나마 도움이 되었으면 한다.
블로그로 정리하니 놓쳤던 부분들에 대해서도 새롭게 개념을 정리할 수 있어 너무 좋은 것 같다.
