[작성일: 2023. 09. 17]
구글 로그인
우선 구글 OAuth 프로젝트를 만들고 OAuth 동의 화면(외부) ➡️ OAuth 클라이언트 ID 만들기 순서로 진행하면 된다.
승인된 리디렉션 URI에는 다음과 같은 주소를 넣어주면 된다.
http://localhost:8080/login/oauth2/code/google
이 주소는 구글 로그인이 완료되고 나면 구글 서버쪽에서 인증이 되었다는 code를 돌려준다. 우리는 이 코드를 받아 access token을 요청할 수 있다. 이 token으로 사용자의 개인 정보에 접근할 수 있는 권한이 생긴다. 이 URI가 있기 때문에 컨트롤러를 따로 만들 필요가 없다.
그 다음 나는 gradle을 사용하기 때문에 gradle.build에 의존성을 추가하고, yml에 google 관련된 코드를 작성했다.
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client:2.6.2'
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- email
- profile
client-id와 client-secret은 다른 사람에게 노출되면 안 되기 때문에 환경변수 처리를 했다.
그리고 google 로그인창을 띄우려면 SecurityConfig의 filterChain 아래 쪽에 해당 코드를 추가해야한다.
.and()
.oauth2Login()
.loginPage("/loginForm");
- oauth2Login()
- OAuth2.0을 활성화하겠다는 의미로 이를 통해 Google, Kakao 등 OAuth2.0 제공자를 사용해서 사용자가 로그인할 수 있다.
구글 로그인을 하려면 loginForm에 로그인 버튼을 추가해줘야 한다.
<a href="/oauth2/authorization/google">구글 로그인</a>
여기까지 되었다면 로그인 버튼을 눌렀을 때 구글 로그인 창이 뜨게된다.
구글 회원 프로필 정보 받아오기
User entity와 SpringConfig에 다음과 같은 코드를 추가한다. (의존성 주입 잊지 말기)
cofing ➡️ oauth ➡️ PrincipalOauth2UserService 클래스도 생성해준다.
private String provider;
private String providerId;
.userInfoEndpoint()
.userService(principalOauth2UserService);
- userInfoEndpoint()
- OAuth2 로그인 프로세스 중 사용자 정보를 가져오는 단계를 구성하기 위한 메서드
- OAuth2 로그인을 수행한 후 제공자(Google, Naver 등)로부터 사용자의 정보를 가져올 때 사용된다.
- userService()
- OAuth2 로그인을 성공적으로 수행한 후 제공자로부터 받은 사용자 정보를 처리할 때 서비스를 설정하는 메서드
- principalOauth2UserService는 사용자의 정보를 가져오고 처리하는데 사용된다. (따로 생성해주어야 한다.)
@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
// 구글에서 받은 userRequest 데이터에 대한 후처리 되는 함수
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
System.out.println("getClientRegistration : " + userRequest.getClientRegistration());
System.out.println("getAccessToken : " + userRequest.getAccessToken().getTokenValue());
System.out.println("getAccessToken : " + userRequest.getAccessToken());
System.out.println("getAttributes : " + super.loadUser(userRequest).getAttributes());
return super.loadUser(userRequest);
}
}
PrincipalOauth2UserService 클래스는 DefaultOauth2UserService를 상속받으며 loadUser를 오버라이드 해주면 된다.
loadUser 메서드는 userRequest 데이터에 대한 후처리 되는 함수이다. sysout으로 userRequest에 있는 것들을 살펴보자.
- getClientregistration은 사용된 OAuth2 클라이언트의 등록 정보(Google, Naver)를 반환한다. (registrationId, clientId 등)
- getAccessToken은 액세스 토큰 정보를 반환한다.
- getAttributes()는 구글로부터 받은 사용자의 속성(이름, 이메일) 등을 반환한다.
getAttributes에 담긴 정보는 다음과 같이 사용할 예정이다.
- sub : 구글에 회원가입 한 ID의 primary key 같은 것
- password = "암호화(겟인데어)"
- email = email
- username은 = google_sub 이런 식으로 하면 중복될 일이 없다.
- role = ROLE_USER
- provider = google
- providerId = sub
이제 getAttributes로 우리 사이트에서 회원가입을 시킬 수 있다.
Authentication 객체가 가질 수 있는 2가지 타입
다시 정리를 해보자면 구글 로그인 버튼을 클릭하면 구글 로그인 창이 뜨고 로그인이 완료된다.
그럼 우리는 OAuth-Client 라이브러리를 통해 code를 리턴받고, AccessToken을 요청할 수 있다.
여기까지가 userRequest 정보이며 loadUser 함수를 호출해서 구글로부터 회원프로필을 받는다.
이제 컨트롤러에서 testLogin 코드를 작성해보자.
@GetMapping("/test/login")
public @ResponseBody String testLogin(Authentication authentication,
@AuthenticationPrincipal UserDetails userDetails) {
System.out.println("/test/login =====================");
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
System.out.println("authentication = " + principalDetails.getUser());
System.out.println("userDetails = " + userDetails.getUsername());
return "세션 정보 확인하기";
}
일반 로그인을 해서 /test/login으로 이동하면 authentication 정보를 가져올 수 있다.
@AuthenticationPrincipal은 UserDetails를 가지고 있기 때문에 이 애노테이션을 사용해서 username을 확인할 수도 있다.
@AuthenticationPrincipal은 PrincipalDetails도 받을 수 있다.(UserDetails를 상속받고 있기 때문에 다운캐스팅이 가능하다.)
PrincipalDetails를 받게 되면 getUsername()이 아니라 getUser()를 확인해볼 수 있다.
이번에는 구글로 로그인을 해보자.
하지만 구글 로그인 후 /test/login 으로 이동하면 ClasscastException 에러가 발생한다.
OAuth 로그인은 OAuth2User를 사용해서 getAttributes()를 받아야 하기 때문에 코드를 수정해주어야 한다.
@GetMapping("/test/oauth/login")
public @ResponseBody String testOauthLogin(Authentication authentication,
@AuthenticationPrincipal OAuth2User oauth) {
System.out.println("/test/oauth/login =====================");
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
System.out.println("authentication = " + oAuth2User.getAttributes());
System.out.println("oauth2User = " + oauth.getAttributes());
return "OAuth 세션 정보 확인하기";
}
코드 수정 후 로그인을 한 다음에 /test/oauth/login으로 이동하면 에러없이 getAttributes를 확인할 수 있다.
OAuth2User도 마찬가지로 @AutenticationPrincipal 애노테이션을 사용해서 getAttributes를 받아올 수 있다.
정리를 해보면, Spring Security는 자기만의 security session을 가지고 있다.
이 session에 들어갈 수 있는 타입은 Authentication 객체 뿐이다.
그래서 우리는 컨트롤러에서 DI를 할 수 있고, Authentication에는 UserDetails와 OAuth2User 타입이 들어갈 수 있다.
UserDetails는 일반 로그인을 하면 Authentication 객체 안에 들어가게 되고, OAuth 로그인을 하게 되면 OAuth2User타입이 Authentication 객체 안에 들어가게 된다.
하나의 컨트롤러에서 일반 로그인도 해야하고 구글 로그인도 해야하면 어떻게 해야될까?
정답은 엄청 간단하다.
x라는 클래스를 하나 만들어서 UserDetails와 OAuth2User를 구현하면 된다. 그 다음 x 클래스를 Authentication에 담으면 된다.
하지만 굳이 x라는 클래스를 만들 필요가 없다. PrincipalDetails가 이미 UserDetails를 implements 하고 있기 때문에 거기에 OAuth2User만 추가해주면 된다. 그 다음 OAuth2User 메서드들도 오버라이드 해주면 된다.
구글 로그인 및 자동 회원가입 진행
우리가 PrincipalDetails를 만든 이유는 두 가지가 있다.
첫번째는 security session에 들어갈 수 있는 객체는 Authentication 뿐이다. Authentication은 담을 수 있는 게 OAuth2User와 UserDetails가 있다.
회원가입을 하려면 유저 객체가 필요하다. OAuth2User와 UserDetails는 User를 포함하고 있지 않기 때문에 PrincipalDetails를 만들고 UserDetails를 implements를 했었다. 그렇게 해서 Authentication에 UserDetails 대신 PrincipalDetails를 change 하고 User 객체를 받아올 수 있었다. 그리고 OAuth2User까지 implements를 했기 때문에 이 둘은 유저 객체를 가지고 있다.
우리는 이제 PrincipalDetails를 사용해도 유저 객체를 꺼내올 수 있다.
getAttributes()는 Map<String,Object>으로 저장되어 있다. 다음과 같이 PrincipalDetails에서 OAuth2User에 관련된 메서드를 오버라이드 한다.
@Data
public class PrincipalDetails implements UserDetails, OAuth2User {
private User user; // composition
private Map<String, Object> attributes;
public PrincipalDetails(User user) {
this.user = user;
}
public PrincipalDetails(User user, Map<String, Object> attributes) {
this.user = user;
this.attributes = attributes;
} // OAuth 로그인
// ... 코드 생략
@Override
public String getName() {
return null;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
}
이제 구글 로그인을 하게 되면 PrincipalDetails는 OAuth 로그인의 정보도 가질 수 있게 된다.
attributes를 토대로 User 객체를 만들 수 있다.
@Service
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
private final BCryptPasswordEncoder bCryptPasswordEncoder;
private final UserRepository userRepository;
// 구글에서 받은 userRequest 데이터에 대한 후처리 되는 함수
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
System.out.println("getClientRegistration : " + userRequest.getClientRegistration());
System.out.println("getAccessToken : " + userRequest.getAccessToken().getTokenValue());
System.out.println("getAccessToken : " + userRequest.getAccessToken());
OAuth2User oAuth2User = super.loadUser(userRequest);
// 구글 로그인 버튼 클릭 -> 구글 로그인 창 -> 로그인 완료 -> code 리턴(OAuth-Client 라이브러리) -> AccessToken 요청
// 여기까지가 userRequest 정보 -> 구글로부터 회원 프로필을 받아야 함.(loadUser 함수)
System.out.println("getAttributes : " + oAuth2User.getAttributes());
// 회원가입
String provider = userRequest.getClientRegistration().getRegistrationId(); // google
String providerId = oAuth2User.getAttribute("sub");
String email = oAuth2User.getAttribute("email");
String username = provider + "_" + providerId; // google_sub
String password = bCryptPasswordEncoder.encode("겟인데어");
String role = "ROLE_USER";
// 이미 회원가입이 되어 있다면?
User user = userRepository.findByUsername(username);
if (user == null) {
user = User.builder()
.username(username)
.password(password)
.email(email)
.role(role)
.provider(provider)
.providerId(providerId)
.build();
userRepository.save(user);
}
return new PrincipalDetails(user, oAuth2User.getAttributes());
}
}
new PrincipalDetails를 할 수 있는 이유는 리턴타입이 OAuth2User고, PrincipalDetails는 OAuth2User를 구현했기 때문에 가능하다.
리턴되는 new PrincipalDetails는 Authentication 객체 안에 들어가게 된다. 그럼 이제 Authentication은 일반 로그인 한 유저와 OAuth로 로그인한 유저 두 가지를 들고 있게 된다.
그럼 이제 /user에서 유저 정보를 잘 가지고 오는지 확인해보자.
@GetMapping("/user")
public @ResponseBody String user(@AuthenticationPrincipal PrincipalDetails principalDetails) {
System.out.println("principalDetails = " + principalDetails.getUser());
return "user";
}
그런데 여기까지 진행하고 나니 순환참조 오류가 떴다.
강의대로 했는데 왜 나는 순환 참조라고 하지? 찾아보니 SpringContainer에서 처음 빈으로 등록하기 위해 생성한다.(싱글톤)
그래서 SecurityConfig 객체를 생성하는데 PrincipalOauth2UserService 객체를 이미 의존하고 있다.
이번엔 Principaloauth2UserService를 생성하는데 이미 여기서도 SecurityConfig에서 빈으로 등록한 BCrpytPasswordEncoder를 참조하고 있다.
즉, SecurityConfig ➡️ PrincipalOauth2UserService ➡️ PrincipalOauth2UserService ➡️ SecurityConfig의 순환구조가 된다.
그렇기 때문에 SecurityConfig ➡️ PrincipalOauth2UserService ➡️ CustomBCryptPasswordENcoder로 구조를 변경하면 된다.
SecurityConfig에서 빈으로 등록했던 BCryptPasswordencoder를 주석처리 해주고 따로 클래스를 만들어준 다음 컨테이너에 등록하면 된다고 한다.
@Component
public class CustomBCryptPasswordEncoder extends BCryptPasswordEncoder {
}
그럼 이제 순환참조 에러는 발생하지 않는다. (*도움: 인프런)
🐣 출처: 인프런 최주호님 강의
이 글은 인프런의 최주호님 SpringBoot Security & JWT 강의를 보고 작성한 글입니다.
강의를 들으면서 정리한 글이므로 틀린 내용이나 오타가 있을 수 있습니다.