티스토리 뷰

이전 글: https://gojs.tistory.com/60

 

OAuth 2.0 with Spring Security (1) - 기본 로그인 구현하기

최근 프로젝트들의 경향을 살펴보면 자체 데이터를 기반으로 인증 로직을 구현하기보다는 대형 플랫폼의 인증 시스템을 기반으로 서비스를 구현한다.이를 위해 필수적으로 알아햐하는 방식이

gojs.tistory.com

위 포스팅에 이어 OAuth 2.0 Spring Security로 구현할 때 추가적으로 설정할 수 있는 로그인 옵션들에 대해서 알아보자.

 


https://datatracker.ietf.org/doc/html/rfc6749#section-3

 

RFC 6749: The OAuth 2.0 Authorization Framework

The OAuth 2.0 authorization framework enables a third-party application to obtain limited access to an HTTP service, either on behalf of a resource owner by orchestrating an approval interaction between the resource owner and the HTTP service, or by allowi

datatracker.ietf.org

https://openid.net/specs/openid-connect-core-1_0.html#UserInfo

 

Final: OpenID Connect Core 1.0 incorporating errata set 2

 

openid.net

기존에 소개한 각 엔드 포인트들이 OAuth 2.0 인증 프레임워크에서 어떤 역할을 하는 것인지는 위 링크들을 참고하였다.

- Authorization endpoint: 클라이언트가 리소스 소유자에게 인증받기 위해 리다이렉트될 때 사용

- Token endpoint: 클라이언트가 인증 권한을 가지고 access token으로 바꿀 때 사용

- Redirection endpoint: 인증 서버가 리소스 소유자 사용자 에이전트를 통해 인증 권한 포함 응답을 리다이렉트할 때 사용

- UserInfo endpoint: 인증된 최종 사용자에 대한 클레임을 반환할 때 사용 (OpenID Connect에서 사용)

 

OAuth 2.0 Login Page

기본적으로 OAuth 2.0 로그인 페이지는 DefaultLoginPageGeneratingFilter 클래스에서 자동으로 생성한다. 

이렇게 기본적으로 생성된 로그인 페이지는 아래와 같은 링크로 연결된다.

OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI + "/{registrationId}"

<a href="/oauth2/authorization/google">Google</a>

 

로그인 페이지를 덮어쓰기 위해서는 oauth2Login().loginPage() 와 oauth2Login().authorizationEndPoint().baseUri()를 설정해야한다.

@Configuration
@EnableWebSecurity
public class Oauth2LoginSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .loginPage("/login/oauth2")
                ...
                .authorizationEndpoint(authorization -> authorization
                    .baesUri("/login/oauth2/authorization")
                    ...
                )
            );

        return http.build();
    }
}

위와 같이 커스텀 로그인 페이지를 설정하였으면 꼭 "/login/oauth2"에 대응되는 컨트롤러를 구현해야한다.

 

Redirection Endpoint

Redirection Endpoint는 인증 서버로부터 권한 부여 자격을 리소스 소유자 User-Agent를 통해 클라이언트로 전달하는데 사용된다.

기본적으로 인증 응답의 baseUri는 아래와 같다.

OAuth2LoginAuthenticationFilter.DEFAULT_FILTER_PROCESS_URI

"/login/oauth2/code/*"

 

그리고 Redirection Endpoint를 덮어쓰는 방법은 아래와 같다.

@Configuration
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .redirectionEndpoint(redirection -> redirection
                    .baseUri("/login/oauth2/callback/*")
                    ...
                )
            );
        return http.build();
    }
}

 

UserInfo Endpoint

UserInfo Endpoint에 관련된 여러 가지 설정은 아래에서 하나씩 알아보자.

Mapping User Authorities

사용자가 성공적으로 서비스 제공자로부터 인증이 끝난 후에 OAuth2User.getAuhorities() 메서드나 OidcUser.getAuthorities() 메서드에는 권한 목록이 포함된다.

