Spring Security Oauth 2.0 with Google

수정이·2022년 8월 19일
0

security-jwt

목록 보기
2/7
post-thumbnail

Oauth 2.0이 뭐야?

Oauth 2.0에 대해서는 아래 강좌에 설명이 잘 되어 있으니 아래 강좌를 들어보자.
Oauth에 대한 설명이 기가막히네


Oauth를 사용하여 Google 로그인

Spring Boot는 Oauth-client 라는 dependency를 지원한다. 이 라이브러리를 사용하면 Oauth를 통해 Google, Facebook 등 소셜 로그인을 편리하게 할 수 있다.

일반적으로 Oauth를 통한 로그인 방법은

1. Code를 받아서 인증을 완료한다.
2. Code를 통해 AccessToken을 받아 사용자 정보에 접근할 권한을 받는다.
3. AccessToken을 통해 사용자의 프로필 정보를 가져올 수 있다.
4-1. 프로필 정보를 토대로 회원가입을 진행하거나
4-2. 프로필 정보 + 추가 정보를 통해 회원가입을 진행한다.

이제 구글 로그인을 통해 회원가입을 진행해보겠다.
먼저 Google api console에서 Oauth 클라이언트 아이디와 비밀번호를 생성한다. (구글에 검색)
그 다음 Oauth2-client dependency를 추가한다. (구글에 검색)
그 다음 application 파일에 다음과 같이 추가한다.

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 발급받은 아이디
            client-secret: 발급받은 비밀번호
            scope: 어떤 정보가 필요한지
              - email
              - profile

로그인 폼에 <a href="/oauth2/authorization/google">구글 로그인</a> 이 코드를 추가한다.
/oauth2/authorization/google는 고정 값이기 때문에 똑같이 써야 구글 로그인 창으로 이동된다.

그 다음 SecurityConfig 파일에 구글 로그인을 위한 코드를 더 추가해준다.

 @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.authorizeRequests()
                ...
                .and()
                .oauth2Login()
                .loginPage("/loginForm")

        return http.build();
    }

다음 3줄의 코드를 추가하면 a태그를 통해 구글 로그인 창으로 이동이 되지만 로그인을 해도 그 다음으로 진행이 되지 않는다.
왜냐하면 구글 로그인이 완료된 뒤에 후처리가 필요하기 때문이다.

 @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf().disable();
    http.authorizeRequests()
            ...
            .and()
            .oauth2Login()
            .loginPage("/loginForm")
            .userInfoEndpoint()
            .userService(principalOauth2UserService);

    return http.build();
}

다음 2줄을 추가한다. principalOauth2UserService에서 후처리를 해준다.

PrincipalOauth2UserService 클래스 생성

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        
        System.out.println("userRequest.getClientRegistration() = " + userRequest.getClientRegistration());
        System.out.println("userRequest.getAccessToken().getTokenValue() = " + userRequest.getAccessToken().getTokenValue());

        System.out.println("super.loadUser(userRequest).getAttributes() = " + super.loadUser(userRequest).getAttributes());

        return super.loadUser(userRequest);
    }
}

DefaultOAuth2UserService 를 상속받은 PrincipalOauth2UserService는 구글 로그인을 한 뒤에 loadUser 가 구글로부터 받은 OAuth2UserRequest 를 통해 후처리를 해준다.

중간 정리하고 다시 공부합시다.

OAuth의 동작 방식을 정리하자면,

1. 구글 로그인 버튼을 클릭하면 구글 로그인창으로 이동되고 로그인을 완료한다.
2. 로그인을 완료하면 'OAuth-Client'가 'code'를 받아서 리턴해준다.
3. 'code'를 통해 'AccessToken'을 요청하여 받는다.
4. 'AccessToken'은 위 코드에 'userRequest'에 들어가 있다.
5. 'loadUser' 메소드를 호출하여 구글로부터 회원 프로필을 받는다.

이 과정을 거쳐서 우리는 프로필 정보를 받을 수 있다.

컨트롤러에서 Security Session에 접근하기

IndexController 클래스에 다음과 같은 코드를 추가하자.

