Post

[Spring Security] OAuth2

Spring Security를 통해 OAuth2 기능을 사용하는 방법

[Spring Security] OAuth2

✏️ OAuth

OAuth (Open Authorization)

: 사용자(Resource Owner)가 자신의 비밀번호를 제공하지 않고도 서비스(Client)가 사용자를 대신하여 다른 서비스(Resource Server)의 자원에 접근할 수 있도록 권한을 위임하는 프로토콜

OAuth에 대한 설명과 동작원리는 이 사이트에서 자세히 설명되어 있으니 참고하도록 하자.

간단하게 동작 과정을 설명하자면

  1. 사용자 승인: 사용자는 애플리케이션(Client)에서 로그인 요청을 받습니다.

  2. 인증 서버 요청: Client는 Authorization Server에 사용자 승인 요청을 보냅니다.
  3. 승인 코드 발급: Authorization Server는 사용자 승인을 받으면 인증 코드를 발급합니다.
  4. 토큰 요청: Client는 이 코드를 Authorization Server에 제출하여 Access Token을 요청합니다.
  5. Access Token 발급: Authorization Server는 인증된 Client에게 Access Token을 발급합니다.
  6. 리소스 서버 접근: Client는 이 Access Token을 사용해 Resource Server의 보호된 리소스에 접근합니다.


이제, 이 OAuth를 Spring Security가 어떻게 사용하는지 알아보도록 하자.


■ W/Spring Security

Spring Security가 OAuth2 프로토콜을 어떻게 지원하는지 알아보자. 일단, build.gradle에 의존성을 추가해야 한다.

1
2
3
4
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
}


의존성을 추가하면, 새로운 필터가 등록되어 OAuth2 인증을 처리한다!

  • OAuth2LoginAuthenticationFilter
    로그인 시 OAuth2 인증 흐름을 처리하는 필터
  • BearerTokenAuthenticationFilter
    리소스 서버에서 요청을 받을 때 액세스 토큰을 검증하는 필터


로그인 필터는 다음과 같이 동작한다.

  1. 리디렉션: 사용자가 보호된 리소스에 접근 시, OAuth2LoginAuthenticationFilter는 OAuth2 제공자의 로그인 페이지로 리디렉션한다.
  2. 인증 완료: 사용자가 OAuth2 제공자에서 인증을 완료하면, 인증 서버가 클라이언트 애플리케이션에 인증 코드를 반환한다.
  3. 액세스 토큰 요청: 클라이언트 애플리케이션은 인증 코드를 사용해 인증 서버에 액세스 토큰을 요청
  4. 액세스 토큰 수신: 클라이언트 애플리케이션이 액세스 토큰을 받은 후, 보호된 리소스에 접근 가능




✏️ 구현 방법

리소스 서버인 Google을 활용한 OAuth2 로그인 기능을 구현해보자. build.gradle에 의존성이 추가되어 있다고 가정한다.

1. Google OAuth2 가입

google API Console 사이트에 접속한 뒤, OAuth2 client-id,client-secret을 발급 받는다.

그런 다음, application.yml 파일에 다음과 같은 정보를 추가한다.

1
2
3
4
5
6
7
8
9
10
security:
  oauth2:
    client:
      registeration:
        google:
          client-id: 발급받은 client-id
          client-secret: 발급받은 client-secret
        scope:
        - email
        - profile  #사용하려는 기능들 적기




2. OAuth2 로그인 설정

Google 로그인을 위한 URL 경로는 고정되어 있다! Google OAuth2 경로: /oauth2/authorization/google

로그인 화면에 다음과 같은 링크를 추가해준다.

1
<a href = "/oauth2/authorization/google">구글 로그인</a>


⚠️ 사용자가 해당 링크로 접속하면, 404가 뜰 것이다! ➜ SecurityConfig 클래스에서 OAuth2 클라이언트를 설정하지 않았기 때문이다.

Spring Security는 자동으로 OAuth2 로그인 필터를 구성해주지만, 보안 설정 클래스에서 이를 명시적으로 지정해야 한다.


이전 포스팅에서 작성했던 SecurityConfig 클래스에 다음과 같은 내용을 추가해준다.

1
2
3
.and()
.oauth2Login() // OAuth2 로그인을 활성화
.loginPage("/loginForm") // 사용자가 OAuth2 로그인을 시도할 때 이 URL로 리디렉션


전체 SecurityConfig 클래스의 내용은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Configuration 
@EnableWebSecurity 
public class SecurityConfig {

	@Bean
	public BCryptPasswordEncoder encodePwd() {
		return new BCryptPasswordEncoder();
	}

	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http.csrf().disable();
		http.authorizeRequests()
				.antMatchers("/user/**").authenticated()
				.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_USER')")
				.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
				.anyRequest().permitAll()
				
