Spring Security - OAuth2 session 방식으로 구현 해보기(Google,Facebook,Naver)
OAuth2.0 동작 원리
- OAuth2 인증 과정
- 클라이언트가 서버로 로그인 요청:
- 사용자가 클라이언트 애플리케이션에서 로그인 버튼을 클릭하면, 클라이언트는 /oauth2/authorization/서비스명 엔드포인트로 요청을 보냅니다.
- OAuth2AuthorizationRequestRedirectFilter 동작:
- 클라이언트의 요청을 받으면, OAuth2AuthorizationRequestRedirectFilter가 동작하여 사용자를 등록된 소셜 로그인 인증 서버(예: 네이버, 구글)로 리다이렉션합니다.
- 외부 소셜 로그인 인증 서버로 리다이렉션:
- 사용자는 네이버나 구글 로그인 페이지로 이동하여 인증을 진행합니다.
- 인증 서버에 등록된 우리 서버의 주소로 리다이렉션:
- 사용자가 소셜 로그인 인증을 성공하면, 인증 서버는 우리 서버의 리다이렉션 URL (예: /login/oauth2/code/서비스명)로 인증 코드를 포함하여 요청을 보냅니다.
- OAuth2LoginAuthenticationFilter 동작:
- 리다이렉션 URL을 받으면, OAuth2LoginAuthenticationFilter가 동작하여 이 요청을 처리합니다.
- 이 필터는 인증 코드를 OAuth2LoginAuthenticationProvider로 넘깁니다.
- OAuth2LoginAuthenticationProvider 코드를 이용해 액세스 토큰 요청:
- OAuth2LoginAuthenticationProvider는 인증 서버로 인증 코드와 클라이언트 정보를 보내 액세스 토큰을 요청합니다.
- 액세스 토큰 발급:
- 인증 서버는 요청을 검증하고 액세스 토큰을 발급하여 클라이언트 애플리케이션에 반환합니다.
- 사용자 유저 정보 요청:
- 클라이언트 애플리케이션은 발급받은 액세스 토큰을 이용하여 외부 소셜 로그인 서비스의 리소스 서버에 사용자 정보를 요청합니다.
- 유저 정보 반환:
- 리소스 서버는 액세스 토큰을 검증한 뒤, 요청한 사용자 정보를 클라이언트 애플리케이션에 반환합니다.
- 커스텀유저디테일 서비스 동작:
- 반환된 사용자 정보를 OAuth2UserDetailService를 통해 OAuth2UserDetails로 넘깁니다.
- 이후 세션 저장, DB 저장 등의 시큐리티 로직이 동작합니다.
- 인증 코드:
- 사용자가 소셜 로그인 인증을 완료한 후, 소셜 로그인 인증 서버가 우리 서버의 리다이렉션 URL (/login/oauth2/code/서비스명)로 인증 코드를 전달합니다.
- 액세스 토큰:
- OAuth2LoginAuthenticationProvider가 인증 서버로 인증 코드와 클라이언트 정보를 보내어 액세스 토큰을 요청하고 발급받습니다.
- 유저 정보 요청 토큰:
- 발급받은 액세스 토큰을 이용하여 외부 소셜 로그인 서비스의 리소스 서버에 사용자 정보를 요청할 때 사용됩니다.
- 클라이언트가 서버로 로그인 요청:
구글 소셜 로그인 신청
https://console.cloud.google.com/apis/
저는 이미 만들어서 Spring-boot-oauth라는 API가 있지만, 사용자 인증 정보 탭에 사용자 인증정보 만들기 추가 후
없으신 분들은 새로 생성하시면 됩니다! 이름은 마음대로 정하시면 돼요.
이후 리디렉션 URL, 가져올 정보 등을 설정하면 됩니다.
(이 부분은 자세하게 설명하지 않을게요)
application.yml 파일에 security 설정 추가
Google과 FaceBook같은 글로벌 기업은 스프링 시큐리티에서 기본적으로 정보를 내장하고 있지만
Naver는 그렇지 않기 때문에 따로 등록해줘야해 적는 코드가 긴거에요.
+) redirect-uri로 /login/oauth2/code/{google,facebook,naver ...} 이 부분은 고정 값 입니다!
security:
oauth2:
client:
registration:
google:
client-id: your_client_id
client-secret: your_client_serect
scope:
- email
- profile
facebook:
client-id: your_client_id
client-secret: your_client_serect
scope:
- email
- public_profile
naver:
client-id: your_client_id
client-secret: your_client_serect
scope:
- name
- email
client-name: Naver
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8000/login/oauth2/code/naver
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 #회원 정보를 json으로 받는데 response라는 키값으로 네이버가 리턴해줌
PrincipalDetails 구현
그리고 이제 PrincipalDetails를 구현할 건데요. 그 전에 스프링 시큐리티 Authentication 아키텍쳐를 보고 갈게요.
스프링 시큐리티는 스프링 시큐리티 세션(=SecurityContextHolder)을 들고 있는데요.
그러면 원래 서버 세션 영역 안에 시큐리티가 관리하는 세션이 따로 존재하게 됩니다.
스프링 시큐리티 세션에는 무조건 Authentication 객체만 들어갈 수 있는데요.
Authentication가 시큐리티 세션 안에 들어가 있다는 것은 로그인이 된 상태라는 의미입니다.
Authentication에는 2개의 타입이 들어갈 수 있는데 UserDetails, OAuth2User입니다.
문제점
- 이때 세션이 2개의 타입으로 나눠졌기 때문에 컨트롤러에서 처리하기 복잡해진다는 문제점이 발생합니다.
- 일반적인 로그인을 할 때에는 UserDetails타입으로 Authentication 객체가 만들어지고
- 구글 로그인처럼 OAuth 로그인을 할 때에는 OAuth2User 타입으로 Authentication 객체가 만들어지기 때문입니다.
해결방법
- PrincipalDetails에 UserDetails와 OAuth2User를 implements(다중상속)합니다.
➡️ 이렇게 되면 우리는 PrincipalDetails만 사용하면 됩니다!
아래 코드처럼 UserDetails와 OAuth2User 다중상속하고, user 객체를 반환할 수 있도록 user 값을 넣어줍니다.
@Data
public class PrincipalDetails implements UserDetails, OAuth2User {
private User user;
public Map<String, Object> attributes;
//일반 생성자
public PrincipalDetails(User user){
this.user=user;
}
//Oauth 로그인할 때 사용되는 생성자
public PrincipalDetails(User user, Map<String, Object> attributes){
this.user=user;
this.attributes=attributes;
}
@Override
public Map<String, Object> getAttributes() {
return getAttributes();
}
//해당 User의 권한을 리턴하는 곳!
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() {
//user.getRole은 string타입이라서 바로 리턴할 수 없기 때문에
//collect를 오버라이드 해서 던져줘야함.
@Override
public String getAuthority() {
return user.getRole();
}
});
return collect;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
//우리 사이트에서 1년동안 회원이 로그인을 안하면 휴먼계정으로 변환되기 했다면?
//user.getLoginDate();를 들고와서
//현재 시간 - 로그인 시간을 해서 1년을 초과하면
//return false하면 된다.
return true;
}
@Override
public String getName() {
return null;
}
}
PrincipalDetailsService 구현
시큐리티 설정에서 SeurityConfig에 loginProcessiingUrl("/login")이 실행되면 자동으로 UserDetailsService타입으로 IoC되어 loadUserByUsername함수가 실행됩니다.
그래서 UserDetailsService를 상속해서 PrincipalDetailsService에 loadUserByUsername 오버라이드해야해요!
시큐리티 session - Authentication Type만 저장 가능하고, 이는 UserDetails와 OAuthDetails만 저장 가능하다고 했죠?
loadUserByUsername를 오버라이드 해주면 알아서 session안에 (Authentication(내부 UserDetails)를 넣어줍니다!
그리고 해당 함수가 종료될 때 자동으로 @AuthenticationPrincipal 어노테이션이 만들어집니다.
@Service
public class PrincipalDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User userEntity = userRepository.findByUsername(username);
if(username != null) { //유저 네임이 없다면
return new PrincipalDetails(userEntity); //PrincipalDetails에 userEntity를 넣어 user 세션에 넣어준다.
}
return null;
}
}
PrincipalOauth2UserService
OAuth2UserService에 기본으로 내장되어 있는 loadUser를 Overide하여 OAuth2 제공 업체로부터 사용자 정보(userRequest)를 얻어옵니다.
(여기서 loadUser란! OAuth2 제공 업체로부터 회원 프로필을 받는 함수입니다)
➡️이를 통해 getClientRegistration()으로 어떤 Oauth로 로그인 했는지을 알 수 있고,
getTokenValue()로 토큰을 확인할 수 있습니다.
OAuth2UserService를 구현한 PrincipalOauth2UserService로 PrincipalDetails를 활용해서 userRepository에 user정보가 있는지 findByUsername으로 확인하고, 없으면 save합니다
@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Autowired
private UserRepository userRepository;
//구글로부터 받은 userRequest데이터에 대한 후처리 되는 함수
//해당 함수가 종료될 때 자동으로 @AuthenticationPrincipal 어노테이션이 만들어진다.
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
System.out.println("getClientRegistration :"+userRequest.getClientRegistration()); //registrationId로 어떤 Oauth로 로그인했는지 알 수 있음.
System.out.println("getTokenValue:"+userRequest.getAccessToken().getTokenValue());
OAuth2User oAuth2User = super.loadUser(userRequest);
//구글 로그인 버튼 클릭-> 구글 로그인 창 -> 로그인을 완료 -> code를 리턴(OAuth-Client라이브러리가 받음)->AccessToken을 요청
//여기까지가 userRequest정보, -> 회원 프로필을 받아야함 이게 loadUser함수! -> 구글로부터 회원프로필을 받는다.
System.out.println("getAttributes:"+oAuth2User.getAttributes());
String provider = userRequest.getClientRegistration().getClientId(); //google
String providerId = oAuth2User.getAttribute("sub");
String username = provider+"_"+providerId; //google_sub
String password = bCryptPasswordEncoder.encode("신재은");
String email = oAuth2User.getAttribute("email");
String role = "ROLE_USER";
User userEntity = userRepository.findByUsername(username);
if(userEntity==null){
userEntity=User.builder()
.username(username)
.password(password)
.email(email)
.role(role)
.provider(provider)
.providerId(providerId)
.build();
userRepository.save(userEntity);
}else {
System.out.println("당신은 이미 구글 로그인을 한 적이 있어 자동 회원가입이 되었었답니다");
}
//getAttributes된 값으로 강제로 회원가입을 진행시킨다.
return new PrincipalDetails(userEntity, oAuth2User.getAttributes()); //이렇게 되면 세션정보로 안의 값이 들어간다.
}
}
OAuth2UserInfo
각 OAuth2 제공 업체마다 회원가입을 따로 진행하는 것은 객체지향프로그래밍에 어긋나기 때문에 Interface로 받는 Id, Provider, Email, Name를 만들어줍니다.
import org.springframework.scheduling.support.SimpleTriggerContext;
public interface OAuth2Userinfo {
String getProviderId(); //OAuth2 제공 업체의 Id
String getProvider(); //OAuth2 제공 업체이름 ex)Google, FaceBook, Naver
String getEmail();
String getName();
}
NaverUserInfo
import java.util.Map;
public class NaverUserInfo implements OAuth2Userinfo{
private Map<String, Object> attributes; //getAttributes()를 받는다.
public NaverUserInfo(Map<String, Object> attributes){
this.attributes = attributes;
}
@Override
public String getProviderId() {
return (String) attributes.get("id");
}
@Override
public String getProvider() {
return "naver";
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
}
GoogleUserInfo
import java.util.Map;
public class GoogleUserInfo implements OAuth2Userinfo{
private Map<String, Object> attributes; //getAttributes()를 받는다.
public GoogleUserInfo(Map<String, Object> attributes){
this.attributes = attributes;
}
@Override
public String getProviderId() {
return (String) attributes.get("sub");
}
@Override
public String getProvider() {
return "google";
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
}
FacebookUserInfo
import java.util.Map;
public class FacebookUserInfo implements OAuth2Userinfo{
private Map<String, Object> attributes; //getAttributes()를 받는다.
public FacebookUserInfo(Map<String, Object> attributes){
this.attributes = attributes;
}
@Override
public String getProviderId() {
return (String) attributes.get("id");
}
@Override
public String getProvider() {
return "facebook";
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
}