@ResponseBody
@GetMapping("/test/login")
public String testLogin(Authentication authentication, 
                        @AuthenticationPrincipal PrincipalDetails principalDetail) {
    System.out.println("/test/login =================");
    PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
    System.out.println("principalDetails = " + principalDetails.getUser());
    System.out.println("userDetails = " + principalDetail.getUser());
    return "세션 정보 확인하기";
}

이전에 쓴 포스트에서 시큐리티 세션은 Authentication 타입의 객체만 저장할 수 있다는 것을 배웠다.
그래서 우리는 매개변수로 Authentication 타입의 객체를 주입받고(DI),
우리가 구현한 PrincipalDetails 타입으로 다운 캐스팅하면, 그 안에 로그인하여 저장된 유저의 정보에 접근할 수 있다.

다른 방법으로는 @AuthenticationPrincipal 어노테이션을 통해서 접근하는 방법이다.
이 방법은 다운캐스팅 없이 Authentication 타입안에 들어가 있는 PrincipalDetails 타입으로 받아주면 된다.

여기서 주의할 점은 지금 받은 유저의 정보는 OAuth를 통해서 로그인한 유저의 정보가 아닌 홈페이지에서 회원가입을 한 다음 로그인한 유저의 정보이다.

그렇다면 OAuth를 통해 로그인한 유저의 정보는 어떻게 접근할 수 있을까?
그 방법은 다음과 같다.

IndexController 클래스에 다음과 같은 코드를 추가하자.

@ResponseBody
@GetMapping("/test/oauth/login")
public String testOauthLogin(Authentication authentication,
                             @AuthenticationPrincipal OAuth2User oauth) {
    System.out.println("/test/oauth/login =================");
    OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
    System.out.println("oAuth2User = " + oAuth2User.getAttributes());
    System.out.println("oauth = " + oauth.getAttributes());
    return "Oauth 세션 정보 확인하기";
}

방금 추가한 코드와 이전에 추가한 코드를 비교하면 하나 빼고는 다 똑같을 것이다.
(방금 추가한 코드를 2번 코드라 하고, 이전에 추가한 코드를 1번 코드라 하자.)

1번 코드와 2번 코드의 차이점은 주입받는 객체의 타입이다.
1번 코드에서는 UserDetails를 구현한 PrincipalDetails 타입으로 받았지만,
2번 코드에서는 OAuth2User 타입으로 받았다.

일반 로그인을 할 때는 Spring Security가 UserDetails 타입으로 Authentication에 저장하지만,
OAuth 로그인을 할 때는 OAuth2User 타입으로 저장한다.

Authentication에 저장되는 타입이 제각각이면 개발자는 타입마다 컨트롤러를 만들어야 한다.
그러면 코드 양이 늘어나서 보기에도 좋지않다.

이를 해결하기 위해 UserDetailsOAuth2User를 동시에 구현한 어떠한 클래스를 만들면 될 것이다.
그것이 바로 PrincipalDetails이다!!

PrincipalDetails클래스에 다음과 같이 추가하자!

public class PrincipalDetails implements UserDetails, OAuth2User {

    (생략)

    @Override
    public Map<String, Object> getAttributes() {
        return null;
    }

    @Override
    public String getName() {
        return null;
    }
}

두 메소드는 implement 한 메소드이다.


구글 로그인을 통한 회원가입

이제 구글로그인을 했을 때, DB에 구글 프로필 정보를 통해 회원가입을 하는 코드를 작성해 보자.
먼저 PrincipalDetailsServicePrincipalOauth2UserService 클래스를 만든 이유를 살펴보자.
Authentication 타입은 UserDetailsOAuth2User타입만 받는다고 하였다.
만약 우리가 둘 중 한개만으로 로그인을 진행했다면 PrincipalDetails 클래스를 만들지 않고, 그냥 Authentication에 둘 중 하나가 들어가도록 했을 것이다.
그렇지만 우리는 일반 로그인도 필요하고, OAuth 로그인도 필요했기 때문에 PrincipalDetails를 만들어서 두 타입을 구현하게 한 것이다.

