[Spring Security] OAuth2

JUJU·2024년 7월 3일
0

Spring

목록 보기
18/21

✏️ 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에 의존성을 추가해야 한다.

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 파일에 다음과 같은 정보를 추가한다.

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

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

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

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

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


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

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

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

@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 클래스에 다음과 같은 코드를 추가한다.

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

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


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

@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 로그인 후에 받은 사용자 정보는 다음과 같은 요소들로 구성되어 있다.
keyvalue설명
sub1234985749google에서 사용자의 식별 번호
name주재원이름
given_name재원first name
family_namelast name
emailmyEmail@gmail.com이메일 정보
localeko지역 정보



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() 메소드 안에 넣어준다.

@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 타입의 객체인 PrincipalDetailsAuthentication 에 저장된다.


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

이런식으로

@GetMapping("/userInfo")
public @ResponseBody String userInfo(
					@AuthenticationPrincipal PrincipalDetails userDetails) {
                    
           userDetails.getUser();
           ...
}

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

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


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 타입으로 저장될 수 있다.
  1. loadUser()PrincipalDetails 타입 객체를 return 하도록 한다.



6. 완성

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

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;
	}
    
    ...
@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. OAuth2LoginAuthenticationProviderCustomOAuth2UserService 호출
  4. CustomOAuth2UserServiceOAuth2User 타입 객체 PrincipalDetails를 반환
  5. SecurityContextHolder에 저장

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


REFERENCE

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

profile
개발자 지망생

0개의 댓글

관련 채용 정보