Spring Security Oauth2 Part 7
OAuth2 Social Login
Google, Naver, Kakao 와 같은 소셜 로그인을 지원하기 위한 작업 수행
각 OAuth2 Provider에 대한 Client 등록
아래의 사이트들을 통해 OAuth2 인증을 위한 Application을 생성한다.
Google: https://console.cloud.google.com/
Naver: https://developers.naver.com/main/
Kakao: https://developers.kakao.com/
이후, client_id, client_secret, oauth2 provider에 대한 endpoint을 등록한다.
OAuth2-application.yml
server:
port: 8081
spring:
security:
oauth2:
client:
registration:
google:
client-id: [cliend-id]
client-secret: [client-secret]
scope: openid,profile,email
naver:
client-id: [cliend-id]
client-secret: [client-secret]
client-name: naver-client-app
redirect-uri: http://localhost:8081/login/oauth2/code/naver
scope: profile,email
kakao:
client-id: [cliend-id]
client-secret: [client-secret]
authorization-grant-type: authorization_code
client-name: client-app
redirect-uri: http://localhost:8081/login/oauth2/code/kakao
scope: openid,profile_nickname,profile_image,account_email
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response
kakao:
issuer-uri: https://kauth.kakao.com
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
mvc:
static-path-pattern: /static/**
Configuration
Oauth2 기반의 인증을 위한 보안 설정, OAuth2Login 설정을 통해 Social Login이 가능하도록 설정하며, CustomUserService들을 등록해서 UserInfo에 접근하는 과정에서 사용자가 정의한 객체가 활용될 수 있도록 한다.
OAuth2ClientConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class OAuth2ClientConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final CustomOidcUserService customOidcUserService;
//정적 파일에 대한 보안 인증을 생략
@Bean
public WebSecurityCustomizer webSecurityCustomizer(){
return (web) -> web.ignoring().requestMatchers(
"/static/js/**", "/static/images/**", "/static/css/**", "/static/scss/**");
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception{
httpSecurity.authorizeHttpRequests()
.requestMatchers("/api/user").hasAnyRole("SCOPE_profile", "SCOPE_email","OAUTH2_USER")
.requestMatchers("/api/oidc").hasAnyRole("SCOPE_openid")
.requestMatchers("/").permitAll()
.anyRequest().authenticated();
//oauth2 기반의 인증
httpSecurity
.oauth2Login(oauth2->oauth2
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig
.userService(customOAuth2UserService)
.oidcUserService(customOidcUserService)
)
);
//form 인증
httpSecurity.formLogin()
.loginPage("/login")
.loginProcessingUrl("/loginProc")
.defaultSuccessUrl("/")
.permitAll();
httpSecurity.exceptionHandling().authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"));
httpSecurity.logout().logoutSuccessUrl("/");
return httpSecurity.build();
}
}
Model
각각의 Provider에서 제공하는 정보가 다를 수 있기 때문에, GoogleUser, NaverUser, KakaoUser와 같이 각 모델을 분리해서 저장할 수 있도록 한다.
PrincipalUser
UserDetails, Oauth2User, OidcUser에 대해 모두 처리할 수 있는 record
public record PrincipalUser(ProviderUser providerUser) implements UserDetails, OidcUser, OAuth2User {
@Override
public String getName() {
return providerUser.getUserName();
}
@Override
public Map<String, Object> getAttributes() {
return providerUser.getAttributes();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return providerUser.getAuthorities();
}
@Override
public String getPassword() {
return providerUser.getPassword();
}
@Override
public String getUsername() {
return providerUser.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public Map<String, Object> getClaims() {
return null;
}
@Override
public OidcUserInfo getUserInfo() {
return null;
}
@Override
public OidcIdToken getIdToken() {
return null;
}
}
ProviderUser
공통적인 메소드에 대해서는 인터페이스로 추상화한다.
public interface ProviderUser {
String getId();
String getUserName();
String getPassword();
String getEmail();
String getProvider();
String getPicture();
List<? extends GrantedAuthority> getAuthorities();
Map<String, Object> getAttributes();
OAuth2User getOAuth2User();
boolean isCertificated();
void isCertificated(boolean bool);
}
각 Social Login 과정에서 얻은 유저 정보를 토대로 각 login 방식에 맞는 User model 생성
Users
Oauth2ProviderUser
Oauth2 기반의 인증에서 공통적으로 사용할 수 있는 유저 모델, 각 모델에서 공통적으로 처리될 수 있는 메소드에 대해서 추상클래스로 묶어서 처리한다.
@Data
public abstract class OAuth2ProviderUser implements ProviderUser {
private Map<String, Object> attributes;
private OAuth2User oAuth2User;
private ClientRegistration clientRegistration;
private boolean isCertificated;
public OAuth2ProviderUser(Map<String,Object> attributes,OAuth2User oAuth2User, ClientRegistration clientRegistration) {
this.attributes = attributes;
this.oAuth2User = oAuth2User;
this.clientRegistration = clientRegistration;
}
@Override
public String getPassword() {
return UUID.randomUUID().toString();
}
@Override
public String getEmail() {
return (String) getAttributes().get("email");
}
@Override
public String getProvider() {
return clientRegistration.getRegistrationId();
}
@Override
public List<? extends GrantedAuthority> getAuthorities() {
return oAuth2User.getAuthorities()
.stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAuthority()))
.collect(Collectors.toList());
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public boolean isCertificated() {
return isCertificated;
}
@Override
public void isCertificated(boolean isCertificated) {
this.isCertificated = isCertificated;
}
}
GoogleUser
public class GoogleUser extends OAuth2ProviderUser {
public GoogleUser(Attributes attributes, OAuth2User oAuth2User, ClientRegistration clientRegistration) {
super(attributes.getMainAttributes(), oAuth2User, clientRegistration);
}
@Override
public String getId() {
return (String) getAttributes().get("sub");
}
@Override
public String getUserName() {
return (String) getAttributes().get("name");
}
@Override
public String getPicture() {
return null;
}
}
NaverUser
public class NaverUser extends OAuth2ProviderUser {
public NaverUser(Attributes attributes, OAuth2User oAuth2User, ClientRegistration clientRegistration) {
//네이버의 경우 response 계층이 존재하여 한 단계 더 들어가야하는 차이점이 있다.
super(attributes.getSubAttributes(), oAuth2User, clientRegistration);
}
@Override
public String getId() {
return (String) getAttributes().get("id");
}
@Override
public String getUserName() {
return (String) getAttributes().get("name");
}
@Override
public String getPicture() {
return (String) getAttributes().get("profile_image");
}
}
KakaoOidcUser
public class KakaoOidcUser extends OAuth2ProviderUser {
public KakaoOidcUser(Attributes attributes, OAuth2User oAuth2User, ClientRegistration clientRegistration) {
super(attributes.getMainAttributes(), oAuth2User, clientRegistration);
}
@Override
public String getId() {
return (String) getAttributes().get("id");
}
@Override
public String getUserName() {
return (String) getAttributes().get("nickname");
}
@Override
public String getPicture() {
return (String) getAttributes().get("profile_image_url");
}
}
KakaoUser
public class KakaoUser extends OAuth2ProviderUser {
private Map<String, Object> otherAttributes;
private Map<String, Object> subAttributes;
public KakaoUser(Attributes attributes, OAuth2User oAuth2User, ClientRegistration clientRegistration) {
//카카오의 경우 특정 유저 정보에 접근하기 위해 2단계를 거쳐야한다.
super(attributes.getOtherAttributes(), oAuth2User, clientRegistration);
this.otherAttributes = attributes.getOtherAttributes();
this.subAttributes = attributes.getSubAttributes();
}
@Override
public String getId() {
return (String) getAttributes().get("id");
}
@Override
public String getUserName() {
return (String) subAttributes.get("nickname");
}
@Override
public String getPicture() {
return (String) otherAttributes.get("profile_image_url");
}
}
FormUser
form login 기반의 인증도 수행할 수 있도록 FormUser 클래스도 정의한다.
@Data
@Builder
public class FormUser implements ProviderUser {
private String id;
private String username;
private String password;
private String email;
private List<? extends GrantedAuthority> authorities;
private String provider;
private boolean isCertificated;
@Override
public String getId() {
return id;
}
@Override
public String getUserName() {
return username;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getEmail() {
return email;
}
@Override
public String getProvider() {
return null;
}
@Override
public String getPicture() {
return null;
}
@Override
public List<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public Map<String, Object> getAttributes() {
return null;
}
@Override
public OAuth2User getOAuth2User() {
return null;
}
@Override
public boolean isCertificated() {
return isCertificated;
}
@Override
public void isCertificated(boolean isCertificated) {
this.isCertificated = isCertificated;
}
}
Attributes
유저 정보를 저장할 수 있는 Attributes 객체
@Data
@Builder
public class Attributes {
private Map<String, Object> mainAttributes;
private Map<String, Object> subAttributes;
private Map<String, Object> otherAttributes;
}
OAuth2Util
OAuth2 인증 과정에서 사용되는 메소드들을 제공하는 유틸리티 클래스
public class OAuth2Utils {
public static Attributes getMainAttributes(OAuth2User oAuth2User) {
return Attributes.builder()
.mainAttributes(oAuth2User.getAttributes())
.build();
}
public static Attributes getSubAttributes(OAuth2User oAuth2User, String subAttributesKey) {
Map<String, Object> subAttributes = (Map<String, Object>) oAuth2User.getAttributes().get(subAttributesKey);
return Attributes.builder()
.subAttributes(subAttributes)
.build();
}
public static Attributes getOtherAttributes(OAuth2User oAuth2User, String subAttributesKey, String otherAttributesKey) {
Map<String, Object> subAttributes = (Map<String, Object>) oAuth2User.getAttributes().get(subAttributesKey);
Map<String, Object> otherAttributes = (Map<String, Object>) subAttributes.get(otherAttributesKey);
return Attributes.builder()
.subAttributes(subAttributes)
.otherAttributes(otherAttributes)
.build();
}
public static String oAuth2UserName(OAuth2AuthenticationToken authentication, PrincipalUser principalUser) {
String username;
String registrationId = authentication.getAuthorizedClientRegistrationId();
OAuth2User oAuth2User = principalUser.providerUser().getOAuth2User();
//Google, Facebook, Apple
Attributes attributes = OAuth2Utils.getMainAttributes(oAuth2User);
username = (String) attributes.getMainAttributes().get("name");
//Naver
if (registrationId.equals(OAuth2Config.SocialType.NAVER.getSocialName())) {
attributes = OAuth2Utils.getSubAttributes(oAuth2User, "response");
username = (String) attributes.getSubAttributes().get("name");
}
//Kakao
if (registrationId.equals(OAuth2Config.SocialType.KAKAO.getSocialName())) {
if (oAuth2User instanceof OidcUser) {
attributes = OAuth2Utils.getMainAttributes(oAuth2User);
username = (String) attributes.getSubAttributes().get("nickname");
}else {
attributes = OAuth2Utils.getOtherAttributes(oAuth2User, "profile", null);
username = (String) attributes.getSubAttributes().get("nickname");
}
}
return username;
}
}
Converters
각 로그인 인증 방식에 맞는 User 객체를 반환 할 수 있도록 Converter 클래스를 생성해서, 각 인증 과정에서 처리할 수 있는 User 객체를 반환하도록 한다.
ProviderUserConverter
Input에 대해 Output을 반환하는 converter 메소드에 대한 인터페이스 정의
public interface ProviderUserConverter<T,R> {
R converter(T t);
}
ProviderUserRequest
ClientRegistration, OAuth2User, User와 같은 정보를 포함하고 UserRequest으로 해당 정보를 토대로 인증 방식 및 유저 정보에 접근이 가능하다.
public record ProviderUserRequest(ClientRegistration clientRegistration, OAuth2User oAuth2User, User user) {
public ProviderUserRequest(ClientRegistration clientRegistration, OAuth2User oAuth2User){
this(clientRegistration, oAuth2User, null);
}
public ProviderUserRequest(User user){
this(null,null,user);
}
}
DelegatingProviderUserConverter
converter list을 저장하고 있어, 인증 방식에 맞는 converter가 처리 될 수 있도록 한다.
@Component
public class DelegatingProviderUserConverter implements ProviderUserConverter<ProviderUserRequest, ProviderUser>{
private List<ProviderUserConverter<ProviderUserRequest, ProviderUser>> converters;
public DelegatingProviderUserConverter() {
List<ProviderUserConverter<ProviderUserRequest, ProviderUser>> providerUserConverters =
Arrays.asList(
new UserDetailsProviderUserConverter(),
new OAuth2GoogleProviderUserConverter(),
new OAuth2NaverProviderUserConverter(),
new OAuth2KakaoProviderUserConverter(),
new OAuth2KakaoOidcProviderUserConverter()
);
this.converters = Collections.unmodifiableList(new LinkedList<>(providerUserConverters));
}
@Override
public ProviderUser converter(ProviderUserRequest providerUserRequest) {
Assert.notNull(providerUserRequest, "providerUserRequest cannot be null");
for (ProviderUserConverter<ProviderUserRequest, ProviderUser> converter : converters) {
ProviderUser providerUser = converter.converter(providerUserRequest);
if (providerUser != null) {
return providerUser;
}
}
return null;
}
}
각 인증을 처리하는 converter
OAuth2GoogleProviderUserConverter
public class OAuth2GoogleProviderUserConverter implements ProviderUserConverter<ProviderUserRequest, ProviderUser> {
@Override
public ProviderUser converter(ProviderUserRequest providerUserRequest) {
if (!providerUserRequest.clientRegistration().getRegistrationId().equals(OAuth2Config.SocialType.GOOGLE.getSocialName())) {
return null;
}
return new GoogleUser(OAuth2Utils.getMainAttributes(providerUserRequest.oAuth2User()),
providerUserRequest.oAuth2User(),
providerUserRequest.clientRegistration());
}
}
OAuth2KakaoOidcProviderUserConverter
public class OAuth2KakaoOidcProviderUserConverter implements ProviderUserConverter<ProviderUserRequest, ProviderUser> {
@Override
public ProviderUser converter(ProviderUserRequest providerUserRequest) {
if (!providerUserRequest.clientRegistration().getRegistrationId().equals(OAuth2Config.SocialType.KAKAO.getSocialName())) {
return null;
}
if (!(providerUserRequest.oAuth2User() instanceof OidcUser)) {
return null;
}
return new KakaoOidcUser(OAuth2Utils.getMainAttributes(providerUserRequest.oAuth2User()),
providerUserRequest.oAuth2User(),
providerUserRequest.clientRegistration());
}
}
OAuth2KakaoProviderUserConverter
public class OAuth2KakaoProviderUserConverter implements ProviderUserConverter<ProviderUserRequest, ProviderUser> {
@Override
public ProviderUser converter(ProviderUserRequest providerUserRequest) {
if (!providerUserRequest.clientRegistration().getRegistrationId().equals(OAuth2Config.SocialType.KAKAO.getSocialName())) {
return null;
}
if (providerUserRequest.oAuth2User() instanceof OidcUser) {
return null;
}
return new KakaoUser(OAuth2Utils.getOtherAttributes(providerUserRequest.oAuth2User(),"kakao_account","profile"),
providerUserRequest.oAuth2User(),
providerUserRequest.clientRegistration());
}
}
OAuth2NaverProviderUserConverter
public class OAuth2NaverProviderUserConverter implements ProviderUserConverter<ProviderUserRequest, ProviderUser> {
@Override
public ProviderUser converter(ProviderUserRequest providerUserRequest) {
if (!providerUserRequest.clientRegistration().getRegistrationId().equals(OAuth2Config.SocialType.NAVER.getSocialName())) {
return null;
}
return new NaverUser(OAuth2Utils.getSubAttributes(providerUserRequest.oAuth2User(),"response"),
providerUserRequest.oAuth2User(),
providerUserRequest.clientRegistration());
}
}
UserDetailsProviderUserConverter
public class UserDetailsProviderUserConverter implements ProviderUserConverter<ProviderUserRequest, ProviderUser> {
@Override
public ProviderUser converter(ProviderUserRequest providerUserRequest) {
User user = providerUserRequest.user();
if (user == null) {
return null;
}
return FormUser.builder()
.id(user.getId())
.username(user.getUsername())
.password(user.getPassword())
.email(user.getEmail())
.authorities(user.getAuthorities())
.provider("none")
.build();
}
}
User Service
Oauth2 인증, Form Login 인증에서 유저 정보에 접근하기 위해 UserService가 호출되는데, 이때 CustomUserService들을 설정하여 로직을 추가한다. 로그인을 시도하는 과정에서 회원이 등록되어 있지 않은 경우 회원가입이 이루어질 수 있도록 한다.
AbstractUserService
providerUserConverter가 등록되어 있는데, 이는 DelegatingProviderUserConverter가 주입되어 상황에 맞는 converter가 동작할 수 있게끔 한다.
@Service
@Getter
public abstract class AbstractUserService {
@Autowired
private UserRepository userRepository;
@Autowired
private UserService userService;
@Autowired
private ProviderUserConverter<ProviderUserRequest, ProviderUser> providerUserConverter;
//ProviderUser을 반환하는 작업(DelegatingProviderUserConverter 호출)
public ProviderUser providerUser(ProviderUserRequest providerUserRequest) {
return providerUserConverter.converter(providerUserRequest);
}
//회원 등록 처리
protected void register(ProviderUser providerUser, OAuth2UserRequest userRequest) {
userService.register(userRequest.getClientRegistration().getRegistrationId(),providerUser);
}
}
UserService & UserRepository
User 객체를 DB에 등록하는 Service, Repository
//UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public void register(String registrationId, ProviderUser providerUser) {
User user = User.builder().registrationId(registrationId)
.username(providerUser.getUserName())
.provider(providerUser.getProvider())
.email(providerUser.getEmail())
.authorities(providerUser.getAuthorities())
.build();
userRepository.register(user);
}
}
//UserRepository
@Repository
public class UserRepository {
private Map<String, Object> users = new HashMap<>();
public Optional<User> findByUsername(String username) {
return Optional.ofNullable((User)users.get(username));
}
public void register(User user) {
if(users.containsKey(user.getUsername()))
return;
users.put(user.getUsername(), user);
}
}
CustomOAuth2UserService
OAuth2Prodiver 방식에서 사용되는 User Service
@Service
public class CustomOAuth2UserService extends AbstractUserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//OAuth2User 객체 반환
ClientRegistration clientRegistration = userRequest.getClientRegistration();
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);
//OAuth2User 객체를 각 User model로 변환하는 작업
ProviderUserRequest providerUserRequest = new ProviderUserRequest(clientRegistration, oAuth2User);
ProviderUser providerUser = super.providerUser(providerUserRequest);
//회원 등록하는 과정
super.register(providerUser, userRequest);
return new PrincipalUser(providerUser);
}
}
CustomOidcUserService
OIDC Provider 방식에서 사용되는 User Service
@Service
public class CustomOidcUserService extends AbstractUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
@Override
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
//OIDC 기반의 인증에서는 username attribute가 sub가 되어야 한다.
ClientRegistration clientRegistration = ClientRegistration.withClientRegistration(userRequest.getClientRegistration())
.userNameAttributeName("sub")
.build();
OidcUserRequest oidcUserRequest = new OidcUserRequest(clientRegistration,
userRequest.getAccessToken(),
userRequest.getIdToken(), userRequest.getAdditionalParameters());
//Oidc2User 객체 반환
OAuth2UserService<OidcUserRequest, OidcUser> oAuth2UserService = new OidcUserService();
OidcUser oidcUser = oAuth2UserService.loadUser(oidcUserRequest);
//OidcUser 객체를 각 User model로 변환하는 작업
ProviderUserRequest providerUserRequest = new ProviderUserRequest(clientRegistration, oidcUser);
ProviderUser providerUser = super.providerUser(providerUserRequest);
//회원 등록하는 과정
super.register(providerUser, userRequest);
return new PrincipalUser(providerUser);
}
}
CustomUserDetailsService
Form Login 과정에서 사용되는 User Service
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService extends AbstractUserService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElse(
User.builder()
.id("1")
.username("user1")
.password("{noop}1234")
.authorities(AuthorityUtils.createAuthorityList("ROLE_USER"))
.email("user@a.com")
.build());
ProviderUserRequest providerUserRequest = new ProviderUserRequest(user);
ProviderUser providerUser = providerUser(providerUserRequest);
return new PrincipalUser(providerUser);
}
}
인증된 유저 접근
아래와 같이 @AuthenticationPrincipal annotation을 이용해서 유저 객체를 인자로 받을 수 있다.
@Controller
public class IndexController {
@GetMapping("/")
public String index(Model model, Authentication authentication, @AuthenticationPrincipal PrincipalUser principalUser) {
String view = "index";
if (authentication != null) {
String username=principalUser.providerUser().getUserName();
model.addAttribute("user", username);
model.addAttribute("providerUser", principalUser.providerUser().getProvider());
if(!principalUser.providerUser().isCertificated())
view = "selfcert";
}
return view;
}
}
References
link: inflearn
docs: spring_security
댓글남기기