                .and()
				.formLogin()
				.loginPage("/loginForm")
                .loginProcessingUrl("/login")
				.defaultSuccessUrl("/")
                
				.and()
                .oauth2Login()
                .loginPage("/loginForm")
		return http.build();
	}
}


이제, /oauth2/authorization/google 링크를 누르면 Google 로그인 페이지가 뜰 것이다. 사용자는 해당 페이지에서 자신의 Google 아이디로 로그인 하면 된다.


일반적으로, 로그인을 완료하면 Google은 사용자에게 AuthorizationCode를 반환한다. 애플리케이션(client)은 AuthorizationCode를 사용해서 Access Token을 요청해야 한다.

하지만!

Spring Security는 이 과정을 자동으로 처리해서, Access Token과 사용자 프로필 정보를 가져온다!




3. 사용자 정보 추출

OAuth2 로그인이 완료되면, OAuth2LoginAuthenticationFilter 는 자동으로 OAuth2UserService 를 호출하도록 동작한다.

∴ OAuth2UserService 에서 사용자 정보를 추출할 수 있다.


SecurityConfig 클래스에 다음과 같은 코드를 추가한다.

1
2
3
.userInfoEndpoint()
.userService(customOAuth2UserService); // 사용자 정보를 처리할 커스텀 서비스를 설정
// customOAuth2UserService 는 DefaultOAuth2UserService를 상속한다.

이제, 로그인이 완료된 후 CustomOAuth2UserService의 loadUser() 라는 메소드가 자동으로 호출된다.


우선, CustomOAuth2UserService 을 구현해서 사용자 정보를 추출해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
	    // userRequest는 Access Token 및 사용자 정보를 가지고 있다.
        // userRequest는 Spring Security에 의해 자동적으로 주입된다.
        
        OAuth2User oauth2User = super.loadUser(userRequest); 
        // userRequest에서 사용자 정보를 추출
        
        return oauth2User;
    }
}
  • userRequest 는 Access Token 및 사용자 정보를 가지고 있다.
  • super.loadUser(userRequest) 메소드를 통해 사용자 정보를 OAuth2User 타입으로 추출할 수 있다.
  • Google 로그인 후에 받은 사용자 정보는 다음과 같은 요소들로 구성되어 있다.
key value 설명
sub 1234985749 google에서 사용자의 식별 번호
name 주재원 이름
given_name 재원 first name
family_name 주 last name
email myEmail@gmail.com 이메일 정보
locale ko 지역 정보




4. 자동 회원가입

CustomOAuth2UserService 에서 추출한 정보를 바탕으로 자동 회원가입 기능을 구현할 수 있다.

다음과 같이, google에서 받은 사용자 정보를 재구성해서 DB에 사용자를 저장(회원가입)할 것이다.

  • username ex) google_1234985749

  • password ex) 암호화(특정문자열)

  • email ex) myEmail@gmail.com

  • role ex) ROLE_USER

  • provider ex) google

  • providerId ex) 1234985749


자동 회원가입 로직을 loadUser() 메소드 안에 넣어준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

	...
    
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oauth2User = super.loadUser(userRequest); 
        // userRequest에서 사용자 정보를 추출
        
        OAuth2UserInfo oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
        // GoogleUserInfo 는 따로 구현해줘야 함
        
        
        Optional<User> userOptional = userRepository.findByProviderAndProviderId(
        		oAuth2UserInfo.getProvider(), oAuth2UserInfo.getProviderId());
                
        User user;
        
		if (userOptional.isPresent()) {
        	// 이미 회원가입이 되어 있음
            
			user = userOptional.get();
			user.setEmail(oAuth2User.getEmail());
			userService.save(user);
		} else {
        	// 회원가입이 안되어 있음
            
			user = User.builder()
					.username(oAuth2UserInfo.getProvider() + "_" + oAuth2UserInfo.getProviderId())
					.email(oAuth2UserInfo.getEmail())
					.role("ROLE_USER")
					.provider(oAuth2UserInfo.getProvider())
					.providerId(oAuth2UserInfo.getProviderId())
					.build();
			userService.save(user);
		}
        
        return ???
}

회원가입 로직을 다 작성했다.

  • userRequest에서 사용자 정보를 추출했다.
  • 회원가입이 되어있는 경우는 Email만 업데이트 하도록 만들었다.
  • 회원가입이 되어있지 않으면, 새로운 User 객체를 만들어서 DB에 저장했다.

무엇을 return 해야 할까?

loadUser 메서드에서 OAuth2User 타입의 객체를 반환해야 한다. 반환된 객체는 SecurityContextHolder 에 저장된다.

그렇다면, OAuth2User 타입 객체인 oauth2User 를 반환하면 되지 않을까?


