
OAuth2란?| 이름 | 설명 |
|---|---|
| Authentication | (인증) 접근 자격이 있는지 검증하는 단계 |
| Authorization | (인가) 자원에 접근할 권한을 부여하는 것이며, 인가가 완료되면 리소스 접근 권한이 담긴 Access Token이 클라이언트에게 부여 |
| Access Token | 리소스 서버에게서 리소스 소유자의 보호된 자원을 획득할 때 사용하는 토큰 |
| Refresh Token | Access Token만료 시 이를 갱신하기 위한 용도로 사용하는 토큰 |
| 이름 | 설명 |
|---|---|
| Resource Owner | 웹 서비스를 이용하려는 유저, 자원(개인정보)을 소유하는 자, 사용자 |
| Client | 자사 또는 개인이 만든 애플리케이션 서버 |
| Resource Server | 사용자의 개인정보를 가지고있는 애플리케이션 (Google, Facebook, Kakao 등) 회사 서버 |
Client는 Token을 이 서버로 넘겨 개인정보를 응답 받을 수 있음 |
| Authorization Server | 권한을 부여(인증에 사용할 아이템을 제공주는)해주는 서버
사용자는 이 서버로 ID, PW를 넘겨 Authorization Code를 발급 받을 수 있음
Client는 이 서버로 Authorization Code을 넘겨 Token을 받급 받을 수 있음 |
Authorization Code Grant(권한 부여 코드 승인 방식) : OAuth2에서 가장 기본이 되는 방식이며, SNS 로그인 기능에서 사용되는 방식 
접근 권한 요청 시, response_type=code로 요청하게 되면 클라이언트는 Authorization Server에서 제공하는 로그인 페이지 이동
로그인 시, Authorization Server는 접근 권한 요청시에 받은 redirect_url로 Authorization Code를 전달
Client에서 전달받은 Authorization Code로 Access Token요청
Client에서 전달받은 Access Token으로 Resource Server에 자원 요청
이외에도 다른 방식이 존재하지만 여기서 설명하지 않겠다.
Implicit Grant : 암묵적 승인 방식
Client Credentials Grant : 클라이언트 자격 증명 방식
Resource Owner Password Credentials Grant : 자원 소유자 자격 증명 방식
[참고] : https://wildeveloperetrain.tistory.com/247
Spring Security 원리DelegatingProxyChain
DelegatingFilterProxy : 서블릿 필터와 스프링 빈을 연결해주는 클래스, 서블릿 필터로 요청을 받아서 스프링에서 관리하는 필터에게 요청을 위임하는 역할을 한다.springSecurityFilterChain : 스프링 시큐리티 스프링 빈FilterChainProxy
FilterChainProxy는 각 필터들을 순서대로 호출하며 인증/인가처리 및 각종 요청에 대한 처리를 수행한다.API추가 시 생성되는 필터
OAuth2로그인을 활성화 하면UsernamePasswordAuthenticationFilter대신OAuth2LoginAuthenticationFilter필터가 사용 된다.

DelegatingFilterProxy이 요청을 받게되면 delegate request로 요청 위임FilterChainProxy의 필터 목록들 순차적으로 수행DispatcherServlet(Controller)로 전달Spring OAuth2 Client 원리Access Token 획득
Auth-Server에서 로그인을 완료 하면 설정한 Redirect URL로 Authorization Code를 전달Authorization Code를 가지고 Access Token 요청Access Token 발급User Info 획득
Access Token으로 사용자 정보 조회SecurityContext의 인증 객체 저장Spring OAuth2 Client 실전 예제https://start.spring.io/ 에서 다음과 같이 예제 프로젝트를 세팅
‘GENERATE’를 클릭하여 프로젝트 저장 ****

IDE를 이용하여 WAS를 실행
다음과 같은 화면 페이지가 확인되면 프로젝트 설정 완료

ngrok 공식 홈페이지 : https://ngrok.com/WAS 실행 후, 터미널 아래 명령어 입력$ ngrok http 8080https://c029-218-152-213-155.ngrok-free.app 로 터널링이 완료된 것을 확인 

