Spring Security 의 OAuth2
https://tose33.tistory.com/1303
Google OAuth2
구글 로그인 구현 https://lsh-instaweb.herokuapp.com/login 로그인 lsh-instaweb.herokuapp.com OAuth (Open Authorization) 만든 웹사이트에 구글로 로그인을 구현하고 싶어서 이에 대해서 공부해봤다. 요즘 많은 웹사이
tose33.tistory.com
https://tose33.tistory.com/1304
git 프로젝트의 secret key 관리, heroku 배포 시 secret key 관리
이전에 구글 로그인을 구현했는데 구현을 다 하고 나서 문제가 생겼다. https://tose33.tistory.com/1303 Google OAuth2 구글 로그인 구현 https://lsh-instaweb.herokuapp.com/login 로그인 lsh-instaweb.herokuapp.com OAuth (Open A
tose33.tistory.com
https://tose33.tistory.com/1330
OAuth2 로그인 시스템 구조 리팩토링
https://tose33.tistory.com/1303 Google OAuth2 구글 로그인 구현 https://lsh-instaweb.herokuapp.com/login 로그인 lsh-instaweb.herokuapp.com OAuth (Open Authorization) 만든 웹사이트에 구글로 로그인을 구현하고 싶어서 이에 대
tose33.tistory.com
이전에 직접 구현한 OAuth2 로그인 서비스를 이제 Spring Security 에서 제공하는 방법으로 구현해 봤다.
스프링 프레임워크를 쓰면서 항상 느끼지만 정말 너무너무 좋다..
내가 열심히 공부한 내용들, 그리고 각 프로바이더 (네이버 구글 등) 가 리턴하는 값이 다르기 때문에 발생하는 문제 등을 다 해결해 놓았다.
스프링 시큐리티를 쓰면 OAuth2 가 뭔지도 모르고 그냥 Oauth2 로그인을 구현할수 있다.
공부한 내용이 허무할 정도로 스프링 시큐리티가 좋은데, 하지만 결국 어디서 버그가 나면 제대로 공부하는 것 외에는 방법이 없기 때문에 무의미한 일은 당연히 아닐것이다.
사실 이전에 spring security 구조를 정리했을때 (https://tose33.tistory.com/1333) 처럼 OAuth2 로그인을 처리를 담당하는 클래스와 인터페이스도 엄청 많았는데, 사실 모든 걸 알아보면서 구현하지는 않았고 필요한 클래스들만 알아보면서 구현했다.
application.yml
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: # provider 추가 시 OpenIdProvider 로 인식, 구글은 OpenIdProvider 사용하지 않음
- profile
- email
#redirect-uri: CommonOAuth2Provider 에서 구글은 기본으로 설정되어 있음 : "{baseUrl}/{action}/oauth2/code/{registrationId}"
naver: # https://developers.naver.com/docs/login/devguide/devguide.md
client-id: ${NAVER_CLIENT_ID}
client-secret: ${NAVER_CLIENT_SECRET}
scope:
- name
- email
client-name: Naver
authorization-grant-type: authorization_code
redirect-uri: ${NAVER_REDIRECT_URI}
kakao: # https://developers.kakao.com/docs/latest/ko/kakaologin/common#user-info
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
client-authentication-method: client_secret_post
scope:
- profile_nickname
# - account_email # 카카오는 email 받으려면 비즈니스앱으로 전환 필요
client-name: Kakao
authorization-grant-type: authorization_code
redirect-uri: ${KAKAO_REDIRECT_URI}
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-info-authentication-method: header
user-name-attribute: response # Naver 응답 json: resultCode, message, response 중 response 지정
kakao:
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-info-authentication-method: header
user-name-attribute: id # Kakao 응답 값 id, connected_at, properties, kakao_account 중 id 지정
스프링 시큐리티의 기본적인 oauth2 로그인은 사실 거의 대부분 application.yml 에서 내가 코드 작성도 안하고 이루어진다고 봐도 된다.
물론 커스터마이징을 하기 시작하면 인터페이스를 구현하거나 상속받아서 해야 하는 것들이 있는데, 그냥 기본적인 것들은 여기서 다 설정한다.
security: oauth2: client: 영역 내부에 두가지 영역이 존재하는데 registration 과 provider 영역이다.
registration 영역
해당 플랫폼 (구글 네이버 등) 에 인증 요청을 할때 필요한 정보들을 적는다.
client-id, client-secret, scope 등 OAuth2 를 실제 구현할때 필요했던 값들이다.
(scope 는 내가 리소스 서버에게 인가(Authorization) 받을 리소스 범위를 나타낸다)
OAuth2 는 처음에 auth_code 를 받아서 인증 요청을 하고, 인증 완료시 token 을 받는다.
받은 token 으로 리소스에 인가 요청을 받는다.
그래서 직접 구현할때는 유저를 회원가입 처리할때 리소스 서버에 두 번 요청을 보내야 했다.
스프링 시큐리티는 이 과정을 모두 스스로 하기 때문에 스프링 시큐리티를 사용하는 사람 입장에서는 OAuth 의 과정을 잘 모른다면 한 번에 끝내는것처럼 보인다.
provider 영역
여기서 provider 는 네이버 카카오 구글 같은 각 플랫폼을 의미한다.
그런데 지금 보면 provider 영역에는 구글이 없다.
이건 스프링이 대표적인 provider 에 대해서는 oauth2 에 필요한 정보들을 이미 CommonOAuth2Provider 에 만들어놨기 때문이다.
CommonOAuth2Provider
package org.springframework.security.config.oauth2.client;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistration.Builder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
public enum CommonOAuth2Provider {
GOOGLE {
public Builder getBuilder(String registrationId) {
Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"openid", "profile", "email"});
builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
builder.issuerUri("https://accounts.google.com");
builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
builder.userNameAttributeName("sub");
builder.clientName("Google");
return builder;
}
},
GITHUB {
public Builder getBuilder(String registrationId) {
Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"read:user"});
builder.authorizationUri("https://github.com/login/oauth/authorize");
builder.tokenUri("https://github.com/login/oauth/access_token");
builder.userInfoUri("https://api.github.com/user");
builder.userNameAttributeName("id");
builder.clientName("GitHub");
return builder;
}
},
FACEBOOK {
public Builder getBuilder(String registrationId) {
Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_POST, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"public_profile", "email"});
builder.authorizationUri("https://www.facebook.com/v2.8/dialog/oauth");
builder.tokenUri("https://graph.facebook.com/v2.8/oauth/access_token");
builder.userInfoUri("https://graph.facebook.com/me?fields=id,name,email");
builder.userNameAttributeName("id");
builder.clientName("Facebook");
return builder;
}
},
OKTA {
public Builder getBuilder(String registrationId) {
Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"openid", "profile", "email"});
builder.userNameAttributeName("sub");
builder.clientName("Okta");
return builder;
}
};
private static final String DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}";
private CommonOAuth2Provider() {
}
protected final Builder getBuilder(String registrationId, ClientAuthenticationMethod method, String redirectUri) {
Builder builder = ClientRegistration.withRegistrationId(registrationId);
builder.clientAuthenticationMethod(method);
builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE);
builder.redirectUri(redirectUri);
return builder;
}
public abstract Builder getBuilder(String registrationId);
}
google, github, facebook. okta 에 관련된 내용은 이미 여기 정의되어 있고
스프링 시큐리티는 해당 provider 에 대해서는 디폴트로 얘내를 가져다 쓰게 된다.
provider 영역에서 다른건 다 보면 알겠는데 user-name-attribute 가 뭔지 이해가 안갈수 있다.
이건 이전에 OAuth2 를 직접 구현할때도 좀 힘들었던 부분이라고 이전에 말했는데,
provider 들은 JSON 형태로 데이터를 리턴하는데 provider 들 마다 key 값이 다르고, 어떤 데이터들은 중첩된 JSON 형태로 되어있는 등 다 다르게 데이터를 보내온다는 점이다.
따라서 user-name-attribute: 에 회원가입을 시킬때 필요한 user name 정보가 담겨있는 key 를 적어줘야 한다.
예를들어 네이버는 아래와 같이 데이터를 보내온다.
그렇기 때문에 naver 는 user-name-attribute: response 라고 해줘야 response 를 키값으로 갖는 데이터들을 가져와서 최초 요청때 회원가입 처리를 할 수 있다.
SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final PrincipalOAuth2UserService principalOAuth2UserService;
@Autowired
public SecurityConfig(PrincipalOAuth2UserService principalOAuth2UserService) {
this.principalOAuth2UserService = principalOAuth2UserService;
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(request -> request
//.anyRequest().authenticated() // 인증만 되면 접근 가능한 경로
.requestMatchers("/", "/members", "/members/login", "/errors/**", "/login").permitAll() // 인증없이 접근 가능 경로
.requestMatchers(RegexRequestMatcher.regexMatcher("/pages/[0-9]+/comments")).permitAll() // Page 에 속한 Comment 요청
.requestMatchers(RegexRequestMatcher.regexMatcher("/pages/[0-9]+")).permitAll() // 작성글들은 인증 없이 볼 수 있음
.requestMatchers("/js/**", "/css/**", "/bootstrap/**", "/ckeditor5/**", "/img/**", "/*.ico", "/error").permitAll()
.requestMatchers("/page/create", "/page/upload",
"/members/pages", // 로그인한 Member 의 글작성 목록
"/pages/[0-9]+/edit", // 로그인한 Member 가 작성한 {pageId} 글 수정
"/comments", // Comment 작성
"/comments/[0-9]+") // Comment 삭제
.hasRole("USER") // "ROLE_USER" role 이 있어야 접근 가능한 경로 (자동 prefix: ROLE_)
.anyRequest().authenticated() // 이외에는 모두 인증만 있으면 접근 가능
)
.formLogin(formLogin -> formLogin
.loginPage("/login")
.usernameParameter("username")
.passwordParameter("password")
.permitAll()
.failureHandler(authenticationFailureHandler()) // 로그인 실패시 핸들러
)
// OAuth2 로그인 처리
.oauth2Login(oauth2Login -> oauth2Login
.loginPage("/login")
.userInfoEndpoint(userInfo -> userInfo
// PrincipalOAuth2UserService extends DefaultOAuth2UserService 가 회원가입 처리함
.userService(principalOAuth2UserService))
)
.csrf(csrf -> csrf.disable());
return http.build();
}
// 로그인 실패 핸들러
@Bean
AuthenticationFailureHandler authenticationFailureHandler() {
return new SimpleUrlAuthenticationFailureHandler("/login?error=true");
}
}
스프링 시큐리티 설정 클래스에서 OAuth2 를 위해 추가해야 하는 부분은 .oauth2Login 부분 뿐이다.
.userInfoEndpoint 는 provider 가 보낸 유저 정보를 내가 받아서 어떻게 처리할것인가에 대한 부분이다.
지금 userInfo.userService(principalOAuth2UserService) 로 설정되어 있는데
유저 정보를 처리하는 userService 를 principalOAuth2UserService 가 담당하도록 한 것이다.
principalOAuth2UserService 는 DefaultOAuth2UserService를 상속하는 커스텀 클래스다.
PrincipalOAuth2UserService extends DefaultOAuth2UserService
@Service
@Slf4j
public class PrincipalOAuth2UserService extends DefaultOAuth2UserService {
private final PasswordEncoder passwordEncoder;
private final MemberService memberService;
@Autowired
public PrincipalOAuth2UserService(PasswordEncoder passwordEncoder, MemberService memberService) {
this.passwordEncoder = passwordEncoder;
this.memberService = memberService;
}
// spring security 는 auth_code 를 리소스 서버에서 받아서, access_token 을 요청하고 받는 과정을 스스로 처리한다
// 즉 우리는 access_token 을 알 필요도 없다..
// OAuth2UserRequest 에 이미 access_token 담겨 있다
// 사용자가 구글 로그인 -> 리소스 서버가 auth_code 클라이언트에 리턴 및 리다이렉트 -> 발급 받은 auth_code 리소스 서버에 보내 access_token 요청 -> 리소스서버는 access_token 클라이언트에 발급
// 여기 까지의 정보가 OAuth2UserRequest 에 담겨있고, 이제 이걸 이용해서 필요한 회원 정보들을 리소스 서버에 요청 가능
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
log.info("userRequest = {}", userRequest);
// ClientRegistration{registrationId='google', clientId='', clientSecret='', clientAuthenticationMethod=client_secret_basic, authorizationGrantType=org.springframework.security.oauth2.core.AuthorizationGrantType@5da5e9f3, redirectUri='{baseUrl}/{action}/oauth2/code/{registrationId}', scopes=[profile, email], providerDetails=org.springframework.security.oauth2.client.registration.ClientRegistration$ProviderDetails@1eac847c, clientName='Google'}
log.info("userRequest.getClientRegistration() = {}", userRequest.getClientRegistration());
log.info("userRequest.getAccessToken().getTokenValue() = {}", userRequest.getAccessToken().getTokenValue());
log.info("userRequest.getClientRegistration() = {}", userRequest.getClientRegistration());
OAuth2User oAuth2User = super.loadUser(userRequest);
// {sub=113278514104204816500, name=이세현, given_name=세현, family_name=이, picture=https://lh3.googleusercontent.com/a/ACg8ocLjmyFdD4xwZx25hfhq4DEzJ7HpOiEH11PvmGg6RD-c=s96-c, email=dltpgustpgus@gmail.com, email_verified=true, locale=ko}
log.info("super.loadUser(userRequest).getAttributes() = {}", oAuth2User.getAttributes());
OAuth2UserInfo oAuth2UserInfo = null;
// GOOGLE
if (userRequest.getClientRegistration().getRegistrationId().equals("google")) {
oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
}
// NAVER
else if (userRequest.getClientRegistration().getRegistrationId().equals("naver")) {
// naver 가 리턴 해주는 json
// oAuth2User.getAttributes() = {resultcode=00, message=success, response={id=Espin_Vgi-JRn4SxQzLlTDg1Pz58s-DL3ZXN1GkGphQ, email=chadol51@naver.com, name=이세현}}
oAuth2UserInfo = new NaverUserInfo((Map)oAuth2User.getAttributes().get("response"));
}
// KAKAO
else if (userRequest.getClientRegistration().getRegistrationId().equals("kakao")) {
// oAuth2User.getAttributes() = {id=3233146583, connected_at=2023-12-20T04:09:49Z, properties={nickname=이세현}, kakao_account={profile_nickname_needs_agreement=false, profile={nickname=이세현}}}
oAuth2UserInfo = new KakaoUserInfo(oAuth2User.getAttributes());
} else {
log.info("지원하지 않는 provider");
throw new OAuth2AuthenticationException("지원하지 않는 provider");
}
// 회원가입
String provider = oAuth2UserInfo.getProvider();
String providerId = oAuth2UserInfo.getProviderId();
String email = oAuth2UserInfo.getEmail();
String username = provider + "_" + providerId; // google_45312134...
String password = passwordEncoder.encode("password");
String role = "ROLE_USER";
// 회원 중복 확인
Member memberEntity = memberService.findByUsername(username).orElse(null);
// 중복 아니면 새로 생성후 저장
if (memberEntity == null) {
memberEntity = new Member(username, password, email, role, provider, providerId, LocalDateTime.now());
memberService.save(memberEntity);
}
// Authentication 에 담기게됨
return new PrincipalDetails(memberEntity, oAuth2User.getAttributes());
}
}
PrincipalOAuth2UserService 는 DefaultOAuth2UserService 를 상속하고
DefaultOAuth2UserService 는 OAuth2UserService 인터페이스를 구현한다.
OAuth2UserService 인터페이스
@FunctionalInterface
public interface OAuth2UserService<R extends OAuth2UserRequest, U extends OAuth2User> {
/**
* Returns an {@link OAuth2User} after obtaining the user attributes of the End-User
* from the UserInfo Endpoint.
* @param userRequest the user request
* @return an {@link OAuth2User}
* @throws OAuth2AuthenticationException if an error occurs while attempting to obtain
* the user attributes from the UserInfo Endpoint
*/
U loadUser(R userRequest) throws OAuth2AuthenticationException;
}
OAuth2UserService 인터페이스는 한가지 메소드만 있는데 loadUser 다.
인증, 인가를 마치고 provider 가 보내온 유저 정보가 loadUser 에 OAuth2UserRequest 타입으로 들어온다.
그리고 loadUser() 는 OAuth2User 타입을 리턴한다.
이 OAuth2User 타입은 SecurityContext 의 Authentication 내부에 저장된다.
즉! 인증(Authentication) 과정 인가(Authorization) 과정은 모두 우리가 application.yml 에 설정한 정보들을 갖고 스프링 시큐리티가 다 처리해주고,
우리는 loadUser 에 OAuth2UserRequest 에 담겨서 오는 유저의 정보를 갖고 회원가입 처리를 하고, OAuth2User 타입으로 반환해주면 끝이다.
일반 회원등록과 OAuth2 회원등록
OAuth2 를 잠깐 지우고 생각해보자.
스프링 시큐리티는 UserDetailsService 가 인증을 처리하는데 UserDetails 타입을 받아서 처리한다.
그래서 보통 UserDetails 인터페이스를 구현하는 커스텀 클래스를 만들어서 유저 정보를 저장한다.
PrincipalDetails
@Getter
public class PrincipalDetails implements UserDetails {
private Member member;
// OAuth2User 에 담겨있는 attributes
private Map<String, Object> attributes;
// 일반 로그인 생성자
public PrincipalDetails(Member member) {
this.member = member;
}
// OAuth2 로그인 생성자
public PrincipalDetails(Member member, Map<String,Object> attributes) {
this.member = member;
this.attributes = attributes;
}
// 해당 User 의 권한을 리턴
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
ArrayList<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return member.getRole();
}
});
return collect;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.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;
}
}
UserDetails 를 구현하는 PrincipalDetails 클래스다.
결국 PrincipalDetails 가 최종적으로 인증을 완료하고 Autentication 영역에 저장된다.
이제 OAuth2 를 다시 생각해보면, OAuth2UserService 의 loadUser() 에서 OAuth2User 타입을 반환하고 OAuth2User 가 최종적으로 Authentication 영역에 저장된다.
- 일반 회원등록 : UserDetails 타입이 최종적으로 Authentication 영역에 저장됨
- OAuth2 회원등록 : OAuth2User 타입이 최종적으로 Authentication 영역에 저장됨
그래서 인증을 마친 후에 컨트롤러에서 다음과 같이 principal 을 꺼내보려고 하면 문제가 생긴다
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
authentication.getPrincipal(); // 문제
일반 회원으로 로그인한 경우 authentication.getPrinciplal() 로 나오는 타입이 UserDetails 타입이고,
OAuth2 회원으로 로그인한 경우 나오는 타입이 OAuth2User 타입이다.
이렇게 서로 다른 타입이 저장된다.
해결 방법은 Authentication 영역에 저장되는 객체가 UserDetails, OAuth2User 둘 다 구현하도록 하면된다.
예를들어 여기서는 PrincipalDetails 가 userDetails, OAuth2User 둘다 구현하도록 했는데 이렇게 하면 PrincipalDetails 타입이 최종적으로 Authentication 영역에 저장되고, getPrincipal() 로 꺼내도 PrincipalDetails 타입으로 꺼내면 된다.
OAuth2User 는 OAuth2AuthenticatedPrincipal 을 상속받는데 여기서 중요한건 getAttributes() 다.
public interface OAuth2AuthenticatedPrincipal extends AuthenticatedPrincipal {
/**
* Get the OAuth 2.0 token attribute by name
* @param name the name of the attribute
* @param <A> the type of the attribute
* @return the attribute or {@code null} otherwise
*/
@Nullable
@SuppressWarnings("unchecked")
default <A> A getAttribute(String name) {
return (A) getAttributes().get(name);
}
/**
* Get the OAuth 2.0 token attributes
* @return the OAuth 2.0 token attributes
*/
Map<String, Object> getAttributes();
/**
* Get the {@link Collection} of {@link GrantedAuthority}s associated with this OAuth
* 2.0 token
* @return the OAuth 2.0 token authorities
*/
Collection<? extends GrantedAuthority> getAuthorities();
}
auttributes 에 결국 provider 가 보내준 유저 정보들이 담긴다.