⚠️ 예상치 못한 오류가 발생할 수 있다!!!




5. 회원가입 문제 해결

oauth2User 를 그냥 반환하면 어떤 오류가 발생할까?

사실, 반환하는 것 자체로 오류가 발생하지는 않는다. 하지만, 애플리케이션의 다른 로직을 작성할 때 많은 영향을 준다.


지금 우리의 애플리케이션에는 2가지 로그인 방법이 존재한다.

  1. 일반 로그인
  2. OAuth2 기반 로그인

일반 로그인은 이전 포스트에서 구현했다. 일반 로그인을 완료한 후에는 UserDetails 타입의 객체인 PrincipalDetails 가 Authentication 에 저장된다.


애플리케이션에서 기능을 구현할 때, 사용자 정보를 사용해야 한다면 Authentication 객체에서 PrincipalDetails 를 꺼내올 것이다.

이런식으로

1
2
3
4
5
6
7
@GetMapping("/userInfo")
public @ResponseBody String userInfo(
					@AuthenticationPrincipal PrincipalDetails userDetails) {
                    
           userDetails.getUser();
           ...
}


만약! loadUser 메서드에서 그냥 oAuth2User 객체를 반환하도록 작성했다면?

일반 로그인은 문제가 없다. 문제는 OAuth2로 로그인 했을 시에 발생한다.


1
public @ResponseBody String userInfo(@AuthenticationPrincipal PrincipalDetails userDetails) 

이 코드에서 오류가 발생할 것이다. ➜ oauth2User 객체는 PrincipalDetails 타입으로 캐스팅 될 수 없기 때문이다.


정리하자면

  1. Google 로그인 후 CustomOAuth2UserService 을 호출
  2. loadUser() 메소드에서 OAuth2User 타입 객체를 반환
  3. 해당 객체를 Authentication 내부에 저장
  4. Authentication 을 활용할 때, 일반 로그인/ OAuth2 로그인을 따로 처리해야 한다는 문제 발생
    • 일반 로그인: Authentication 내부에 PrincipalDetail 타입이 저장되어 있음
    • OAuth2 로그인: Authentication 내부에 OAuth2User 타입이 저장되어 있음

따라서, 문제를 해결하려면 다음 2가지를 수정하면 된다.

  1. PrincipalDetails 클래스가 UserDetails 뿐만 아니라, OAuth2User 인터페이스도 구현하도록 수정한다.
    • 이렇게 하면, 다형성에 의해 PrincipalDetails 객체는 OAuth2User 타입으로 저장될 수 있다.
  2. loadUser() 는 PrincipalDetails 타입 객체를 return 하도록 한다.




6. 완성

위의 2가지 수정사항을 적용하면 OAuth2를 활용한 회원가입 기능이 완성된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class PrincipalDetails implements UserDetails, OAuth2User{
// OAuth2User도 구현하도록 수정

	private User user;
	private Map<String, Object> attributes;

	// 일반 로그인시 사용
	public PrincipalDetails(User user) {
		this.user = user;
	}
	
	// OAuth2 로그인시 사용
	public PrincipalDetails(User user, Map<String, Object> attributes) {
		this.user = user;
		this.attributes = attributes;
	}
    
    ...
1
2
3
4
5
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    // 회원 가입 로직
    return new PrincipalDetails(user, oAuth2User.getAttributes())
}




✏️ 정리

Spring Security를 활용해서 로그인을 하면, 어떤 로그인 방식을 사용했던지 간에 SecurityContextHolder 내부의 Authentication 객체에 사용자 정보가 저장된다.
이때, Authentication 객체에 저장될 수 있는 타입은 단 두 가지이다.

  1. UserDetails 타입
  2. OAuth2User 타입
    두 타입을 모두 구현한 PrincipalDetails 객체는 Authentication 에 저장될 수 있다.

일반 로그인을 사용했던, OAuth 기반 로그인을 사용했던지 간에, 사용자 정보는 PrincipalDetails 타입 객체로 저장된다.

애플리케이션에서 사용자 정보를 사용해야 할 때는 Authentication 에서 PrincipalDetails 객체를 꺼내와서 사용하면 된다.


OAuth2를 사용하는 Spring Security의 처리 로직은 다음과 같다.

  1. 사용자 -> /oauth2/authorization/google 요청
  2. Google 인증 완료 -> OAuth2LoginAuthenticationFilter 가 처리를 시작
  3. OAuth2LoginAuthenticationProvider가 CustomOAuth2UserService 호출
  4. CustomOAuth2UserService가 OAuth2User 타입 객체 PrincipalDetails를 반환
  5. SecurityContextHolder에 저장

사용자 인증 완료, 보호된 리소스 접근 가능


REFERENCE

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

This post is licensed under CC BY 4.0 by the author.