<ngrok 터널링 URL 접속 화면>The default redirect URI template is {baseUrl}/login/oauth2/code/{registrationId}.
The registrationId is a unique identifier for the ClientRegistration.
[참고] : [https://spring.io/guides/tutorials/spring-boot-oauth2/](https://spring.io/guides/tutorials/spring-boot-oauth2/)
CommonOAuth2ProviderSpring OAuth2 Client에서 기본으로 제공하는 플랫폼의 정보값을 가지고 있다.public enum CommonOAuth2Provider {
GOOGLE {
@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
builder.scope("openid", "profile", "email");
builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
builder.issuerUri("https://accounts.google.com");
builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
builder.userNameAttributeName(IdTokenClaimNames.SUB);
builder.clientName("Google");
return builder;
}
},
GITHUB {
@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
builder.scope("read:user");
builder.authorizationUri("https://github.com/login/oauth/authorize");
builder.tokenUri("https://github.com/login/oauth/access_token");
builder.userInfoUri("https://api.github.com/user");
builder.userNameAttributeName("id");
builder.clientName("GitHub");
return builder;
}
},
FACEBOOK {
@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_POST, DEFAULT_REDIRECT_URL);
builder.scope("public_profile", "email");
builder.authorizationUri("https://www.facebook.com/v2.8/dialog/oauth");
builder.tokenUri("https://graph.facebook.com/v2.8/oauth/access_token");
builder.userInfoUri("https://graph.facebook.com/me?fields=id,name,email");
builder.userNameAttributeName("id");
builder.clientName("Facebook");
return builder;
}
}
...
}
application.yml 설정spring:
security:
oauth2:
client:
registration:
google:
client-id: #{client-id}
client-secret: #{client-secret}
redirect-uri: "https://{baseHost}{basePort}/login/oauth2/code/{registrationId}"
facebook:
client-id: #{client-id}
client-secret: #{client-secret}
redirect-uri: "https://{baseHost}{basePort}/login/oauth2/code/{registrationId}"
github:
client-id: #{client-id}
client-secret: #{client-secret}
redirect-uri: "https://{baseHost}{basePort}/login/oauth2/code/{registrationId}"
naver:
client-name: Naver
client-id: #{client-id}
client-secret: #{client-secret}
authorization-grant-type: authorization_code
redirect-uri: "https://{baseHost}{basePort}/login/oauth2/code/{registrationId}"
scope: name,email,age
kakao:
client-name: Kakao
client-id: #{client-id}
client-secret: #{client-secret}
authorization-grant-type: authorization_code
redirect-uri: "https://{baseHost}{basePort}/login/oauth2/code/{registrationId}"
scope: profile_nickname,account_email
client-authentication-method: post
provider: # 기본제공하지 않는 플랫폼인 경우, 직접 Provider 설정 필요
naver:
authorization_uri: https://nid.naver.com/oauth2.0/authorize
token_uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user_name_attribute: response
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
#{client-id}, #{client-secret}에는 플랫폼 설정의 보안 정보를 가져와야 한다.provider을 설정해야한다.authorization-grant-type : 인증 방식은 authorization_code로 설정scope : 각 플랫폼 별, 클라이언트에게 허용된 리소스의 동의 항목만 명시하면 된다.Spring Security 설정 및 활성화@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain config(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest().authenticated()
.and()
.oauth2Login()
.defaultSuccessUrl("/user")
.and()
.build();
}
}
/login만 전체 허용, /login을 제외한 나머지 path는 인증 과정(즉, 로그인)이 필수oauth2Login() : oauth2를 활성화.defaultSuccessUrl("/user"): 로그인 성공시의 /user로 리다이렉트 한다./login/login을 따로 구현하지 않았을 시, 스프링 시큐리티에서 기본 제공되는 ui를 그려준다.public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
boolean loginError = isErrorPage(request);
boolean logoutSuccess = isLogoutSuccess(request);
if (isLoginUrlRequest(request) || loginError || logoutSuccess) {
String loginPageHtml = generateLoginPageHtml(request, loginError, logoutSuccess);
response.setContentType("text/html;charset=UTF-8");
response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
response.getWriter().write(loginPageHtml);
return;
}
chain.doFilter(request, response);
}
private String generateLoginPageHtml(HttpServletRequest request, boolean loginError, boolean logoutSuccess) {
...
if (this.oauth2LoginEnabled) { // oauth2Login이 활성화 일 때
sb.append("<h2 class=\"form-signin-heading\">Login with OAuth 2.0</h2>");
sb.append(createError(loginError, errorMsg));
sb.append(createLogoutSuccess(logoutSuccess));
sb.append("<table class=\"table table-striped\">\n");
for (Map.Entry<String, String> clientAuthenticationUrlToClientName : this.oauth2AuthenticationUrlToClientName
.entrySet()) {
sb.append(" <tr><td>");
String url = clientAuthenticationUrlToClientName.getKey();
sb.append("<a href=\"").append(contextPath).append(url).append("\">");
String clientName = HtmlUtils.htmlEscape(clientAuthenticationUrlToClientName.getValue());
sb.append(clientName);
sb.append("</a>");
sb.append("</td></tr>\n");
}
sb.append("</table>\n");
}
...
}
}
WAS 실행 후, /login에 접속하면 아래와 같은 페이지가 노출 되는 것을 확인할 수 있다.