이 권한 목록은 OAuth2UserRequest.getAccessToken().getScopes()로 부터 채워지는 SCOPE_로 시작하는 권한들인데, 인증이 완료되는 시점에 OAuth2AuthenticationToken에 제공되어 GrantedAuthority의 Set 인스턴스에 대응될 수 있다.

 

@Configuration
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .userAuthoritiesMapper(this.userAuthoritiesMapper())
                    ...
                )
            );
            
        return http.build();
    }
    
    private GrantedAuthoritiesMapper userAuthoritiesMapper() {
        return (authorities) -> {
            Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
            
            authorities.forEach(authority -> {
                if (OidcUserAuthority.class.isInstance(authority)) {
                    OidcUserAuthority oidcUserAuthority = (OidcUserAuthority) authority;
                    
                    OidcToken idToken = oidcUserAuthority.getIdToken();
                    OidcUserInfo userInfo = oidcUserAuthority.getUserInfo();
                    
                    // idToken이나 userInfo에 있는 정보를 기준으로
                    // GrantedAuthority를 mappedAuthorities에 세팅
                    
                } else if (OAuth2UserAuthority.class.isInstance(authority)) {
                	OAuth2UserAuthority oauth2UserAuthority = (OAuth2UserAuthority) authority;
                    
                    Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();
                    
                    // userAttributes에 있는 attribute를 기준으로
                    // GrantedAuthority를 mappedAuthorities에 세팅
                }
            });
            
            return mappedAuthorities;
        };
    }
}

GrantedAuthority 목록을 생성하는 방법 중 첫 번째는 위와 같이 GrantedAuthoritiesMapper를 설정하는 것이다.

위와 같이 직접 설정할 수도 있지만 Spring Bean으로 등록만 하더라도 자동으로 설정이 된다.

 

@Bean
public SecurityFilterChain filterChain(HttpsSecurity http) throws Exception {
    http
        .oauth2Login(oauth2 -> oauth2
            .userInfoEndpoint(userInfo -> userInfo
                .oidcUserService(this.oidcUserService())
                ...
            )
        );
    
    return http.build();
}

private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
    final OidcUserService delegate = new OidcUserService();
    
    return (userRequest) -> {
        OidcUser oidcUser = delegate.loadUser(userRequest);
        
        OAuth2AccessToken accessToken = userRequest.getAccessToken();
        Set<GrantedAuthority> mappedAuthorities = new HashMap<>();
         
        // accessToken을 사용해 보호된 자원으로부터 권한 정보 추출
        // 권한 정보를 mappedAuthorities에 세팅
        
        ProviderDetails providerDetails = userRequest.getClientRegistration().getProviderDetails();
        String userNameAttributeName = providerDetails.getUserInfoEndpoint().getUserNameAttributeName();
        if (StringUtils.hasText(userNameAttributeName) {
            oidcUser = new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo(), userNameAttributeName);
        } else {
            oidcUser = new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo());
        }
        
        return oidcUser;
    };
}

또 다른 방법은 위와 같이 OAuth2UserService를 설정하는 것이다.

이 방법은 GratedAuthoritiesMapper를 사용하는 것보다 고도화된 방법인데, OAuth2UserRequest는 accessToken을 제공하여 권한 매핑 전에 보호된 자원에 접근해야하는 경우 유용하게 사용할 수 있다.

(예제에서는 OAuth2UserRequest, OAuth2User 대신 OidcUserRequest, OidcUser를 사용했다)

 

OAuth 2.0 UserService

DefaultOAuth2UserService는 OAuth 2.0 제공자가 지원하는 OAuth2UserService의 구현 클래스로 RestOperations 인스턴스를 사용하여 User Endpoint에서 유저 속성을 가져오는 요청을 처리한다.

