[Spring Security] OAuth2
Spring Security를 통해 OAuth2 기능을 사용하는 방법
✏️ OAuth
OAuth (Open Authorization)
: 사용자(Resource Owner)가 자신의 비밀번호를 제공하지 않고도 서비스(Client)가 사용자를 대신하여 다른 서비스(Resource Server)의 자원에 접근할 수 있도록 권한을 위임하는 프로토콜
OAuth에 대한 설명과 동작원리는 이 사이트에서 자세히 설명되어 있으니 참고하도록 하자.
간단하게 동작 과정을 설명하자면
-
사용자 승인: 사용자는 애플리케이션(Client)에서 로그인 요청을 받습니다.
- 인증 서버 요청: Client는 Authorization Server에 사용자 승인 요청을 보냅니다.
- 승인 코드 발급: Authorization Server는 사용자 승인을 받으면 인증 코드를 발급합니다.
- 토큰 요청: Client는 이 코드를 Authorization Server에 제출하여 Access Token을 요청합니다.
- Access Token 발급: Authorization Server는 인증된 Client에게 Access Token을 발급합니다.
- 리소스 서버 접근: 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- 리소스 서버에서 요청을 받을 때 액세스 토큰을 검증하는 필터
로그인 필터는 다음과 같이 동작한다.
- 리디렉션: 사용자가 보호된 리소스에 접근 시,
OAuth2LoginAuthenticationFilter는 OAuth2 제공자의 로그인 페이지로 리디렉션한다. - 인증 완료: 사용자가 OAuth2 제공자에서 인증을 완료하면, 인증 서버가 클라이언트 애플리케이션에 인증 코드를 반환한다.
- 액세스 토큰 요청: 클라이언트 애플리케이션은 인증 코드를 사용해 인증 서버에 액세스 토큰을 요청
- 액세스 토큰 수신: 클라이언트 애플리케이션이 액세스 토큰을 받은 후, 보호된 리소스에 접근 가능
✏️ 구현 방법
리소스 서버인 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 |
| 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가지 로그인 방법이 존재한다.
- 일반 로그인
- 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 타입으로 캐스팅 될 수 없기 때문이다.
정리하자면
- Google 로그인 후
CustomOAuth2UserService을 호출loadUser()메소드에서OAuth2User타입 객체를 반환- 해당 객체를
Authentication내부에 저장Authentication을 활용할 때, 일반 로그인/ OAuth2 로그인을 따로 처리해야 한다는 문제 발생
- 일반 로그인:
Authentication내부에PrincipalDetail타입이 저장되어 있음- OAuth2 로그인:
Authentication내부에OAuth2User타입이 저장되어 있음
따라서, 문제를 해결하려면 다음 2가지를 수정하면 된다.
PrincipalDetails클래스가UserDetails뿐만 아니라,OAuth2User인터페이스도 구현하도록 수정한다.- 이렇게 하면, 다형성에 의해
PrincipalDetails객체는OAuth2User타입으로 저장될 수 있다.
- 이렇게 하면, 다형성에 의해
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객체에 저장될 수 있는 타입은 단 두 가지이다.
UserDetails타입OAuth2User타입
두 타입을 모두 구현한PrincipalDetails객체는Authentication에 저장될 수 있다.
일반 로그인을 사용했던, OAuth 기반 로그인을 사용했던지 간에, 사용자 정보는 PrincipalDetails 타입 객체로 저장된다.
애플리케이션에서 사용자 정보를 사용해야 할 때는 Authentication 에서 PrincipalDetails 객체를 꺼내와서 사용하면 된다.
OAuth2를 사용하는 Spring Security의 처리 로직은 다음과 같다.
- 사용자 ->
/oauth2/authorization/google요청 - Google 인증 완료 ->
OAuth2LoginAuthenticationFilter가 처리를 시작 OAuth2LoginAuthenticationProvider가CustomOAuth2UserService호출CustomOAuth2UserService가OAuth2User타입 객체PrincipalDetails를 반환- SecurityContextHolder에 저장
사용자 인증 완료, 보호된 리소스 접근 가능
