Chapter - Spring Security에서의 OAuth2 인증
OAuth 2의 개념과 OAuth 2의 인증 처리 흐름 등 이론적인 부분을 확인했으니 이제 아주 간단한 Spring Security 기반의 샘플 애플리케이션에 OAuth 2를 적용해 보면서 OAuth 2를 조금 더 구체적으로 이해해 보도록 하겠습니다.
추가적으로 OAuth 2와 JWT를 함께 사용해 Frontend와 Backend 간에 인증을 처리하는 방식을 살펴보면서 여러분들이 CSR 방식의 애플리케이션에 OAuth 2를 적용할 수 있는 기반을 마련해 보도록 하겠습니다.
학습 목표
- Spring Security 기반의 샘플 애플리케이션에 OAuth 2를 적용할 수 있다.
- Spring Security에서 지원하는 OAuth 2 인증을 위한 컴포넌트의 역할을 이해할 수 있다.
- OAuth 2와 JWT를 이용한 Frontend와 Backend의 인증 처리 흐름을 이해할 수 있다.
OAuth 2 인증을 위한 사전 작업
OAuth 2를 이용해 사용자의 로그인 인증을 처리하기 위해서는 OAuth 2 인증 프로토콜을 제공하는 신뢰할 만한 벤더(Google, Facebook, Github)를 먼저 선정해야 합니다.
물론 신뢰할 만한 벤더를 이용하는 대신에 자체적으로 Authorization Server를 직접 구축하는 방법이 있지만 해당 주제는 이번 코스의 범위를 넘어갑니다.
따라서 이번 챕터에서는 우리가 많이 사용하는 벤더 중 하나인 구글에서 제공하는 OAuth 2 인증 시스템을 이용하도록 하겠습니다.
구글 API 콘솔에서의 OAuth 2 설정
구글의 OAuth 2 시스템을 이용하기 위해서는 먼저 구글 API 및 서비스 콘솔에서 OAuth 2 시스템을 이용하기 위한 클라이언트 ID와 Secret을 생성해야 합니다.
아래의 링크를 이용해 구글 API 및 서비스 콘솔로 이동해 주세요.
1️⃣ 프로젝트 생성
- 메인 화면에 보이는 [프로젝트 만들기] 링크를 클릭합니다.
- 만약 링크가 보이지 않는다면 상단의 [프로젝트 선택]을 클릭한 후, 오픈된 팝업에서 [새 프로젝트] 링크를 클릭하세요.
- 새로운 프로젝트를 만듭니다.
- 프로젝트의 이름은 그림과 같이 여러분이 편한 이름으로 입력하면 됩니다.
- 나머지 항목은 디폴트 항목을 그대로 사용하면 됩니다.
- 생성한 프로젝트는 만들어지기까지 1분 내외의 시간을 기다려야 합니다.
- 사용 설정된 API 및 서비스 상단에 [프로젝트 선택] 메뉴를 클릭하고 생성한 프로젝트를 선택합니다. (또는 이미지에 보이는 [최근 프로젝트 선택]을 클릭합니다)
2️⃣ OAuth 동의 화면 만들기
생성된 프로젝트를 선택하면 API 및 서비스 대시보드를 확인할 수 있습니다.
- 왼쪽 메뉴에서 [OAuth 동의 화면]을 클릭합니다.
OAuth 동의 화면에서 [User Type]을 ‘외부’로 선택 체크한 뒤 [만들기] 버튼을 클릭합니다.
[앱 등록 수정] 화면에서 ‘앱 이름’과 ‘사용자 지원 이메일’, 개발자 연락처 정보에서 ‘이메일 주소’를 입력한 후 [저장 후 계속] 버튼을 클릭합니다.
[테스트 사용자] 화면에서 [저장 후 계속] 버튼을 클릭합니다.
3️⃣ 사용자 인증 정보 생성
왼쪽 목록에서 [사용자 인증 정보]를 클릭합니다.
[사용자 인증 정보 만들기]를 클릭한 후, [OAuth 클라이언트 ID]를 클릭합니다.
- 애플리케이션 유형은 웹 애플리케이션을 선택합니다.
- 애플리케이션의 이름을 입력합니다.
- 승인된 리디렉션 URI에 http://localhost:8080/login/oauth2/code/google를 입력합니다.
- [만들기] 버튼을 클릭합니다.
OAuth 클라이언트가 생성되었다는 화면이 보인다면 그림과 같이 클라이언트 ID와 클라이언트 보안 비밀번호(Secret)를 확인할 수 있습니다.
⭐ 클라이언트 ID와 클라이언트 보안 비밀번호(Secret)는 Spring Security 기반의 애플리케이션의 설정 정보로 사용되므로 안전하게 잘 보관해두길 바랍니다.
구글의 OAuth 2 인증 시스템을 사용하기 위한 사전 작업이 끝이 났습니다.
이제 구현을 시작해 보겠습니다.
핵심 포인트
- 구글의 OAuth 2 인증 시스템을 사용하기 위해서는 구글 API 콘솔에서 OAuth 클라이언트를 생성해야 한다.
- 생성된 OAuth 클라이언트의 클라이언트 ID와 클라이언트 보안 비밀번호(Secret)는 Spring Security 기반의 애플리케이션의 설정 정보로 사용되므로 유출되지 않도록 안전하게 잘 보관한다.
Hello, OAuth 2 샘플 애플리케이션 구현
여러분들이 Hello Spring Security 샘플 애플리케이션을 통해 Spring Security에 친숙해졌듯이 이번에는 Hello OAuth 2 샘플 애플리케이션을 통해 OAuth 2에 다가가 보도록 하겠습니다.
💡 이번 챕터에서는 Hello, Spring Security 챕터와 마찬가지로 서버 측에서 HTML을 렌더링 해주는 SSR(Server Side Rendering) 방식의 애플리케이션을 구현합니다.
의존성 추가
OAuth 2 인증을 사용하기 위해 제일 먼저 해야 할 작업은 OAuth 2에 대한 의존성을 추가하는 것입니다.
...
...
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' // (1)
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.boot:spring-boot-starter-security' // (2)
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // (3)
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.mapstruct:mapstruct:1.5.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.2.Final'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'com.google.code.gson:gson'
}
...
...
[코드 4-93] OAuth 2 인증을 사용하기 위한 의존성 추가
- 1)에서는 HTML 화면을 구성하기 위한 템플릿인 타임리프(Thymeleaf)를 추가합니다.
- Hello OAuth 2 애플리케이션은 Spring Security 기반의 애플리케이션이므로 (2)와 같이 spring-boot-starter-security를 추가합니다.
- Hello OAuth 2 애플리케이션은 구글의 OAuth 2 시스템을 이용하는 OAuth 2 클라이언트이므로 클라이언트로써의 역할을 하기 위해 spring-boot-starter-oauth2-client를 추가합니다.
보호된 웹 페이지
SSR 방식의 웹 애플리케이션은 HTML로 렌더링 되는 페이지가 존재합니다.
Hello OAuth 2 샘플 애플리케이션은 OAuth 2 인증을 통해 보호되는 아주 간단한 HTML 페이지를 포함하고 있습니다.
[그림 4-38] OAuth 2 인증을 통해 보호되는 리소스
[그림 4-38]은 OAuth 2로 로그인 인증에 성공하지 않으면 웹 브라우저에서 확인할 수 없는 HTML 화면입니다.
HTML 화면이 얼마나 예쁘게 구성되었는지는 중요하지 않습니다.
이 화면이 OAuth 2를 통해 잘 보호되느냐 그렇지 않으냐가 중요하다는 사실을 기억하세요.
hello-oauth2.html(src/main/resources/templates)
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Welcome to Hello OAuth 2.0</title>
</head>
<body>
<div style="text-align: center"><h2>Welcome to Hello OAuth 2.0!!</h2></div>
</body>
</html>
[코드 4-94] OAuth 2로 보호되는 hello-oauth2.html
코드 4-94는 [그림 4-38]의 HTML 코드입니다.
여러분들이 이 정도는 모두 다 아는 코드일 거라 생각하고 별도의 코드 설명은 하지 않겠습니다.
HelloHomeController
package com.springboot.hello_oauth2.home;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HelloHomeController {
@GetMapping("/hello-oauth2")
public String home() {
return "hello-oauth2";
}
}
[코드 4-95] hello-oauth2 화면에 대한 뷰를 리턴하는 HelloHomeController
코드 4-95는 hello-oauth2 화면에 대한 뷰를 리턴하는 HelloHomeController의 코드입니다.
SSR(Server Side Rendering) 방식의 핸들러(Controller) 메서드의 리턴 타입이 String이면 뷰 이름(hello-oauth2)을 리턴하며, 최종적으로 hello-oauth2.html을 웹브라우저로 전송합니다.
OAuth 2 인증을 위한 SecurityConfiguration 설정
이제 OAuth 2 인증에 성공해야지만 앞에서 작성한 hello-oauth2.html 화면이 브라우저에 표시되도록 Spring Security의 Configuration을 구성해 보도록 하겠습니다.
✅ Spring Boot의 자동 구성을 이용한 OAuth 2 인증 설정
Spring Security에서 OAuth 2 인증을 가장 간단하게 사용할 수 있는 방법은 바로 Spring Boot의 자동 구성을 이용한 방법입니다.
우리가 OAuth 2에 대한 자세한 설정 방법을 몰라도 Spring Boot의 자동 구성을 통해 대부분의 설정이 자동으로 구성됩니다.
SecurityConfiguration(V1)
package com.springboot.hello_oauth2.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.formLogin().disable()
.httpBasic().disable()
.authorizeHttpRequests(authorize -> authorize // (1)
.anyRequest().authenticated()
)
.oauth2Login(withDefaults()); // (2)
return http.build();
}
}
[코드 4-96] OAuth 2 로그인 설정이 추가된 SecurityConfiguration
코드 4-96은 OAuth 2 로그인을 위한 최소한의 설정이 추가된 SecurityConfiguration의 코드입니다.
코드에 대한 설명은 다음과 같습니다.
- (1)과 같이 인증된 request에 대해서만 접근을 허용하도록 authorize.anyRequest().authenticated()를 추가합니다.
- (2)와 같이 .oauth2Login(withDefaults()) 를 추가해서 OAuth 2 로그인 인증을 활성화합니다.
이제 이 상태에서 애플리케이션을 실행해 보겠습니다.
애플리케이션이 정상적으로 실행이 되면 좋겠지만 아쉽게도 아래와 같은 에러가 발생할 것입니다.
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of method setFilterChains in org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration required a bean of type 'org.springframework.security.oauth2.client.registration.ClientRegistrationRepository' that could not be found.
Action:
Consider defining a bean of type 'org.springframework.security.oauth2.client.registration.ClientRegistrationRepository' in your configuration.
에러 로그를 대충 유추해 보면 ClientRegistrationRepository라는 Bean이 없으니 SecurityConfiguration에 추가하라는 의미 같습니다.
분명히 어떤 설정이 빠진 것 같은데 어떤 설정이 빠졌을까요?
우리가 이전 챕터에서 구글 OAuth 2 시스템을 이용하기 위해 OAuth Client ID를 생성했던 것 기억날 거라 생각합니다.
즉 우리가 이용해야 할 OAuth 2 시스템에 대한 클라이언트 ID와 클라이언트 보안 비밀번호(Secret)를 설정하지 않았기 때문에 발생하는 에러입니다.
이 에러를 해결해 보도록 하겠습니다.
OAuth 2 클라이언트 등록 정보 추가
application.yml
spring:
h2:
console:
enabled: true
path: /h2
datasource:
url: jdbc:h2:mem:test
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
sql:
init:
data-locations: classpath*:db/h2/data.sql
security:
oauth2:
client:
registration:
google:
clientId: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # (1)
clientSecret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # (2)
...
...
[코드 4-97] 구글 OAuth 2 클라이언트 등록 정보가 추가된 application.yml
코드 4-97은 우리가 생성한 구글 OAuth 2 클라이언트 정보를 추가한 application.yml입니다.
- (1)은 OAuth 2 클라이언트 생성 시, 우리가 안전하게 보관하고 있는 클라이언트 ID입니다.
- (2)는 클라이언트 보안 비밀번호 즉, 클라이언트의 Secret입니다.
이제 애플리케이션이 실행될까요?
애플리케이션 실행 결과, 정상적으로 실행이 잘 됩니다.
이제 localhost:8080/hello-oauth2으로 접속해 보세요.
아마도 여러분들에게 친숙할 아래의 [그림 4-39]와 같은 구글의 로그인 화면이 뜰 것입니다.
[그림 4-39] 구글이 제공하는 로그인 화면
이제 여러분들의 구글 계정을 마우스로 클릭하고 로그인을 해보세요.
- Welcome to Hello OAuth 2.0!!라는 화면이 잘 보인다면 여러분은 아주 간단하지만 OAuth 2 인증을 통해 잘 보호된 Hello OAuth 2 샘플 애플리케이션을 성공적으로 구현한 것입니다.
💡 만약 OAuth 2의 설정을 정상적으로 구성했는데도 불구하고, 애플리케이션을 재실행해도 특별한 에러 메시지 없이 구글 로그인 화면이 뜨지 않고 [그림 4-38]의 Hello, OAuth 2 화면이 표시된다면, [Google 계정 관리] -> [보안] -> [다른 사이트 로그인 수단]에서 [Google 계정을 통한 로그인]을 선택한 후, 애플리케이션의 [액세스 권한 삭제] 버튼을 클릭해 애플리케이션의 권한을 제거해 주기 바랍니다.
우리가 JWT 유닛에서 학습했다시피 민감한 정보의 경우 application.yml 파일에 그대로 노출하는 것은 보안상 바람직하지 않습니다.
⭐ 만약 실무에서 OAuth 2 클라이언트 ID와 Secret 같은 민감한 정보를 설정한다면 OS의 시스템 환경 변수에 설정하거나 또는 application.yml 파일에 구성하는 프로퍼티 정보를 애플리케이션 외부의 안전한 경로에 위치시키는 등의 방식으로 사용해야 한다는 점을 명심하기 바랍니다.
✅ Configuration을 통한 OAuth 2 인증 설정
Spring Boot에서는 자동 구성을 통한 OAuth 2 인증 설정뿐만 아니라 Configuration을 통해 Bean을 등록함으로써 OAuth 2의 인증을 설정할 수 있습니다.
SecurityConfiguration(V2)
package com.springboot.hello_oauth2.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.client.CommonOAuth2Provider;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfiguration {
@Value("${spring.security.oauth2.client.registration.google.clientId}") // (1)
private String clientId;
@Value("${spring.security.oauth2.client.registration.google.clientSecret}") // (2)
private String clientSecret;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.formLogin().disable()
.httpBasic().disable()
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2Login(withDefaults());
return http.build();
}
// (3)
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
var clientRegistration = clientRegistration(); // (3-1)
return new InMemoryClientRegistrationRepository(clientRegistration); // (3-2)
}
// (4)
private ClientRegistration clientRegistration() {
// (4-1)
return CommonOAuth2Provider
.GOOGLE
.getBuilder("google")
.clientId(clientId)
.clientSecret(clientSecret)
.build();
}
}
[코드 4-98] Java Configuration을 통한 OAuth 2 인증 설정
코드 4-98은 Java Configuration을 이용해 OAuth 2 인증을 설정하는 코드입니다.
코드의 설명은 다음과 같습니다.
- (1)과 (2)에서는 application.yml 파일에 설정되어 있는 구글의 Client ID와 Secret을 로드합니다.
- (3)에서는 ClientRegistrationRepository를 Bean으로 등록합니다.앞에서 애플리케이션 실행 시, ‘ClientRegistrationRepository Bean이 존재하지 않는다’는 오류가 발생했던 것 기억날까요?
- (3-1)에서는 private 메서드인 clientRegistration()을 호출해서 ClientRegistration 인스턴스를 리턴 받습니다.
- (3-2)에서는 ClientRegistrationRepository 인터페이스의 구현 클래스인InMemoryClientRegistrationRepository의 인스턴스를 생성합니다.
- 클래스 이름에서도 알 수 있듯이 InMemoryClientRegistrationRepository는 ClientRegistration을 메모리에 저장합니다.
- ⭐ Spring Boot의 자동 구성 기능을 이용할 경우, application.yml 파일에 설정된 구글의 Client ID와 Secret 정보를 기반으로 우리 눈에는 보이지 않지만 내부적으로 ClientRegistrationRepository Bean이 생성되는 반면, 지금은 우리가 Configuration을 통해 ClientRegistrationRepository Bean을 직접 등록하고 있다는 사실을 기억하기 바랍니다.
- ClientRegistrationRepository는 ClientRegistration을 저장하기 위한 Responsitory입니다.
- (4)는 ClientRegistration 인스턴스를 생성하는 private 메서드입니다.
- (4-1)을 보면 Spring Security에서는💡 CommonOAuth2Provider라는 enum을 제공하는데 CommonOAuth2Provider 는 내부적으로 Builder 패턴을 이용해 ClientRegistration 인스턴스를 제공하는 역할을 합니다.
이제 애플리케이션을 실행하고 다시 웹 브라우저에서 localhost:8080/hello-oauth2으로 접속하면 구글의 로그인 화면이 뜨고, 로그인 인증에 성공하면 Hello OAuth 2 애플리케이션의 home 화면이 정상적으로 보이는 것을 확인할 수 있습니다.
💡 Spring Boot 자동 구성의 마법
만약 application.yml에 Client ID와 Client Secret만 추가하고, \[코드 4-96]의 SecurityConfiguration 클래스가 존재하지 않는다면 어떻게 될까요?
그래도 웹 브라우저에서 구글의 로그인 인증 화면은 정상적으로 표시되고, OAuth 2 인증이 정상 동작하는 것을 확인할 수 있습니다.
우리가 build.gradle dependences {…}에 implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' 를 추가하기만 하면 Spring Boot이 자동 구성이라는 마법을 부려서 내부적으로 알아서 OAuth 2의 기능을 활성화합니다.
Spring Boot의 자동 구성은 대단히 편리한 획기적인 방법이라는 사실을 다시 한번 깨달을 수 있을 거라 생각합니다.
⭐ 하지만 무조건적인 자동 구성보다는 명시적으로 특정 설정을 선언해서 유지보수 용이하고 가독성 있는 코드를 구성하는 것 역시 중요하다는 사실을 기억하기 바랍니다.
우리 챕터에서는 학습을 위해 자동 구성과 부분적인 수동 구성을 혼합해서 사용하겠습니다.
인증된 Authentication 정보 확인
Hello OAuth 2 샘플 애플리케이션에서 추가적으로 확인하면 도움이 되는 작업들이 있는데 그중 하나가 바로 구글의 OAuth 2 인증이 성공적으로 수행되었는지 최종적으로 확인해 보는 것입니다.
이미 구글 로그인 화면에서 정상적으로 로그인한 후, home 화면까지 확인했는데 뭘 더 확인할 필요가 있을까 싶습니다.
그래도 인증이 정상적으로 수행되면 SecurityContext에 인증된 Authentication이 저장되는 Spring Security의 특성을 이용해 마지막으로 인증된 Authentication이 사용자 정보를 잘 포함하고 있는지 몇 가지 방법을 통해 확인해 보도록 하겠습니다.
1️⃣ SecurityContext를 이용하는 방법
HomeController(V2)
package com.springboot.hello_oauth2.home;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HelloHomeController {
@GetMapping("/hello-oauth2")
public String home() {
var oAuth2User = (OAuth2User)SecurityContextHolder.getContext().getAuthentication().getPrincipal(); // (1)
System.out.println(oAuth2User.getAttributes().get("email")); // (2)
return "hello-oauth2";
}
}
[코드 4-99] 인증된 사용자 정보 얻기
코드 4-99는 HomeController의 home() 핸들러 메서드에서 인증된 사용자 정보를 얻는 코드입니다.
코드에 대한 설명은 다음과 같습니다.
- (1)에서는 SecurityContext에서 인증된 Authentication 객체를 통해 Principal 객체를 얻습니다. OAuth2로 로그인 인증을 수행했으므로 SecurityContext에 저장된 Principal은 OAuth2User 객체로 캐스팅할 수 있습니다.
- (2)에서는 OAuth2User 객체에 저장되어 있는 사용자의 정보 중에서 getAttributes() 메서드를 통해 사용자의 이메일 정보를 얻고 있습니다.
웹 브라우저에서 home() 핸들러 메서드로 request를 전송하면 OAuth 2 인증에 성공하기 전까지는 home() 핸들러 메서드가 호출되지 않습니다.
따라서 (1)과 같이 SecurityContext에서 인증된 Authentication을 얻은 후에 (2)와 같이 사용자의 이메일 주소를 출력했을 때, 정상적으로 이메일 주소가 출력된다면 OAuth 2 인증에 성공했다고 확신해도 됩니다.
2️⃣ Authentication 객체를 핸들러 메서드 파라미터로 전달받는 방법
HomeController(V3)
package com.springboot.hello_oauth2.home;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HelloHomeController {
@GetMapping("/hello-oauth2")
public String home(Authentication authentication) { // (1)
var oAuth2User = (OAuth2User)authentication.getPrincipal();
System.out.println(oAuth2User);
System.out.println("User's email in Google: " + oAuth2User.getAttributes().get("email"));
return "hello-oauth2";
}
}
[코드 4-100] 인증된 사용자 정보 얻기
코드 4-100에서는 (1)과 같이 인증된 Authenction을 핸들러 메서드의 파라미터로 전달받고 있습니다.
3️⃣ OAuth2User를 파라미터로 전달받는 방법
HomeController(V4)
package com.springboot.hello_oauth2.home;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HelloHomeController {
@GetMapping("/hello-oauth2")
public String home(@AuthenticationPrincipal OAuth2User oAuth2User) { // (1)
System.out.println("User's email in Google: " + oAuth2User.getAttributes().get("email"));
return "hello-oauth2";
}
}
[코드 4-101] 인증된 사용자 정보 얻기
코드 4-101에서는 (1)과 같이 @AuthenticationPrincipal 애너테이션을 이용해 OAuth2User 객체를 파라미터로 직접 전달받고 있습니다.
Authorization Server로부터 전달받은 Access Token 확인
구글의 OAuth 2 인증이 성공적으로 수행되면 내부적으로 리소스 서버에 접근할 때 사용되는 Access Token을 전달받게 됩니다.
OAuth 2 인증 이후, 전달받은 Access Token의 정보를 확인해 보도록 합시다.
1️⃣ OAuth2AuthorizedClientService를 DI 받는 방법
HomeController(V5)
package com.springboot.hello_oauth2.home;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HelloHomeController {
private final OAuth2AuthorizedClientService authorizedClientService;
// (1)
public HelloHomeController(OAuth2AuthorizedClientService authorizedClientService) {
this.authorizedClientService = authorizedClientService;
}
@GetMapping("/hello-oauth2")
public String home(Authentication authentication) {
var authorizedClient = authorizedClientService.loadAuthorizedClient("google", authentication.getName()); // (2)
// (3)
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
System.out.println("Access Token Value: " + accessToken.getTokenValue()); // (3-1)
System.out.println("Access Token Type: " + accessToken.getTokenType().getValue()); // (3-2)
System.out.println("Access Token Scopes: " + accessToken.getScopes()); // (3-3)
System.out.println("Access Token Issued At: " + accessToken.getIssuedAt()); // (3-4)
System.out.println("Access Token Expires At: " + accessToken.getExpiresAt()); // (3-5)
return "hello-oauth2";
}
}
[코드 4-102] OAuth 2 인증 후, Access Token 얻기
코드 4-102에서는 OAuth 2 인증 후, Resource Server에 접근할 때 사용되는 Access Token을 얻는 방법을 보여주고 있습니다.
코드의 설명은 다음과 같습니다.
- OAuth2AuthorizedClientService는 권한을 부여받은 Client(이하 OAuth2AuthorizedClient)를 관리하는 역할을 하는데 OAuth2AuthorizedClientService를 이용해서 OAuth2AuthorizedClient 가 보유하고 있는 Access Token에 접근할 수 있기 때문에 OAuth2AuthorizedClientService를 (1)과 같이 DI 받습니다.
- (2)에서는 OAuth2AuthorizedClientService의 loadAuthorizedClient("google", authentication.getName())를 이용해 OAuth2AuthorizedClient 객체를 로드합니다.
- loadAuthorizedClient()를 호출하면 내부적으로 OAuth2AuthorizedClientRepository에서 OAuth2AuthorizedClient 를 조회합니다.
- (3)에서는 authorizedClient.getAccessToken()를 이용해 OAuth2AccessToken 객체를 얻습니다.
- (3-1)에서는 Access Token의 문자열을 출력합니다.
- (3-2)에서는 Token의 타입을 출력합니다.
- (3-3)에서는 토큰으로 접근할 수 있는 리소스의 범위 목록을 출력합니다.
- (3-4)에서는 토큰의 발행일시를 출력합니다.
- (3-5)에서는 토큰의 만료일시를 출력합니다.
애플리케이션 실행 후, 구글 로그인 인증에 성공하면 아래와 같은 로그를 확인할 수 있습니다.
Access Token Value: ya29.a0Aa4xrXMyh6LZ9Ffg3FTNmvwHQ2yVVNeU_UwnKCJ4-O0P61BbqwioT0-qZ3QzsDfzz_ekCu7w2efS8xL3GjvpU0JjBfBiuWX8s8qa3akuEiLqOjbxSmLn0UVu8fmyp6nvuGUGAqafuVr0sozsh7aOZV3540e8aCgYKATASARISFQEjDvL9eowcbkmqO6nCcuIOn-SxjA0163
Access Token Type: Bearer
Access Token Scopes: [<https://www.googleapis.com/auth/userinfo.profile>, <https://www.googleapis.com/auth/userinfo.email>, openid]
Access Token Issued At: 2022-08-23T06:30:55.883356900Z
Access Token Expires At: 2022-08-23T07:30:54.883356900Z
2️⃣ OAuth2AuthorizedClient를 핸들러 메서드의 파라미터로 전달받는 방법
HomeController(V6)
package com.springboot.hello_oauth2.home;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HelloHomeController {
@GetMapping("/hello-oauth2")
public String home(@RegisteredOAuth2AuthorizedClient("google") OAuth2AuthorizedClient authorizedClient) { // (1)
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
System.out.println("Access Token Value: " + accessToken.getTokenValue());
System.out.println("Access Token Type: " + accessToken.getTokenType().getValue());
System.out.println("Access Token Scopes: " + accessToken.getScopes());
System.out.println("Access Token Issued At: " + accessToken.getIssuedAt());
System.out.println("Access Token Expires At: " + accessToken.getExpiresAt());
return "hello-oauth2";
}
}
[코드 4-103] OAuth 2 인증 후, Access Token 얻기
코드 4-103에서는 (1)과 같이 @RegisteredOAuth2AuthorizedClient 애너테이션을 이용해 아예 OAuth2AuthorizedClientRepository에 저장되어 있는 OAuth2AuthorizedClient를 파라미터로 전달받아서 Access Token 정보를 얻고 있는 것을 볼 수 있습니다.
💡 위 두 가지 방법 중에서 어떤 방법을 사용해도 상관없지만 하나 이상의 핸들러 메서드에서 OAuth2AuthorizedClient를 사용해야 한다면 OAuth2AuthorizedClientService를 DI 받아서 사용하는 것이 바람직해 보입니다.
핵심 포인트
- spring-boot-starter-oauth2-client으로 추가한 후, 별도의 설정을 하지 않아도 Spring Boot의 자동 구성을 통해 OAuth 2 로그인 인증 기능이 활성화된다.
- ClientRegistration은 OAuth 2 시스템을 사용하는 Client 등록 정보를 표현하는 객체이다.
- Spring Security에서 제공하는 CommonOAuth2Provider enum은 내부적으로 Builder 패턴을 이용해 ClientRegistration 인스턴스를 제공하는 역할을 한다.
- OAuth2AuthorizedClientService는 권한을 부여받은 Client인 OAuth2AuthorizedClient를 관리하는 역할을 한다.
- OAuth2AuthorizedClientService를 이용해서 OAuth2AuthorizedClient 가 보유하고 있는 Access Token에 접근할 수 있다.
- OAuth2AuthorizedClientService의 loadAuthorizedClient("google", authentication.getName())를 호출하면 OAuth2AuthorizedClientRepository를 통해 OAuth2AuthorizedClient 객체를 로드할 수 있다.
심화 학습
- ClientRegistration에 대해서 더 알아보고 싶다면 아래 링크를 클릭하세요.
- CommonOAuth2Provider에 대해서 더 알아보고 싶다면 아래 링크를 클릭하세요.
- OAuth2AuthorizedClient에 대해서 더 알아보고 싶다면 아래 링크를 클릭하세요.
OAuth 2와 JWT를 이용한 샘플 애플리케이션 구현
여러분들이 이전 챕터에서 구현해 본 Hello, OAuth 2 샘플 애플리케이션은 Google의 OAuth 2 인증 시스템을 이용해 사용자의 인증을 처리한 후, 보호된 HTML 페이지를 제공하는 SSR(Server Side Rendering) 방식의 애플리케이션이었습니다.
이번 시간에는 Frontend와 Backend가 분리된 CSR(Client Side Rendering) 방식의 애플리케이션에 Google의 OAuth 2 인증 시스템을 적용해 보겠습니다.
우리가 OAuth 2를 사용하지 않고, Frontend 측에서 Backend 애플리케이션에 직접 로그인 인증 요청을 전송하는 경우를 생각해 보세요.
Frontend 측에서 전송한 로그인 인증 요청이 Backend 애플리케이션에서 성공적으로 수행되면 인증에 성공했음을 증명할 수 있는 자격 증명 정보인 JWT를 Frontend 측에 제공했던 것 기억 날거라 생각합니다.
CSR(Client Side Rendering) 방식의 애플리케이션에 OAuth 2 인증 시스템을 도입할 경우에도 마찬가지로 OAuth 2 인증 시스템을 통해 인증에 성공한 사용자에 대한 자격 증명 정보를 JWT로 제공해 줄 수 있습니다.
한마디로 OAuth 2와 JWT의 콜라보레이션인 셈입니다.
CSR(Client Side Rendering) 방식의 애플리케이션에 OAuth 2 + JWT를 제대로 잘 적용하기 위해서는 먼저 OAuth 2의 인증 처리 흐름과 JWT를 통한 자격 증명 정보 제공 시점에 대해 이해하는 것이 중요합니다.
Frontend와 Backend 간의 OAuth 2 인증 처리 흐름
[그림 4-40] Frontend와 Backend 간의 OAuth 2 인증 처리 흐름
[그림 4-40]은 Frontend와 Backend 간의 OAuth 2 인증 처리 흐름입니다. 그림에 대한 설명은 다음과 같습니다.
(1) Resource Owner가 웹 브라우저에서 ‘Google 로그인 링크’를 클릭합니다.
(2) Frontend 애플리케이션에서 Backend 애플리케이션의 http://localhost:8080/oauth2/authorization/google로 request를 전송합니다. 이 URI의 request는 OAuth2LoginAuthenticationFilter 가 처리합니다.
(3) Google의 로그인 화면을 요청하는 URI로 리다이렉트 합니다. 이때 Authorization Server가 Backend 애플리케이션 쪽으로 Authorization Code를 전송할 Redirect URI(http://localhost:8080/login/oauth2/code/google)를 쿼리 파라미터로 전달합니다. Redirect URI는 Spring Security가 내부적으로 제공합니다.
(4) Google 로그인 화면을 오픈합니다.
(5) Resource Owner가 Google 로그인 인증 정보를 입력해서 로그인을 수행합니다.
(6) 로그인에 성공하면 (3)에서 전달한 Backend Redirect URI(http://localhost:8080/login/oauth2/code/google)로 Authorization Code를 요청합니다.
(7) Authorization Server가 Backend 애플리케이션에게 Authorization Code를 응답으로 전송합니다.
(8) Backend 애플리케이션이 Authorization Server에 Access Token을 요청합니다.
(9) Authorization Server가 Backend 애플리케이션에게 Access Token을 응답으로 전송합니다.
여기에서 Access Token은 Google Resource Server에게 Resource를 요청하는 용도로 사용됩니다.
(10) Backend 애플리케이션이 Resource Server에 User Info를 요청합니다.
여기서의 User Info는 Resource Owner에 대한 이메일 주소, 프로필 정보 등을 의미합니다.
(11) Resource Server가 Backend 애플리케이션에 User Info를 응답으로 전송합니다.
(12) Backend 애플리케이션은 JWT로 구성된 Access Token과 Refresh Token을 생성한 후, Frontend 애플리케이션에 JWT(Access Token과 Refresh Token)를 전달하기 위해 Frontend 애플리케이션(http://localhost?access_token={jwt-access-token}&refresh_token={jwt-refresh-token})으로 Redirect합니다.
⭐ 동작 흐름이 아주 복잡해 보이지만 (6)부터 (11)까지는 Spring Security에서 내부적으로 알아서 처리해 주기 때문에 기본적으로는 우리가 건드릴 필요가 없습니다.
Frontend 애플리케이션과 Backend 애플리케이션 간의 OAuth 2 인증 처리 흐름을 이해했다면 이제 구현해 볼 차례입니다.
그런데 OAuth 2 인증 처리가 정상적으로 동작하는지 확인하기 위해서는 웹서버에서 실제로 실행되는 Frontend 애플리케이션이 필요합니다.
Frontend 애플리케이션의 실행 환경을 먼저 준비해 봅시다.
Frontend 애플리케이션 준비
1️⃣ 아파치 웹서버 설치 - Windows OS 사용자
Windows OS 사용자는 아래의 순서대로 Frontend 애플리케이션 실행 환경을 준비합니다
- 아래의 링크에서 아파치 웹서버를 다운로드합니다.
- 다운로드한 파일의 압축을 해제합니다.
- 아래와 같이 Apache24 디렉토리를 C:\ 디렉토리로 이동합니다. 최종 경로는 C:\\\\Apache24가 됩니다.
아래와 같이 httpd.conf 파일을 메모장 등의 에디터로 오픈합니다.
- ServerName을 주석 해제하고 아래와 같이 수정합니다.
- ServerName localhost:80
- 나머지는 디폴트 값을 그대로 사용하면 됩니다.
- Ctrl과 Alt 키 사이에 있는 윈도우 키 + S를 누른 후, cmd로 검색해서 마우스 오른쪽 버튼을 눌러 명령 프롬프트(cmd) 창을 관리자 모드로 실행합니다.
- 아래와 같이 C:\\\\Apache24\\\\bin 디렉토리로 이동합니다.
아래와 같이 httpd.exe -k install 명령을 입력합니다.
아래와 같이 ApacheMonitor.exe를 더블 클릭해서 아파치 웹서버를 실행합니다. 아파치 웹서버는 바탕화면 오른쪽 하단의 빠른 실행 창에서 실행/중지할 수 있습니다.
웹브라우저에서 http://localhost로 접속했을 때 아래와 같은 화면이 뜨면 아파치 웹서버가 정상적으로 실행이 되는 것입니다.
1️⃣ 아파치 웹서버 설치 - Mac OS 사용자
Mac OS는 기본적으로 시스템에 아파치가 설치되어 있습니다. 최신 버전의 Mac OS가 아닐 경우는 homebrew를 이용해 별도 설치가 필요할 수 있습니다.
아래의 순서대로 Frontend 애플리케이션 실행 환경을 준비합니다.
- 시스템에 설치되어 있는 아파치를 확인합니다.
- 아래 명령어를 통해 설치 확인이 되지 않는다면 다음 링크를 참고하여 아파치 웹 서버를 설치합니다.
$ apachectl -v
- 아파치를 실행합니다.
- 아파치를 실행할 땐 반드시 sudo를 붙여야 합니다.
- 실행 중인 아파치를 종료할 땐 sudo apachectl stop 명령어로 종료할 수 있습니다.
$ sudo apachectl start
웹 브라우저에서 http://localhost로 접속했을 때 아래와 같은 화면이 뜨면 아파치 웹 서버가 정상적으로 실행됨을 확인할 수 있습니다.
2️⃣ Frontend 샘플 애플리케이션을 아파치 웹서버에 배포
아래의 세 개 HTML 파일을 에디터로 작성한 후, 각 운영체제에 맞는 경로의 디렉토리로 위치시킵니다.
- Windows OS 사용자 : C:\Apache24\htdocs
- Mac OS 사용자 : /Library/WebServer/Documents
- 현재 위치의 하위 디렉토리가 아닌 루트(/)의 하위 경로입니다.
- 해당 위치에서 파일을 직접 생성할 땐 반드시 sudo 명령어를 붙여야 합니다.
- 다른 경로에서 해당 위치로 드래그 앤 드롭을 이용해 이동할 땐 암호와 함께 허용 여부를 확인하는 메시지가 뜰 수 있습니다.
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>OAuth2 + JWT Frontend</title>
</head>
<body>
<h2>Welcome to OAuth 2.0 + JWT Spring Security</h2>
<a href="<http://localhost:8080/oauth2/authorization/google>">Google로 로그인</a>
</body>
</html>
index.html에서 [Google로 로그인] 버튼을 클릭하면 Backend 애플리케이션으로 request가 전송되고, Goolge 로그인 화면이 오픈됩니다.
receive-token.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>OAuth2 + JWT My page</title>
</head>
<body>
<script type="text/javascript">
let accessToken = (new URL(location.href)).searchParams.get('access_token');
let refreshToken = (new URL(location.href)).searchParams.get('refresh_token');
localStorage.setItem("accessToken", accessToken)
localStorage.setItem("refreshToken", refreshToken)
location.href = 'my-page.html'
</script>
</body>
</html>
receive-token.html 은 Backend 애플리케이션에서 전달받은 JWT Access Token과 Refresh Token을 웹브라우저의 LocalStorage에 저장한 후, my-page.html 이동합니다.
my-page.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>OAuth2 + JWT My page</title>
</head>
<body>
<h2>My Page</h2>
<h3>아래의 토큰을 이용해서 Backend 애플리케이션의 리소스를 요청할 수 있습니다.</h3>
<p>
<span>Access Token: </span><span id="accessToken" style="color: blue"></span>
</p>
<p>
<span>Refresh Token: </span><span id="refreshToken" style="color: blue"></span>
</p>
<script type="text/javascript">
let accessToken = localStorage.getItem('accessToken')
let refreshToken = localStorage.getItem('refreshToken');
document.getElementById("accessToken").textContent = accessToken;
document.getElementById("refreshToken").textContent = refreshToken;
</script>
</body>
</html>
my-page.html에서는 LocalStorage에 저장된 JWT Access Token과 Refresh Token을 로드해서 웹 브라우저에 표시합니다.
이제 Frontend 쪽은 준비가 끝났습니다.
Backend 애플리케이션에 OAuth 2 인증 기능 적용
1️⃣ JwtTokenizer 추가
제일 먼저 할 일은 JWT를 생성하고 JWT를 검증해 주는 JwtTokenizer를 구현하는 것입니다. JwtTokenizer는 우리가 JWT 유닛에서 이미 구현한 코드를 그대로 사용합니다.
JwtTokenizer
package com.springboot.oauth2_jwt.jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Calendar;
import java.util.Date;
import java.util.Map;
@Component
public class JwtTokenizer {
@Getter
@Value("${jwt.key.secret}")
private String secretKey;
@Getter
@Value("${jwt.access-token-expiration-minutes}")
private int accessTokenExpirationMinutes;
@Getter
@Value("${jwt.refresh-token-expiration-minutes}")
private int refreshTokenExpirationMinutes;
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
// 검증 후, Claims을 반환하는 용도
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
return claims;
}
// 단순히 검증만 하는 용도로 쓰일 경우
public void verifySignature(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
}
public Date getTokenExpiration(int expirationMinutes) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expirationMinutes);
Date expiration = calendar.getTime();
return expiration;
}
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
Key key = Keys.hmacShaKeyFor(keyBytes);
return key;
}
}
[코드 4-104] JwtTokenizer 코드
코드 4-104는 JwtTokenizer 코드이며, JWT 유닛의 코드를 그대로 사용하기 때문에 코드에 대한 별도의 설명은 생략하겠습니다.
2️⃣ application.yml 설정
spring:
h2:
console:
enabled: true
path: /h2
datasource:
url: jdbc:h2:mem:test
jpa:
hibernate:
ddl-auto: create # (1) 스키마 자동 생성
show-sql: true # (2) SQL 쿼리 출력
properties:
hibernate:
format_sql: true # (3) SQL pretty print
sql:
init:
data-locations: classpath*:db/h2/data.sql
security:
oauth2:
client:
registration:
google:
clientId: ${G_CLIENT_ID}
clientSecret: ${G_CLIENT_SECRET}
scope:
- email // (1)
- profile // (2)
logging:
level:
org:
springframework:
orm:
jpa: DEBUG
server:
servlet:
encoding:
force-response: true
mail:
address:
admin: admin@gmail.com
jwt:
key:
secret: ${JWT_SECRET_KEY} # 민감한 정보는 시스템 환경 변수에서 로드한다.
access-token-expiration-minutes: 30
refresh-token-expiration-minutes: 420
[코드 4-105] application.yml
코드 4-105는 application.yml 파일의 코드입니다.
우리가 JWT와 OAuth2를 함께 사용하기 때문에 JWT와 OAuth2의 설정이 합쳐진 모습입니다.
(1), (2)와 같이 scope 값을 직접 지정하면 해당 범위만큼의 Resource를 Client(백엔드 애플리케이션)에 제공합니다.
(1)은 Resource Owner의 이메일 정보를 의미하고, (2)는 Resource Owner의 프로필 정보를 의미합니다.
3️⃣ JwtVerificationFilter 추가
JwtVerificationFilter는 OAuth 2 인증에 성공하면 Frontend 애플리케이션 쪽에서 request를 전송할 때마다 Authorization header에 실어 보내는 Access Token에 대한 검증을 수행하는 Filter입니다.
JWT 유닛에서 학습한 코드를 그대로 사용하므로 [JWT를 이용한 자격 증명 및 검증 구현] 챕터를 참고해 주세요.
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.security.SignatureException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Map;
public class JwtVerificationFilter extends OncePerRequestFilter {
private final JwtTokenizer jwtTokenizer;
private final JwtAuthorityUtils authorityUtils;
public JwtVerificationFilter(JwtTokenizer jwtTokenizer, JwtAuthorityUtils authorityUtils) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try{
Map<String, Object> claims = verifyJws(request);
setAuthenticationToContext(claims);
} catch (SignatureException se) {
request.setAttribute("exception", se);
} catch (ExpiredJwtException ee) {
request.setAttribute("exception", ee);
} catch (Exception e) {
request.setAttribute("exception", e);
}
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request)throws ServletException{
String authorization = request.getHeader("Authorization");
return authorization == null || !authorization.startsWith("Bearer");
}
private Map<String,Object> verifyJws(HttpServletRequest request){
String jws = request.getHeader("Authorization")
.replace("Bearer ", "");
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();
return claims;
}
private void setAuthenticationToContext(Map<String, Object>claims){
String username = (String) claims.get("username");
List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));
Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class JwtAuthorityUtils {
@Value("${mail.address.admin}")
private String adminMailAddress;
private final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList(
"ROLE_ADMIN", "ROLE_USER"
);
private final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList(
"ROLE_USER"
);
private final List<String> ADMIN_ROLES_STRING = List.of("ADMIN","USER");
private final List<String> USER_ROLES_STRING = List.of("USER");
public List<GrantedAuthority> createAuthorities(String email){
if(email.equals(adminMailAddress)){
return ADMIN_ROLES;
}
return USER_ROLES;
}
public List<String> createRoles(String email){
if(email.equals(adminMailAddress)){
return ADMIN_ROLES_STRING;
}
return USER_ROLES_STRING;
}
public List<GrantedAuthority> createAuthorities(List<String> roles){
List<GrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
return authorities;
}
}
4️⃣ AuthenticationSuccessHandler 구현
AuthenticationSuccessHandler는 OAuth 2 인증에 성공하면 호출되는 핸들러입니다.
여기에서 JWT를 생성하고, Frontend 쪽으로 JWT를 전송하기 위해 Redirect 하는 로직을 구현하면 됩니다.
AuthenticationSuccessHandler
package com.springboot.oauth2_jwt.auth.handler;
import com.springboot.member.entity.Member;
import com.springboot.member.service.MemberService;
import com.springboot.oauth2_jwt.jwt.JwtTokenizer;
import com.springboot.oauth2_jwt.utils.CustomAuthorityUtils;
import com.springboot.stamp.Stamp;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class OAuth2MemberSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { // (1)
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
private final MemberService memberService;
// (2)
public OAuth2MemberSuccessHandler(JwtTokenizer jwtTokenizer,
CustomAuthorityUtils authorityUtils,
MemberService memberService) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
this.memberService = memberService;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
var oAuth2User = (OAuth2User)authentication.getPrincipal();
String email = String.valueOf(oAuth2User.getAttributes().get("email")); // (3)
List<String> authorities = authorityUtils.createRoles(email); // (4)
saveMember(email); // (5)
redirect(request, response, email, authorities); // (6)
}
private void saveMember(String email) {
Member member = new Member(email);
member.setStamp(new Stamp());
memberService.createMember(member);
}
private void redirect(HttpServletRequest request, HttpServletResponse response, String username, List<String> authorities) throws IOException {
String accessToken = delegateAccessToken(username, authorities); // (6-1)
String refreshToken = delegateRefreshToken(username); // (6-2)
String uri = createURI(accessToken, refreshToken).toString(); // (6-3)
getRedirectStrategy().sendRedirect(request, response, uri); // (6-4)
}
private String delegateAccessToken(String username, List<String> authorities) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
claims.put("roles", authorities);
String subject = username;
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
private String delegateRefreshToken(String username) {
String subject = username;
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
return refreshToken;
}
private URI createURI(String accessToken, String refreshToken) {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("access_token", accessToken);
queryParams.add("refresh_token", refreshToken);
return UriComponentsBuilder
.newInstance()
.scheme("http")
.host("localhost")
// .port(80)
.path("/receive-token.html")
.queryParams(queryParams)
.build()
.toUri();
}
}
[코드 4-106] OAuth2MemberSuccessHandler
코드 4-106은 OAuth2 인증이 성공적으로 수행되면 호출되는 핸들러인 OAuth2MemberSuccessHandler 코드입니다.
⭐ OAuth2MemberSuccessHandler 클래스는 OAuth 2 인증 후, Frontend 애플리케이션 쪽으로 JWT를 전송하는 핵심 역할을 담당합니다.
코드 설명은 다음과 같습니다.
- (1)과 같이 SimpleUrlAuthenticationSuccessHandler를 상속하면 Redirect를 손쉽게 할 수 있는 getRedirectStrategy().sendRedirect() 같은 API를 사용할 수 있습니다.
- (2)와 같이 필요한 객체를 DI 받습니다.
- DI는 정말 여러분들에게 지겹도록 언급하는 내용이기 때문에 이제는 모른다고 얘기하지 않아야 합니다.
- (3)에서는 Authentication 객체로부터 얻어낸 OAuth2User 객체로부터 Resource Owner의 이메일 주소를 얻고 있습니다.
- (4)에서는 CustomAuthorityUtils를 이용해 권한 정보를 생성하고 있습니다. CustomAuthorityUtils의 코드는 JWT에서 사용한 코드를 그대로 사용하므로 레퍼런스 코드를 참고하세요.
- (5)에서는 Resource Owner의 이메일 주소를 DB에 저장합니다. OAuth 2의 특성상 Resource Owner의 크리덴셜(Credential)을 Backend 애플리케이션에서 관리하지는 않지만 Backend 애플리케이션의 Resource(커피 정보, 주문 정보)와 연관 관계를 맺기 위해서 최소한의 정보는 Backend 애플리케이션 쪽에서 관리해도 무방합니다.
- (6)에서는 Access Token과 Refresh Token을 생성해서 Frontend 애플리케이션에 전달하기 위해 Redirect합니다.
- (6-1)과 (6-2)에서는 JWT Access Token과 Refresh Token을 생성합니다. JWT 유닛에서 이미 이용한 코드와 동일한 코드입니다.
- (6-3)에서는 Frontend 애플리케이션 쪽의 URL을 생성합니다. createURI() 메서드에서 UriComponentsBuilder를 이용해 Access Token과 Refresh Token을 포함한 URL을 생성하고 있습니다.
- 💡 UriComponentsBuilder에서 Port 설정을 하지 않으면 기본값은 80 포트란 걸 기억하세요.
- (6-4)에서는 SimpleUrlAuthenticationSuccessHandler에서 제공하는 sendRedirect() 메서드를 이용해 Frontend 애플리케이션 쪽으로 리다이렉트 합니다.
SecurityConfigiguration 설정
SecurityConfiguration의 코드가 길어 보이지만 사실상 여러분들이 이미 이 전 유닛에서 모두 학습한 내용이라는 사실을 기억하기 바랍니다.
SecurityConfiguration(V2)
package com.springboot.oauth2_jwt.config;
import com.springboot.member.service.MemberService;
import com.springboot.oauth2_jwt.auth.filter.JwtVerificationFilter;
import com.springboot.oauth2_jwt.auth.handler.MemberAccessDeniedHandler;
import com.springboot.oauth2_jwt.auth.handler.MemberAuthenticationEntryPoint;
import com.springboot.oauth2_jwt.jwt.JwtTokenizer;
import com.springboot.oauth2_jwt.auth.handler.OAuth2MemberSuccessHandler;
import com.springboot.oauth2_jwt.utils.CustomAuthorityUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
private final MemberService memberService;
public SecurityConfiguration(JwtTokenizer jwtTokenizer,
CustomAuthorityUtils authorityUtils,
MemberService memberService) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
this.memberService = memberService;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.csrf().disable()
.cors(withDefaults())
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable()
.exceptionHandling() // 추가
.authenticationEntryPoint(new MemberAuthenticationEntryPoint()) // 추가
.accessDeniedHandler(new MemberAccessDeniedHandler()) // 추가
.and()
.apply(new CustomFilterConfigurer()) // 추가
.and()
.authorizeHttpRequests(authorize -> authorize // url authorization 전체 추가
// .antMatchers(HttpMethod.POST, "/*/members").permitAll() // OAuth 2로 로그인하므로 회원 정보 등록 필요 없음.
// .antMatchers(HttpMethod.PATCH, "/*/members/**").hasRole("USER") // OAuth 2로 로그인하므로 회원 정보 수정 필요 없음.
// .antMatchers(HttpMethod.GET, "/*/members").hasRole("ADMIN") // OAuth 2로 로그인하므로 회원 정보 수정 필요 없음.
// .antMatchers(HttpMethod.GET, "/*/members/**").hasAnyRole("USER", "ADMIN") // OAuth 2로 로그인하므로 회원 정보 수정 필요 없음.
// .antMatchers(HttpMethod.DELETE, "/*/members/**").hasRole("USER") // OAuth 2로 로그인하므로 회원 정보 수정 필요 없음.
.antMatchers(HttpMethod.POST, "/*/coffees").hasRole("ADMIN")
.antMatchers(HttpMethod.PATCH, "/*/coffees/**").hasRole("ADMIN")
.antMatchers(HttpMethod.GET, "/*/coffees/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.GET, "/*/coffees").permitAll()
.antMatchers(HttpMethod.DELETE, "/*/coffees").hasRole("ADMIN")
.antMatchers(HttpMethod.POST, "/*/orders").hasRole("USER")
.antMatchers(HttpMethod.PATCH, "/*/orders").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.GET, "/*/orders/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.DELETE, "/*/orders").hasRole("USER")
.anyRequest().permitAll()
)
.oauth2Login(oauth2 -> oauth2
.successHandler(new OAuth2MemberSuccessHandler(jwtTokenizer, authorityUtils, memberService)) // (1)
);
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET","POST", "PATCH", "DELETE"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("\\/**", configuration); // 주의 사항: 컨텐츠 표시 오류로 인해 '/**'를 '\\/**'로 표기했으니 실제 코드 구현 시에는 '\\(역슬래시)'를 빼 주세요.
return source;
}
// 추가
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils);
builder.addFilterAfter(jwtVerificationFilter, OAuth2LoginAuthenticationFilter.class); // (2)
}
}
}
[코드 4-107] SecurityConfiguration
코드 4-107에서 // 추가라고 표시된 코드들은 여러분들이 기억 속에서 떠오르지 않을지도 모르지만 JWT 유닛에서 모두 설명한 코드입니다.
아직 언급하지 않은 코드에 대해서만 간단히 설명하도록 하겠습니다.
- (1)에서는 OAuth 2 로그인 설정에 .successHandler()를 통해 OAuth 2 인증이 성공한 뒤 실행되는 핸들러를 추가했습니다. OAuth2MemberSuccessHandler 객체를 생성하면서 OAuth2MemberSuccessHandler에서 필요한 의존 객체를 DI 하고 있는 걸 확인할 수 있습니다.
- (2)와 같이 JwtVerificationFilter를 OAuth2LoginAuthenticationFilter 뒤에 추가합니다.
OAuth 2 인증을 사용하므로 Backend 애플리케이션 쪽에서는 MemberController를 사용할 일이 현재로서는 없으므로 MemberController의 핸들러 메서드 쪽 URI에 대한 접근 권한은 주석 처리를 했다는 사실을 기억하세요.
5️⃣ 기타 수정된 코드
기타 수정된 코드는 회원 정보와 관련된 코드입니다.
제삼자인 써드 파티 애플리케이션(Google 서비스)의 OAuth 2 인증 시스템을 사용하기 때문에 회원 정보를 등록하거나 수정할 필요가 없으므로 이와 관련된 MemberController, MemberDto, MemberService, Member 엔티티 클래스에서 회원 정보를 등록 및 수정하는 로직의 대부분이 제거되거나 수정되었습니다.
수정된 부분에 대해서 확인하고 싶다면 레퍼런스 코드를 확인해 주세요.
애플리케이션 테스트
이제 구현은 마무리되었습니다.
Backend 애플리케이션을 IDE에서 실행하고, Frontend 쪽 아파치 웹서버가 실행되어 있는지 확인한 후, 웹 브라우저에 http://localhost를 입력해서 Frontend 애플리케이션의 화면을 오픈합니다.
[그림 4-41] Frontend 애플리케이션 화면
[그림 4-41]은 Frontend 애플리케이션의 메인 페이지입니다.
아주 아주 심플한 화면이지만 화면의 디자인보다 OAuth 2 인증과 JWT 구현 로직이 잘 동작하는지가 중요하다는 사실을 기억하면서 [Google로 로그인] 버튼을 클릭해 보세요.
구글 로그인 인증 화면이 표시되고, 로그인 인증에 성공하면 아래의 \[그림 4-42]와 같이 JWT의 Access Token과 Refresh Token이 표시되어야 합니다.
[그림 4-42] JWT Access Token과 Refresh Token이 화면에 표시된 모습
[그림 4-42]에서 확인할 수 있는 JWT Access Token은 Backend 애플리케이션의 Resource를 요청하는 CoffeeController와 OrderController의 핸들러 메서드를 호출할 때 Authorization header에 추가해서 사용하며, Refresh Token은 Access Token이 만료되었을 때, Access Token을 새로 발급받고자 할 때 사용할 수 있습니다.
이 Access Token과 Refresh Token을 얼마나 안전하게 잘 보관하고 사용하는지는 이제 전적으로 Frontend 애플리케이션의 책임 영역입니다.
Backend 개발자가 될 여러분은 여기까지만 구현하고 Frontend는 더 이상 신경 쓰지 않아도 됩니다.
우리가 지금껏 학습한 OAuth 2 인증 방식은 Google의 OAuth 2 인증 시스템을 이용한 방식이었습니다.
하지만 Google과 같은 OAuth 2 인증 시스템을 자체적으로 구축하기 위해 Authorization Server와 Resource Server를 구현할 수도 있습니다.
이 경우, Authorization Server와 Resource Server 간에도 JWT를 이용할 수 있으며, Spring Security에서는 Authorization Server와 Resource Server 간의 통신에 JWT를 이용할 수 있는 API를 제공합니다.
아울러 우리가 지금껏 JWT에 대한 서명을 대칭키 방식으로 진행했지만 Authorization Server와 Resource Server 간에 주고받는 JWT의 보안성을 강화하기 위해 비대칭키 방식의 서명도 사용할 수 있다는 사실을 기억하면 좋을 것 같습니다.
💡 비대칭키로 JWT를 암복호화하는 방식은 우리가 앞에서 학습했던 OAuth2MemberSuccessHandler에서 Frontend 애플리케이션 쪽으로 JWT를 쿼리파라미터로 추가한 뒤 리다이렉트 할 경우에도 사용할 수 있다는 사실을 기억하세요.
핵심 포인트
- Frontend 애플리케이션과 Backend 애플리케이션의 OAuth 2 인증 처리 흐름에서 Backend 애플리케이션이 Authorization Server, Resource Server와 인터랙션 하는 과정은 Spring Security에서 내부적으로 대신 처리해 준다.
- OAuth2MemberSuccessHandler 클래스는 OAuth 2 인증 후, Frontend 애플리케이션 쪽으로 JWT를 전송하는 핵심 역할을 담당한다.
- JWT의 보안성을 강화하기 위해 비대칭키 방식의 서명을 사용할 수 있다.
심화 학습
- OAuth2ClientAuthenticationProcessingFilter에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.