UserInfo 요청의 전처리가 필요하다면 DefaultOAuth2UserService.setRequestEntityConverter()로 Converter<OAuth2UserRequest, RequestEntity<?>> 타입의 인스턴스를 설정해서 구현할 수 있다. 기본적으로 제공되는 OAuth2UserRequestEntityConverter는 RequestEntity의 Authorization 헤더에 OAuth2AccessToken을 세팅한다.

UserInfo 응답의 후처리가 필요하다면 DefaultOAuth2UserService.setRestOperations()로 커스터마이징한 RestOperations를 설정할 수 있다.

@Configuration
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(this.oauth2UserService())
                    ...
                )
            );
        return http.build();
    }
    
    private OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService() {
        ...
    }
}

 

OpenID Connect 1.0 UserService

OidcUserService는 OpenID Connect 1.0 제공자가 지원하는 OAuth2UserService의 구현 클래스이다. OidcUserService는 UserInfo Endpoint에서 유저 속성을 받아오는 요청 시 DefaultOAuth2UserService를 활용한다.

유저 속성을 받아오는 요청의 전처리나 응답의 후처리가가 필요한 경우에는 OidcUserService.setOauth2UserService()를 사용하면 된다.

@Configuration
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .oidcUserService(this.oidcUserService())
                    ...
                )
            );
        return http.build();
    }
    
    private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
        ...
    }
}

 

ID Token Signature Verification

OpenID Connect 1.0 Authentication에서 인증 서버는 클라이언트에게 유저의 인증에 대한 정보를 클레임으로 가지는 ID Token을 제공한다. ID Token은 JWT로 표현되며 JWS를 사용해서 서명되어야 한다.

OidcIdTokenDecoderFactory는 OidcIdToken의 서명 유효성 체크를 위해 JwtDecoder를 제공한다. 기본 알고리즘은 RS256이지만 클라이언트 등록에 따라 다르게 설정될 수 있다. 

@Bean
public JwtDecoderFactory<ClientRegistration> idTokenDecoderFactory() {
    OidcIdTokenDecoderFactory idTokenDecoderFactory = new OidcIdTokenDecoderFactory();
    idTokenDecoderFactory.setJwsAlgorithmResolver(clientRegistration -> MacAlgorithm.HS256);
    return idTokenDecoderFactory;
}

위 코드와 같이 기본 ID Token 알고리즘을 HS256으로 변경할 수 있다.

 

OIDC 로그아웃

사용자가 어플리케이션에 로그인을 하고 난 후 어떻게 로그아웃할 것인지도 고려해야한다.

- 로컬 로그아웃만 하는 경우

- 어플리케이션을 통해 어플리케이션과 OIDC 제공자 로그아웃

- OIDC 제공자를 통해 어플리케이션과 OIDC 제공자 로그아웃

일반적으로 OIDC를 사용하는 경우 위와 같은 3가지 로그아웃에 대해서 고려한다.

 

로컬 로그아웃

오로지 로컬 로그아웃만 한다면 별다른 OIDC 설정은 필요없다.

Spring Security는 자동으로 로컬 로그아웃 end point를 제공한다. 또한 logout(...) DSL을 통해 지정할 수도 있다.

 

클라이언트를 통한 OIDC 로그아웃

OpenID Connect Session Managerment 1.0은 클라이언트를 통해 제공자의 유저의 로그아웃을 허용한다.

만약 OpenID 제공자가 Session Management와 Discovery를 모두 제공한다면 클라이언트는 end_session_endpoint URL을 획득할 수 있다.

spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            client-id: okta-client-id
            client-secret: okta-client-secret
            ...
        provider:
          okta:
            issuer-uri: https://dev-1234.oktapreview.com

위와 같이 설정해서 ClientRegistration을 구성할 수 있다.

 

