이전글에서 OAuth 2.0의 개념을 잡았으니 이번글에는 Spring Boot를 이용한 실습을 해보겠다.
목표는 2가지이다.
왜 이렇게 2가지냐면, Google은 CommonOAuth2Provider에 정의되어 있다. Kakao는 그렇지 않다.
그렇기 때문에 Kakao를 통한 로그인을 위해서는 몇 가지 추가 설정이 필요할 것이다.
글 초기부터 어려웠던 점에 대해 말하는 이유는 내가 생각한 설계구조와 연관되어 있기 때문이다.
먼저 Grant 방식은 Authorization code를 이용하기로 했다.
그러면 인증 순서는 아래와 같다.
JWT와 JWT_1은 다르다. JWT는 OAuth2 Provider에서 발급하는 토큰이다. JWT_1은 우리의 백엔드에서 발급하는 토큰이다.
아무튼 내가 생각한 건 이런식이었다. 그런데 여기에 Spring OAuth2-Client가 끼어들면서 체감상의 절차가 상당히 간소화 되었다.
1 - 2~8 - 9의 느낌이다. 2~8단계가 OAuth2-Client에 의해 처리된다.
문제는 나는 이미 로컬 로그인을 지원하고 있는 상황이라 api 엔드포인트를 맞추고 싶었다.
OAuth2-Client가 지원하는 엔드포인트는 oauth2/authorization/{provider}의 형식인데, 내 로컬로그인은 /login 이다. 그러므로 통일성을 생각한다면 /login/social/google, /login/social/kakao 이런식으로 만들고 싶었다는 말이다.
해결하고 나니, 정답은 생각한 것보다 훨씬 간단했다.
정답: 리디렉션을 활용해라
간단하지만 왜 그렇게 해야 하는지 알기 위해서는 OAuth2-Client에 대해 어느 정도 이해할 필요가 있다.
우리는 oauth2/authorization/{provider}라고 하는 단순한 엔드포인트에 접근하지만, 실제로는 자동 설정된 무언가가 이를 컨버팅해서 아래와 같이 바꾸고 있다.
https://kauth.kakao.com/oauth/authorize?
response_type=code
&client_id=클라이언트아이디
&scope=profile_nickname%20account_email
&state=임의코드
&redirect_uri=http://localhost:8080/login/oauth2/code/kakao
왜, 어떻게 그런 일이 일어나는지 알아보자.
애초에 OAuth2-Client에 의해 모든 게 지원이 된다는 말은 해당 설정을 어딘가에서 관리하고 있다는 말이다. 그걸 관리하는 녀석이 OAuth2ClientAutoConfiguration
이다.
이 놈이 하는 일은 이렇다.
ClientRegistration
으로 구성된 ClientRegistrationRepository
을 Bean으로 등록한다.WebSecurityConfigurerAdapter
Configuration을 제공하고, httpSecurity.oauth2Login()
으로 OAuth2 로그인을 활성화한다.그러므로 OAuth2-Client의 커스텀화를 하고 싶다면 이 2가지를 건드려야 하는데, 대개의 경우 2번째 조항은 이미 커스텀화가 된 상황일 것이다.
남은 것은 첫번째인데, 아래와 같은 방식으로 직접 Bean을 등록해 줄 수 있다.
@Configuration
public class OAuth2LoginConfig {
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
return new InMemoryClientRegistrationRepository(this.googleClientRegistration());
}
private ClientRegistration googleClientRegistration() {
return ClientRegistration.withRegistrationId("google")
.clientId("google-client-id")
.clientSecret("google-client-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUriTemplate("{baseUrl}/login/oauth2/code/{registrationId}")
.scope("openid", "profile", "email", "address", "phone")
.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
.tokenUri("https://www.googleapis.com/oauth2/v4/token")
.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
.userNameAttributeName(IdTokenClaimNames.SUB)
.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
.clientName("Google")
.build();
}
}
위 코드를 대강 해석해보면 Bean으로 등록된 InMemoryClientRegistrationRepository
타입의 ClientRegistrationRepository
안에 ClientRegistration
들이 담겨 있다는 소리다.
저기서 ClientRegistration를 가지고 올 수 있다면 리디렉션 할 URL을 쉽게 만들 수 있을 것 같다.
구글, 페이스북, 깃허브 등의 CommonOAuth2Provider
를 사용하는 경우에 한해 위 코드는 아래와 같이 대체될 수 있다.
✨CommonOAuth2Provider: 구글, 페이스북, 깃허브 등을 통한 로그인 구현 할 때 redirect_url이라던가 하는 잡다한 것들을 넣지 않게 해주는 녀석이다.
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
return new InMemoryClientRegistrationRepository(this.googleClientRegistration());
}
@Bean
public OAuth2AuthorizedClientService authorizedClientService(
ClientRegistrationRepository clientRegistrationRepository) {
return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository);
}
@Bean
public OAuth2AuthorizedClientRepository authorizedClientRepository(
OAuth2AuthorizedClientService authorizedClientService) {
return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
}
private ClientRegistration googleClientRegistration() {
return CommonOAuth2Provider.GOOGLE.getBuilder("google")
.clientId("google-client-id")
.clientSecret("google-client-secret")
.build();
}
네이버, 카카오 등과 같은 한국의 OAuth2 제공자들은 CommonOAuth2Provider
가 아니기 때문에 큰 의미는 없어보인다.
조금 더 알아보자.
HttpSecurity.oauth2Login()
메소드로 여러 속성을 재정의 할 수 있다고 한다.
@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.clientRegistrationRepository(this.clientRegistrationRepository())
.authorizedClientRepository(this.authorizedClientRepository())
.authorizedClientService(this.authorizedClientService())
.loginPage("/login")
.authorizationEndpoint(authorization -> authorization
.baseUri(this.authorizationRequestBaseUri())
.authorizationRequestRepository(this.authorizationRequestRepository())
.authorizationRequestResolver(this.authorizationRequestResolver())
)
.redirectionEndpoint(redirection -> redirection
.baseUri(this.authorizationResponseBaseUri())
)
.tokenEndpoint(token -> token
.accessTokenResponseClient(this.accessTokenResponseClient())
)
.userInfoEndpoint(userInfo -> userInfo
.userAuthoritiesMapper(this.userAuthoritiesMapper())
.userService(this.oauth2UserService())
.oidcUserService(this.oidcUserService())
.customUserType(GitHubOAuth2User.class, "github")
)
);
}
}
위의 리디렉션 엔드포인트는 Authorization code
를 발급 받는 용도이다. 내가 필요한 리디렉션은 커스텀 OAuth2 로그인 엔드포인트를 OAuth2-Client가 지원하는 인증 엔드포인트로 넘기는 것이다.
그보다 .loginPage
가 눈에 띈다.
해당 항목은 디폴트 로그인 페이지를 설정하는데, 기본값은 OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI + "/{registrationId}"
이고 우리가 흔히 쓰던 /oauth2/authorization/{provider}
이다.
아래와 같은 방식으로 변경이 가능하다고 한다.
@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.loginPage("/login/oauth2")
...
.authorizationEndpoint(authorization -> authorization
.baseUri("/login/oauth2/authorization")
...
)
);
}
}
그런데 이렇게 할 거면 해당 로그인 페이지를 렌더링 할 @Controller
로 만들어야 한다고 한다.
내 서비스 접근용 JWT를 발급할 때 이용할 수도 있으니 살펴보자.
사용자가 인증을 하면 OAuth2User.getAuthorities()
를 통해 사용자 권한을 받아올 수 있으며, 이는 GrantedAuthority
로 매핑돼 OAuth2AuthenticationToken
생성에 사용될 수 있다.
매핑 방법에는 2가지가 있다.
공식 레퍼런스 사이트에서는 위가 더 쉽다고 말하는데, 별로 큰 차이는 없어보인다.
그보다는 두 번째 방법이 확장성이 더 좋기 때문에 두 번째 방법만을 살펴보자.
@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.oidcUserService(this.oidcUserService())
...
)
);
}
private OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService() {
final OAuth2UserService delegate = new DefaultOAuth2UserService();
return (userRequest) -> {
OAuth2User oAuth2User = delegate.loadUser(userRequest);
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
OAuth2AccessToken accessToken = userRequest.getAccessToken();
Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
// TODO
// 1) accessToken을 이용해 권한 정보를 받아옴
// 2) 권한 정보를 GrantedAuthority로 매핑하고 mappedAuthorities에 추가
// 3) OAuth2User의 기본 구현체인 DefaultOAuth2User으로 반환
return new DefaultOAuth2User(mappedAuthorities, oAuth2User.getAttributes(),
userNameAttributeName);
};
}
}
oAuth2User.getAttributes()로 사용자 정보를 조회할 수 있다.
Provider에 따라 형식은 다양한 편인데, 어쩌면 CommonOAuth2Provider 사이에서는 동일할 수도 있다.
네이버와 카카오는 확실히 다르다.
카카오의 경우에는 보다시피 Map안에 Map이 또 있고, 그 Map 안에 Map이 또 있는 구조니까 사용자 정보를 받을 때 조금 귀찮다.
DefaultOAuth2User
가 OAuth2User
의 attributes를 가지고 있으므로, 해당 객체만 확보하면 정보 조회는 할 수 있을 것이다. 하지만 CustomOAuth2User를 만들면 어떨까?
말했다시피 카카오의 응답은 depth가 꽤 있는 편이다. 저걸 하나하나 다시 받아오고 있으면 내 복장이 터질지도 모르기 때문에 애초에 필요한 정보들(nickname, email, profile_picture) 등을 CustomOAuth2User가 바로 가지고 있는 것도 괜찮을 것 같다.
아래와 같은 형식으로 CustomOAuth2User 타입을 사용하게 할 수 있다.
@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.customUserType(GitHubOAuth2User.class, "github")
...
)
);
}
}
GitHubOAuth2User의 구성은 아래와 같다.
public class GitHubOAuth2User implements OAuth2User {
private List<GrantedAuthority> authorities =
AuthorityUtils.createAuthorityList("ROLE_USER");
private Map<String, Object> attributes;
private String id;
private String name;
private String login;
private String email;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public Map<String, Object> getAttributes() {
if (this.attributes == null) {
this.attributes = new HashMap<>();
this.attributes.put("id", this.getId());
this.attributes.put("name", this.getName());
this.attributes.put("login", this.getLogin());
this.attributes.put("email", this.getEmail());
}
return attributes;
}
public String getId() {
return this.id;
}
public void setId(String id) {
this.id = id;
}
@Override
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public String getLogin() {
return this.login;
}
public void setLogin(String login) {
this.login = login;
}
public String getEmail() {
return this.email;
}
public void setEmail(String email) {
this.email = email;
}
}
갑자기 웬 Github이냐면 공식 레퍼런스 사이트가 예시로 든 코드가 그거라 이걸로 했다.
애초에 위와 같은 식으로 하면 DefaultOAuth2UserService
를 사용하게 되는데 OAuth2UserService
도 커스텀화가 가능하다.
그리고 그 편이 확장성이 훨씬 좋다.
OAuth2UserService
는 엑세스 토큰으로 사용자 정보를 가져오는 일을 한다. 그리고 이를 OAuth2User
타입의 AuthenticatedPrincipal
을 반환한다.
이걸 SecurityContextHolder에 넣고, 컨트롤러에서 바로 받을 수 있다면 JWT 발급과의 연계가 무척이나 쉬워질 것 같다.
OAuth 2.0을 위한 클라이언트 역할을 하는 녀석이다.
아래와 같은 컴포넌트들을 관리하고 있다.
@EnableWebSecurity
public class OAuth2ClientSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.oauth2Client(oauth2 -> oauth2
.clientRegistrationRepository(this.clientRegistrationRepository())
.authorizedClientRepository(this.authorizedClientRepository())
.authorizedClientService(this.authorizedClientService())
.authorizationCodeGrant(codeGrant -> codeGrant
.authorizationRequestRepository(this.authorizationRequestRepository())
.authorizationRequestResolver(this.authorizationRequestResolver())
.accessTokenResponseClient(this.accessTokenResponseClient())
)
);
}
}
Spring Security가 언제나 그렇듯이 Manager와 Provider에 의해 동작한다.
각각 OAuth2AuthorizedClientManager
OAuth2AuthorizedClientProvider
되시겠다.
아래는 4가지 타입의 인증을 지원하는 Provider를 생성하고 해당 Provider를 포함하는 Manager를 Bean으로 등록하는 코드다.
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.clientCredentials()
.password()
.build();
DefaultOAuth2AuthorizedClientManager authorizedClientManager =
new DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
OAuth 2.0 클라이언트의 핵심 컴포넌트들에 대해 알아보자.
위에서 살펴봤던 OAuth2의 공급자에 등록한 클라이언트 정보를 가지고 있다.
Client_id
Client_secret
Redirect_url
Scope
등의 정보를 가지고 있어서 이 녀석에 접근할 수 있으면 엄청 쉽게 리디렉션 URL을 만들 수 있을 것 같다고 말했었다.
// 탐나는 정보들
public final class ClientRegistration {
// ClientRegistration 식별용의 ID
private String registrationId;
// 내 클라이언트 ID
private String clientId;
// 내 클라이언트 Secret
private String clientSecret;
// 클라이언트를 인증 할 때 사용할 Http Method. Basic, Post, None 지원.
private ClientAuthenticationMethod clientAuthenticationMethod;
// Authorization Grant 방식이다. 웹 어플리케이션은 대개 authorization_code를 사용.
private AuthorizationGrantType authorizationGrantType;
// 인증 후에 유저의 user-agent를 리디렉트 시킬 url
private String redirectUriTemplate;
// 요청할 정보들
private Set<String> scopes;
private ProviderDetails providerDetails;
// 클라이언트 이름
private String clientName;
public class ProviderDetails {
// 인증 엔드포인트 uri
private String authorizationUri;
// 토큰 엔드포인트 uri
private String tokenUri;
private UserInfoEndpoint userInfoEndpoint;
// JSON Web key 셋용 uri. 토큰의 signature가 있다.
private String jwkSetUri;
// OpenId provider 설정 정보
private Map<String, Object> configurationMetadata;
public class UserInfoEndpoint {
// 사용자 정보 엔드포인트 uri
private String uri;
// 사용자 정보 받을 때 쓸 Method. header, form, query 지원.
private AuthenticationMethod authenticationMethod;
// 사용자 정보 속성에 있는 이름. 사용자의 이름이나 id 등에 접근할 때 이용.
private String userNameAttributeName;
}
}
}
ClientRegistration의 저장소이다.
우리가 spring.security.oauth2.client.registration.[registrationId]
하위에 속성들을 등록하면 ClientRegistration
인스턴스에 바인딩해 ClientRegistrationRepository
안에 집어넣는다.
그렇기 때문에 우리가 따로 무언가를 하지 않고 application.yml/properties
에 속성을 작성해 놓으면 알아서 잘 동작하는 것.
중요한 점은 ClientRegistrationRepository
또한 Bean이기 때문에 의존성을 주입받을 수 있다는 것이다.
@Controller
public class OAuth2ClientController {
@Autowired
private ClientRegistrationRepository clientRegistrationRepository;
@GetMapping("/")
public String index() {
ClientRegistration oktaRegistration =
this.clientRegistrationRepository.findByRegistrationId("okta");
...
return "index";
}
}
찾고 찾던 기능이 여기 있었다 ㅋㅋ!! 그러나 이보다 더 편한 방법을 찾음...
인증된 클라이언트를 의미하는 클래스이다. 유저가 인증을 통해 클라이언트에 사용자 정보 접근 권한을 부여하면 해당 클라이언트를 인증된 클라이언트로 간주한다.
이 녀석은 OAuth2AccessToken
과 OAuth2RefreshToken
(있으면)을 ClientRegistration
, Principal
과 함쳐 종합선물세트로 만들어 준다.
OAuth2AuthorizedClientRepository
는 다른 웹 요청이 와도 동일한 OAuth2AuthorizedClient
를 유지하는 역할을 한다.
OAuth2AuthorizedClientService
는 어플리케이션 레벨에서 OAuth2AuthorizedClient
를 관리한다.
중요한 점은, 둘 다 OAuth2AccessToken
에 접근 가능하다는 점이다.
@Controller
public class OAuth2ClientController {
@Autowired
private OAuth2AuthorizedClientService authorizedClientService;
@GetMapping("/")
public String index(Authentication authentication) {
OAuth2AuthorizedClient authorizedClient =
this.authorizedClientService.loadAuthorizedClient("okta", authentication.getName());
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
...
return "index";
}
}
OAuth2AuthorizedClientService
의 기본 구현체는 InMemoryOAuth2AuthorizedClientService
이다. 이름만 봐도 알 수 있듯이 이 녀석을 정보를 메모리에 저장한다.
메모리에 저장하는 방법 외에 db를 사용하는 방법도 있다.
JdbcOAuth2AuthorizedClientService
를 설정해주면 된다.
저장되는 내용에 대해서는 여기를 참조하자.
현업에서는 Redis를 이용해 저장하는 경우가 가장 많다고 한다.
OAuth2AuthorizedClientManager
는 OAuth2AuthorizedClient
를 관리하는 인터페이스이다.
다음과 같은 일을 한다.
OAuth2AuthorizedClientProvider
를 통해 클라이언트에 권한 부여OAuth2AuthorizedClientService
/ OAuth2AuthorizedClientRepository
에 OAuth2AuthorizedClient
저장을 위임OAuth2AuthorizationSuccessHandler
에 처리 위임OAuth2AuthorizationFailureHandler
에 위임언제나 그렇듯 실질적인 권한 부여 방법 등은 OAuth2AuthorizedClientProvider
에 존재한다.
결국 전체적인 동작 방식은 아래와 같다.
OAuth2AuthorizationSuccessHandler
로 처리 위임.OAuth2AuthorizedClientRepository
에 OAuth2AuthorizedClient
를 저장.RemoveAuthorizedClientOAuth2AuthorizationFailureHandler
가 OAuth2AuthorizedClientRepository
에 있는 OAuth2AuthorizedClient
를 삭제.SuccessHandler와 FailureHandler는 커스텀 가능하다.
.setAuthorizationSuccessHandler(OAuth2AuthorizationSuccessHandler)
.setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler)
OAuth2AuthorizedClientManager
는 추가적으로 OAuth2AuthorizeRequest
의 속성들을 Map<String, Object>
타입의 Map에 매핑한다. 매핑된 값은 OAuth2AuthorizationContext
에 담긴다. 보통 password 인증 방식처럼 provider에게 특정 정보를 전달해야 할 때 종종 이용된다.
찾아 볼 만큼 본 것 같으니 구현을 해보자.
OAuth2 로그인을 위해서는 일단 사용할 공급자 플랫폼에서 OAuth2 인증용 어플리케이션을 만들어줘야 하는데, 이 부분은 워낙 많은 블로그에 자세히 나와있으니 생략한다.
본 프로젝트는 이동욱님의 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스'에 나온 코드에서 내가 필요한 부분만 추출해 변경했다.
JDK는 17을 사용했는데, 8로 해도 문제는 없을 듯.
Spring Boot 2.7.8 버전인가를 사용했고, Spring Security도 아직 WebSecurityConfigurerAdapter
와 antMatchers
가 존재하는 버전이다.
만약 Spring Security 6.0 이상으로 구현하고 싶다면, WebSecurityConfigurerAdapter
의 대체 구현에 대해 찾아보길 바라고 antMatchers
는 requestMatchers
였나? 이걸로 통합됐을 거다.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
compileOnly 'org.springframework.boot:spring-boot-starter-mustache'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
spring:
datasource:
username: sa
password:
url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
security:
oauth2:
client:
registration:
google:
clientId: 클라ID
clientSecret: 클라Secret
scope: profile, email
kakao:
client-name: 클라이름
client-id: 클라ID
client-secret: 클라Secret
client-authentication-method: POST
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8080/login/oauth2/code/kakao
scope:
- profile_nickname
- account_email
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
<!DOCTYPE HTML>
<html>
<head>
<title>소셜로그인 테스트</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>
<h1>과연 될 것인가</h1>
<div class="col-md-12">
<div class="row">
<div class="col-md-6">
{{#userName}}
Logged in as: <span id="user">{{userName}}</span>
<a href="/logout" class="btn btn-info active" role="button">Logout</a>
{{/userName}}
{{^userName}}
<a href="/login/social/google" class="btn btn-success active" role="button">Google Login</a>
<a href="/oauth2/authorization/kakao" class="btn btn-secondary active" role="button">Kakao Login</a>
{{/userName}}
</div>
</div>
<br>
</div>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
</body>
</html>
프론트 파트는 만들기 싫으니 뷰 리졸버를 통해 위의 index.mustache와 함께 일하며 화면을 렌더링 해 줄 컨트롤러이다.
import com.example.oauth2practice2.domain.dto.SessionUser;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
@RequiredArgsConstructor
public class FrontController {
private final HttpSession httpSession;
@GetMapping("/")
public String index(Model model) {
SessionUser user = (SessionUser) httpSession.getAttribute("user");
if(user != null){
model.addAttribute("userName", user.getName());
}
return "index";
}
}
로그인 관련한 컨트롤러이다.
OAuth2-Client 내부 컨트롤러로 리디렉션 해주고, 인증에 성공하면 토큰 발급도 해준다.
가입 절차까지도 이쪽에서 처리하고 싶다면 대충 CustomOAuthUser
객체 던지고 이리로 와서 따로 처리해도 된다.
import java.io.IOException;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/login")
public class LoginController {
@GetMapping("/social/{provider}")
public void login(HttpServletResponse response, @PathVariable String provider) throws IOException {
// 내가 찾은 답은 이거였다 ㅋㅋ 허무...
response.sendRedirect("/oauth2/authorization/" + provider);
}
@GetMapping("/authorized")
public ResponseEntity<String> authorized(@AuthenticationPrincipal CustomOAuth2User user) {
// 디버깅해서 확인해보면 scope 내에 있던 정보들 다 들어가 있는 거 볼 수 있다
OAuthAttributes attributes = user.getOAuthAttributes();
return ResponseEntity.ok("대충 토큰 발급했다고 치자.");
}
}
평범한 Entity
import com.example.oauth2practice2.domain.type.Role;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class User{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
private String picture;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
public User update(String name, String picture) {
this.name = name;
this.picture = picture;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
위의 Entity에서 써먹을 Role이다.
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
이중가입을 하면 안되니까 확인용으로 사용할 조회 쿼리 생성기
import com.example.oauth2practice2.domain.entity.User;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
다른 거 다 별로 안 중요하고,
혹시 모를 csrf 검증에 대비한 csrf 비활성화,
로그인 관련 컨트롤러에도 접근 못하면 안되니까 접근 허용 url 설정,
OAuth2Login 설정과 UserInfo를 받고 난 후 사용할 OAuth2UserService 설정,
성공했을 시에 토큰 발급 절차를 위해 접근할 api 엔드포인트 url 설정
이렇게가 중요하다.
import com.example.oauth2practice2.service.CustomOAuth2UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
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.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/**", "/login/**", "/error").permitAll()
.anyRequest().authenticated()
.and()
.logout().logoutSuccessUrl("/")
.and()
.exceptionHandling()
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.and()
.oauth2Login().userInfoEndpoint().userService(customOAuth2UserService)
.and()
.defaultSuccessUrl("/login/authorized");
}
}
엑세스 토큰을 기반으로 사용자 정보를 받은 다음 권한 및 사용자 정보를 필요한 형태로 가공/매핑한다.
import com.example.oauth2practice2.domain.UserRepository;
import com.example.oauth2practice2.domain.dto.OAuthAttributes;
import com.example.oauth2practice2.domain.dto.SessionUser;
import com.example.oauth2practice2.domain.entity.User;
import java.util.Collections;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final HttpSession httpSession;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
// 이 부분, 연결되는 컨트롤러에서 받아서 처리해도 무방
User user = saveOrUpdate(attributes);
// index.mustache에서 세션을 체크하고 있어서 넣었는데, 딱히 필요없음
httpSession.setAttribute("user", new SessionUser(user));
return new CustomOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey(),
attributes);
}
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
간단하게 DefaultOAuth2User
를 상속받아 구현했다.
또 depth가 깊은 attribute 받아서 매핑하는 짓을 반복하고 싶지는 않았기 때문.
import java.util.Collection;
import java.util.Map;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
@Getter
public class CustomOAuth2User extends DefaultOAuth2User {
private OAuthAttributes oAuthAttributes;
public CustomOAuth2User(
Collection<? extends GrantedAuthority> authorities,
Map<String, Object> attributes, String nameAttributeKey,
OAuthAttributes oAuthAttributes) {
super(authorities, attributes, nameAttributeKey);
this.oAuthAttributes = oAuthAttributes;
}
}
JSON으로 덩어리 째 날아온 사용자 정보를 매핑한다.
import com.example.oauth2practice2.domain.entity.User;
import com.example.oauth2practice2.domain.type.Role;
import java.util.Map;
import lombok.Builder;
import lombok.Getter;
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
@Builder
public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name,
String email, String picture) {
this.attributes = attributes;
this.nameAttributeKey = nameAttributeKey;
this.name = name;
this.email = email;
this.picture = picture;
}
public static OAuthAttributes of(String registrationId, String userNameAttributeName,
Map<String, Object> attributes) {
if ("kakao".equals(registrationId)) {
return ofKakao("id", attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofGoogle(String userNameAttributeName,
Map<String, Object> attributes) {
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.picture((String) attributes.get("picture"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
private static OAuthAttributes ofKakao(String userNameAttributeName,
Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>)attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>)kakaoAccount.get("profile");
return OAuthAttributes.builder()
.name((String) kakaoProfile.get("nickname"))
.email((String) kakaoAccount.get("email"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}