/user@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping
public OAuth2User user(@AuthenticationPrincipal OAuth2User user) {
return user;
}
}
@AuthenticationPrincipal : 현재 인증된 사용자에 대한 정보에 쉽게 접근 해주는 어노테이션OAuth2User를 직접return하여 인증객체를 JSON으로 확인 가능하다.

/user로 리다이렉트 되는 것을 확인 할 수 있다.
Spring OAuth2 Client 심화 예제이러한 요구사항 때문에 로그인 하는 과정 작업 중에 있어 커스텀을 해야되는 이슈를 심화 예제를 통해 알아 보겠다.
UserDetailsServiceUserDetailsService : 일반적인 Form(HTML)을 이용한 로그인은 유저정보를 가져오는 인터페이스이다.UserDetails : UserDetailsService의 loadUserByUsername 리턴 값으로, 유저정보를 담는 객체이다.public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
[참고]
UserDetailsService: https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/user-details-service.html
UserDetails: https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/user-details.html
OAuth2 - UserDetailsServiceOAuth2에서 사용하는 UserDetailsService는 OAuth2UserService이다.OAuth2UserRequest : 유저정보 API를 호출하는 요청 객체를 인자 값으로 가지고 있다.OAuth2User를 상속 받은 객체를 반환@FunctionalInterface
public interface OAuth2UserService<R extends OAuth2UserRequest, U extends OAuth2User> {
U loadUser(R userRequest) throws OAuth2AuthenticationException;
}
DefaultOAuth2UserService : OAuth2에서는 기본적으로 OAuth2UserService 구현 클래스를 제공해주고 있다.loadUser(OAuth2UserRequest userRequest) : 코드를 보면 CommonOAuth2Provider 또는 application.yml에서 설정한 provider의 user-info-url의 API를 호출해 전달 받은 Response값을 DefaultOAuth2User객체로 변환하여 반환 하고 있는 것을 확인할 수 있다.public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
...
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Assert.notNull(userRequest, "userRequest cannot be null");
if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
.getUserNameAttributeName();
if (!StringUtils.hasText(userNameAttributeName)) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request);
Map<String, Object> userAttributes = response.getBody();
Set<GrantedAuthority> authorities = new LinkedHashSet<>();
authorities.add(new OAuth2UserAuthority(userAttributes));
OAuth2AccessToken token = userRequest.getAccessToken();
for (String authority : token.getScopes()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
}
return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
}
...
}
DefaultOAuth2UserService - 사용자화OAuth2UserService 인터페이스를 직접 구현할수도 있지만, 유저정보를 가져오는 API 로직도 구현을 해야되는 어려움이 있어 다음 예제에서는 DefaultOAuth2UserService를 상속받아서 구현해보자.DB를 통한 회원검증을 진행하지는 않지만, 필요하다면 별도로 구현이 필요하다.@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
// TODO : DB를 통한 회원검증
return oAuth2User;
}
}
CustomOAuth2UserService등록@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomOAuth2UserService userService;
@Bean
public SecurityFilterChain config(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest().authenticated()
.and()
.oauth2Login()
.userInfoEndpoint()
.userService(userService)
.and()
.defaultSuccessUrl("/user")
.and()
.build();
}
}
WAS를 디버깅 모드로 실행시켜 제대로 적용이 되었는지 확인해보자.
OpenID Connect(OIDC)OAuth2 vs OIDC” 키워드로 물어본 답변의 일부분이다.OAuth2 기반이지만 다른 인증 방식이다.scope 값에 openid가 포함 되어 있다면 OIDC 방식으로 인증 진행
scope에 openid가 포함되어있는걸 확인 할 수 있다.GOOGLE {
@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
builder.scope("openid", "profile", "email"); // openid 포함
builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
builder.issuerUri("https://accounts.google.com");
builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
builder.userNameAttributeName(IdTokenClaimNames.SUB);
builder.clientName("Google");
return builder;
}
}
OidcUserService - 사용자화Spring OAuth2 Client에서 OAuth2의 DefaultOAuth2UserService와 마찬가지로 OIDC는 OidcUserService를 구현 클래스로 제공해주고 있다.OAuth2UserService을 상속받고 타입 매개변수가 OIDC관련 객체인 것을 확인할 수 있다.OidUser 또한 OAuth2User를 상속받고 있다.public class OidcUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
...
@Override
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
Assert.notNull(userRequest, "userRequest cannot be null");
OidcUserInfo userInfo = null;
if (this.shouldRetrieveUserInfo(userRequest)) {
OAuth2User oauth2User = this.oauth2UserService.loadUser(userRequest);
Map<String, Object> claims = getClaims(userRequest, oauth2User);
userInfo = new OidcUserInfo(claims);
// https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
// 1) The sub (subject) Claim MUST always be returned in the UserInfo Response
if (userInfo.getSubject() == null) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
// 2) Due to the possibility of token substitution attacks (see Section
// 16.11),
// the UserInfo Response is not guaranteed to be about the End-User
// identified by the sub (subject) element of the ID Token.
// The sub Claim in the UserInfo Response MUST be verified to exactly match
// the sub Claim in the ID Token; if they do not match,
// the UserInfo Response values MUST NOT be used.
if (!userInfo.getSubject().equals(userRequest.getIdToken().getSubject())) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
}
Set<GrantedAuthority> authorities = new LinkedHashSet<>();
authorities.add(new OidcUserAuthority(userRequest.getIdToken(), userInfo));
OAuth2AccessToken token = userRequest.getAccessToken();
for (String authority : token.getScopes()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
}
return getUser(userRequest, userInfo, authorities);
}
...
}
public interface OidcUser extends OAuth2User, IdTokenClaimAccessor {
...
}
CustomOAuth2UserService와 비슷하게 CustomOidcUserService를 작성해보자@Service
public class CustomOidcUserService extends OidcUserService {
@Override
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
OidcUser oidcUser = super.loadUser(userRequest);
// TODO : DB를 통한 회원검증
return oidcUser;
}
}
CustomOidcUserService 등록@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomOAuth2UserService userService;
private final CustomOidcUserService oidcUserService;
@Bean
public SecurityFilterChain config(HttpSecurity http) throws Exception {
return http
.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest().authenticated()
.and()
.oauth2Login()
.userInfoEndpoint()
.userService(userService)
.oidcUserService(oidcUserService)
.and()
.defaultSuccessUrl("/user")
.and()
.build();
}
}
breakpoint를 설정하고 WAS를 디버그 모드로 실행을 해보면 Google일 때도 break 걸리는 것을 확인할 수 있다.이렇게 Spring OAuth2 Client를 간단히(?) 알아봤는데…
더욱 상세하게 들어가면 이 글이 끝나지 않을 것 같아 여기서 마무리 하겠다. 😂
(너무 내용이 딱딱하고 진지해서 재미가 없어 걱정이 된다….)
조금이나마, Spring OAuth2 Client의 개념에 대한 이해와 예제 들을 통해 돕기 위해 작성한 글이다.
참고와 출처를 통해 더 디테일하게 알아 보는 것을 권장하고, 학습 후 서비스에 적용하기를 바란다.
위의 예제에서는 5개의 로그인 플랫폼을 연동 시켜봤는데,
이 외에도 댓글로 Twitter, Apple, Weibo 등 요청이 다수 들어오면 다음 포스트에서 소개 하도록 하겠다.

[출처]
https://velog.io/@tmdgh0221/Spring-Security-와-OAuth-2.0-와-JWT-의-콜라보
https://catsbi.oopy.io/f9b0d83c-4775-47da-9c81-2261851fe0d0
https://inpa.tistory.com/entry/WEB-📚-OAuth-20-개념-💯-정리
[Github]
좋은 정보 감사합니다