@Configuration
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {

    @Autowired
    private ClientRegistrationRepository clientRegistrationRepository;
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .oauth2Login(withDefaults())
            .logout(logout -> logout
                .logoutSuccessHandler(oidcLogoutSuccessHandler())
            );
            
        return http.build();
    }
    
    private LogoutSuccessHandler oidcLogoutSuccessHandler() {
        OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler
            = new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository);
        
        oidcLogoutSuccessHandler.setPostLogoutRedirectUrl("redirect-url");
        
        return oidcClientInitiatedLogoutSuccessHandler;
    }
}

또한 위와 같이 OidcClientInitiatedLogoutSuccessHandler를 설정하여 로그아웃을 구현할 수 있다.

 

OIDC 제공자를 통한 로그아웃

OpenID Connect Session Management 1.0은 OIDC 제공자가 클라이언트의 API를 호출하여 클라이언트 로그아웃하도록 한다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests((authorize) -> authorize
            .anyRequest().authenticated()
        )
        .oauth2Login(withDefaults())
        .oidcLogout((logout) -> logout
            .backChannel(Customizer.withDefaults())
        );
    return http.build();
}

위처럼 설정한 후 Spring Security가 제공하는 이벤트를 수신해서 OidcSessionInformation을 제거할 방법이 필요하다.

 

@Bean
public HttpSessionEventPublisher sessionEventPublisher() {
    return new HttpSessionEventPublisher();
}

위처럼 빈 오브젝트를 생성하게 된다면 세션을 무효화하는 엔드포인트가 시작되며, HttpSession.invalidate(...)가 호출되게 되어 메모리의 세션이 제거된다.

 

Back-Channel 로그아웃 구조

1. 로그인 시점에 Spring Security는 OidcSessionRegistry 구현 내에서 어플리케이션 세션 ID와 ID Token 등을 연관시킨다.

2. 로그아웃 시점에 OIDC 제공자는 "/logout/connect/back-channel/registrationId"와 같은 API를 호출한다.

    (API 호출 시 로그아웃 토큰을 포함하며 이 토큰은 sub(사용자), sid(세션ID) 등을 포함한다)

3. Spring Security는 토큰의 서명과 클레임을 검증한다.

4. 만약 sid 클레임이 포함되었다면 OIDC 제공자 세션과 관련된 클라이언트 세션만 종료된다.

5. 만약 sub 클레임이 포함되었다면 해당 최종 사용자에 대한 모든 클라이언트 세션을 종료한다.

 

OIDC Provider Session Registry 구현하기

기본적으로 Spring Security는 OIDC 제공자 세션과 클라이언트 세션을 모두 메모리에 저장한다.

클러스터링된 어플리케이션과 같은 경우에는 글로벌하게 관리할 수 있는 데이터베이스와 같은 장소에 세션을 저장할 필요가 있다.

@Component
public final class MySpringDataOidcSessionRegistry implements OidcSessionRegistry {
    
    private final OidcProviderSessionRepository sessions;
    
    ...
    
    @Override
    public void saveSessionInformation(OidcSessionInformation info) {
        this.session.save(info);
    }
    
    @Override
    public OidcSessionInformation removeSessionInformation(String clientSessionId) {
        return this.sessions.removeByClientSessionId(clientSessionId);
    }
    
    @Override
    public Iterable<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) {
        return token.getSessionId() != null ?
            this.sessions.removeBySessionIdAndIssureAndAudience(...) :
            this.sessions.removeBySubjectAndIssuerAndAudience(...);
    }
}

 

 

다음 글: https://gojs.tistory.com/62

 

OAuth 2.0 with Spring Security (3) - 클라이언트 어플리케이션 구성하는 방법

OAuth 2.0 클라이언트는 아래와 같은 기능들을 제공한다.인가 코드리프레쉬 토큰클라이언트 자격증명리소스 소유자 비밀번호 자격증명JWT토큰 교환 @Configuration@EnableWebSecuritypublic class OAuth2ClientSecu

gojs.tistory.com

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/08   »
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
글 보관함