이제 구글 로그인을 통해서 회원가입을 진행해보자.
OAuth2User 타입은 Attributes를 필요로 한다. 이것이 있어야 정상적인 로그인이 가능하다.

Attributes가 무엇이냐면, PrincipalOauth2UserService클래스에서 loadUser 메소드는 userRequest를 받게 된다. 이 안에 다음과 같은 Attributes가 있다.

// userRequest.getAttributes()
{
sub=147490145755120067183, 
name=이정수, 
given_name=정수, 
family_name=, 
picture=https://lh3.googleusercontent.com/DmcxfJD7pfJ2-cxl6=s96-c, 
email=1996dododog@gmail.com, 
email_verified=true, 
locale=ko
}

이 정보들을 통해 User객체를 만들고 회원가입을 진행할 것이다.
우선 우리는 PrincipalDetails 타입의 객체를 만들어야 로그인이 정상적으로 되기 때문에
PrincipalDetails 클래스에 생성자를 추가할 것이다.

// OAuth 로그인
public PrincipalDetails(User user, Map<String, Object> attributes) {
    this.user = user;
    this.attributes = attributes;
}

그 다음 OAuth2User를 implement하면서 같이 받은 메소드들도 수정해 줄것이다.

@Override
public Map<String, Object> getAttributes() {
    return attributes;
}

@Override
public String getName() {
    return null;
}

getName()은 사용하지 않기 때문에, 수정하지 않았다.

다음으로 User 클래스를 수정해줄 것이다.

@Entity
@Data
@NoArgsConstructor
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String username;
    private String password;
    private String email;
    private String role; // ROLE_USER, ROLE_MANAGER, ROLE_ADMIN

    private String provider;
    private String providerId;

    @CreationTimestamp
    private Timestamp createDate;

    @Builder
    public User(String username, String password, String email, String role, String provider, String providerId, Timestamp createDate) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
        this.provider = provider;
        this.providerId = providerId;
        this.createDate = createDate;
    }
}

이제 구글 로그인을 하면 받은 회원의 프로필 정보로 회원가입을 하는 코드를 작성해보자.
구글 로그인의 후처리는 PrincipalOauth2UserService 클래스에서 한다고 하였다.
회원가입도 후처리니까 이 클래스에서 진행하면 된다.

@Service
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

		(생략)
        
        OAuth2User oAuth2User = super.loadUser(userRequest);
        
        // 회원가입을 강제로 진행
        String provider = userRequest.getClientRegistration().getRegistrationId(); // google
        String providerId = oAuth2User.getAttribute("sub");
        String username = provider + "_" + providerId; // google_숫자
        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);
        }

        return new PrincipalDetails(userEntity, oAuth2User.getAttributes());
    }
}

위와 같이 코딩하였다.

userRequest.getClientRegistration().getRegistrationId()의 값은 "google"인데 나중에 OAuth로그인을 추가할 때, 사용자 아이디가 겹칠 수 있으니 앞에 "google"을 추가하여 헷갈리지 않도록 한것이다.

oAuth2User.getAttribute("sub")는 구글에서 제공해주는 사용자 식별값이며 고유값이다.

bCryptPasswordEncoder.encode("겟인데어");의 "겟인데어"는 아무 의미없다. 비밀번호를 아무렇게나 지정해도 된다. 왜냐면 일반 로그인을 하지 않을 것이기 때문이다.

return new PrincipalDetails(userEntity, oAuth2User.getAttributes())을 보면 PrincipalDetails로 반환이 가능하다. 그 이유는 OAuth2User 구현한 클래스이기 때문이다.

이렇게 OAuth 로그인을 통해 회원가입을 강제로 해주는 코드를 작성하였다.


기타

우리가 시큐리티 세션에 쉽게 접근할 수 있도록 도와주는 @AuthenticationPrincipal 어노테이션은 언제 생성이 될까?

PrincipalDetailsService 클래스의 loadUserByUsername 메소드가 끝날 때,
PrincipalOauth2UserService 클래스의 loadUser 메소드가 끝날 때 생성된다.


참고

스프링부트 시큐리티 & JWT 강의

profile
공부는 꾸준히... 글쓰기도 꾸준히...

0개의 댓글