Springboot OAuth2 Client Github 로그인 쉽게조지기

Kim Dong Kyun·2024년 8월 7일
2
post-thumbnail

SpringBoot 3.x 버전에서는 'org.springframework.boot:spring-boot-starter-oauth2-client' 의존성을 추가함으로써, OAuth2 인증 절차를 더 간단하게 처리 가능하다.

시작하기

Spring Security 는 세션안에 Security Context 를 가지고 있고, Security Context 안에는 Authentication 이라는 인증 객체를 가지고 있다. 즉 인증 객체는 세션에서 관리된다. 그럼 세션이 아니라 JWT를 사용하면?

일반적으로 JWT를 이용해서 인증을 구현 할 때는 아래와 같은 방식을 사용한다.

public Authentication getAuthentication(String token) {  
    CustomUserDetails customUserDetails = (CustomUserDetails) customUserDetailsService.loadUserByUsername(this.getTokenSubject(token));  
    return new UsernamePasswordAuthenticationToken(customUserDetails, "", customUserDetails.getAuthorities());  
}  
  
public String getTokenSubject(String token) {  
    try {  
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();  
    } catch (Exception e) {  
        throw new JwtException("Invalid JWT token");  
    }  
}

...

// UsernamePasswordAuthenticationToken 의 생성자이다.
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {  
    super(authorities);  
    this.principal = principal;  
    this.credentials = credentials;  
    super.setAuthenticated(true);  
}
  1. UserService 를 구현한 CustomUserService 에서 JWT안에 담겨있는 subject (우리가 설정한 사용자 이메일 등등...) 를 기반으로 사용자를 DB에서 조회해서 리턴한다.
  2. 리턴한 사용자의 정보를 바탕으로 새 인증객체를 리턴한다. 이를 통해 우리는 @AuthenticationPrincipal 등의 어노테이션으로 Authentication 객체의 principal 을 가져올 수 있다(말 그대로인 어노테이션)

위와 같이 Custom Filter 를 통해서 인증 객체를 직접 만들어 주는 이유는 서버 세션에서 이 정보를 저장하지 않기 때문이다. 따라서 모든 요청(인증이 필요한)마다 필터를 사용해서 사용자의 정보를 다시 load 해 오는 것.

이번에는 가장 간단한 방식으로 인증을 구현하고 싶다. 추가적으로 필요한 것들이 생기면 더 불러오는 것으로 하고, 한 번 세션방식으로 구현해보자.


2. Security Config

@Configuration  
@EnableWebSecurity  
public class SecurityConfig {  
  
    @Bean  
    public SecurityFilterChain config(HttpSecurity http) throws Exception {  
        return http  
                .authorizeHttpRequests(  
                        authorizationManagerRequestMatcherRegistry -> authorizationManagerRequestMatcherRegistry  
                                .anyRequest().authenticated()  
                )  
                .oauth2Login(  
                        httpSecurityOAuth2LoginConfigurer ->  
                                httpSecurityOAuth2LoginConfigurer.defaultSuccessUrl("/oauth2/login")  
                )  
                .csrf(AbstractHttpConfigurer::disable)  
                .sessionManagement(a -> a.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))  
                .build();  
    }  
}
  • 간단히 설정을 해보자.
  • 스프링 OAuth2 Clietn 튜토리얼 문서에서는 간단한 예제에 대한 설명이 있는데, 이 문서에 나와있듯이 별다른 설정이 없으면 Spring에서 OAuth2 클라이언트 인증 url을 자동으로 만들어준다. ("/login" 경로에)
  • OAuth2 인증이 성공하면 /oauth2/login url 로 보내도록 해보자. 이 url에서는 사용자가 DB에 등록되지 않은 경우 새로 insert 하는 역할을 하게 할 것이다
  • 좀 어이없지만 이게 끝이다.

OAuth2 클라이언트는 일반적으로 OAuth2 인증을 하기 위한 "인증 코드" 방식을 간소화 시켜준다.
1. 인증 코드 발급
2. 인증 코드로 Github 인증서버 액세스 토큰 발급
3. 발급된 액세스 토큰으로 사용자 정보 응답

절차를 생략하고

// 로그인 요청 -> 사용자 정보 응답 ()
...
.oauth2Login(  
                        httpSecurityOAuth2LoginConfigurer ->  
                                httpSecurityOAuth2LoginConfigurer.defaultSuccessUrl("/oauth2/login")  
                )  

위와 같이 지정한 URL로 사용자의 정보를 바로 받아보게 할 수 있다.

그리고 인증이 완료되면 세션 내의 Security Context에 Authentication 을 곧바로 저장하기 때문에 성공 url에서 바로 @AuthenticationPrincipal 어노테이션을 사용해서 인증 정보를 즉시 사용 할 수 있다.

@GetMapping("/oauth2/login")  
public void oauth2Login(@AuthenticationPrincipal OAuth2User oAuth2User){  
    log.info("[{}] 유저 로그인 \n 상세정보 : {}", () -> oAuth2User.getAttribute("login"), oAuth2User::toString);  
    authService.saveIfNotPresent(LoginRequestDto.of(oAuth2User));  
}

의문점

그렇다면 의문점이 생긴다.

  1. SecurityContext는 세션에 저장되는 것이 맞는가?
  2. 일반적으로 Authentication 객체의 principal 은 UsernamePasswordAuthenticationToken 을 사용해왔다. 이 토큰은 UserDetails 타입을 principal 로 요구하기에, 우리는 UserDetails 를 재정의해서 이 인증토큰을 만들어왔다. 그렇다면 OAuth2User 라는 타입의 녀석도 동일한 방식으로 만들어지는가?

테스트를 몇 개 만들어가며 검증해보자.


1. OAuth2User 는 UserDetails 와 같은 방식으로 사용되는가?

SecurityContext 를 커스텀해서, 동작 방식을 비교해보자.

@WithSecurityContext 어노테이션을 사용하면 테스트 환경에서 시큐리티 컨텍스트를 직접 조작 가능하다. 스프링 공식문서를 참고하면, 쉽게 따라 이 방법을 시도 해 볼 수 있다.

@Retention(RetentionPolicy.RUNTIME)  
@WithSecurityContext(factory = WithMockOAuth2UserSecurityContextFactory.class)  
public @interface WithMockOAuth2User {  
    String username() default "testuser";  
    String nameAttributeKey() default "name";  
    String[] authorities() default {"ROLE_USER"};  
    String[] attributes() default {"name:123", "email:testuser@example.com"};  
}

public class WithMockOAuth2UserSecurityContextFactory implements WithSecurityContextFactory<WithMockOAuth2User> {  
  
    @Override  
    public SecurityContext createSecurityContext(WithMockOAuth2User withMockOAuth2User) {  
        SecurityContext context = SecurityContextHolder.createEmptyContext();  
  
        Map<String, Object> attributes = Arrays.stream(withMockOAuth2User.attributes())  
                .map(attr -> attr.split(":"))  
                .collect(Collectors.toMap(arr -> arr[0], arr -> arr[1]));  
  
        OAuth2User principal = new DefaultOAuth2User(  
                Arrays.stream(withMockOAuth2User.authorities())  
                        .map(SimpleGrantedAuthority::new)  
                        .toList(),
                attributes,  
                withMockOAuth2User.nameAttributeKey()  
        );  
  
        context.setAuthentication(new OAuth2AuthenticationToken(principal, principal.getAuthorities(), "github"));  
  
        return context;  
    }  
}

나는 위와 같이 설정했다. 이로써 @WithMockOAuth2User 어노테이션을 사용 시, 테스트에서의 인증 정보는 내가 설정한 OAuth2User 의 정보가 나오게 될 것이다.

정말 그런지 테스트해보자.

  • 아래 부분에서 우리는 Oauth2AuthenticationToken 이 UsernamePasswordAuthenticationToken과 동일한 방식으로 Authentication 으로 저장된다는 것을 알 수 있다.
@Test  
@WithMockOAuth2User  
@DisplayName("OAuth2 유저 인증 또한 Security Context 안에 Authentication 객체로 저장된다. 이 인스턴스는 주로 사용되는 UserNameAuthenticationToken 이 아닌 OAuth2AuthenticationToken 으로 저장된다.")  
void authenticationObjectStoredInSecurityContext() {  
    SecurityContext securityContext = SecurityContextHolder.getContext();  
    Authentication authentication = securityContext.getAuthentication();  
  
    assertThat(authentication).isNotNull();  
    assertThat(authentication.isAuthenticated()).isTrue();  
  
    assertThat(authentication).isInstanceOf(OAuth2AuthenticationToken.class);  // <- 여기
}
  • 아래 부분에서 우리는 Oauth2AuthenticationToken 은 OAauth2User 를 principal 로써 가진다는 것을 알 수 있다.
@Test  
@WithMockOAuth2User(attributes = {"name:myTestOAuth2User"})  
@DisplayName("커스텀 어노테이션의 어트리뷰트를 변경하여 인증 객체의 정보를 컨트롤 할 수 있다.")  
void canControlAuthenticationAttributesWithCustomAnnotation() {  
    OAuth2AuthenticationToken authentication = (OAuth2AuthenticationToken) SecurityContextHolder.getContext().getAuthentication();  
    OAuth2User oAuth2User = authentication.getPrincipal();  
  
    assertThat(oAuth2User).isNotNull();  
    assertThat(oAuth2User.getName()).isEqualTo("myTestOAuth2User");  
}  
  • 마지막으로, 아래에서 우리는 인증이 필요한 요청을 우리가 만든 커스텀 어노테이션으로 수행할 수 있음을 알 수 있다.
@Test  
@WithMockOAuth2User  
@DisplayName("커스텀 어노테이션을 이용해 인증이 필요한 요청을 테스트 할 수 있다.")  
void canTestAuthenticatedRequestsWithCustomAnnotation() throws Exception {  
    mockMvc.perform(get("/oauth2/login")).andExpect(status().isOk());  
}

이 테스트를 사용해서 우리의 의문 중 하나는 풀렸다. 즉, OAuth2User 타입은 OAuth2AuthenticationToken 의 principal 로써 Security Context 안의 Authentication 으로 저장된다 는 사실을 알게 되었다.

그렇다면 한가지 더 궁금한 것

2. Security Context 는 세션 안에 저장되는가?

마찬가지로 간단한 테스트를 작성해보자.

@Test  
@WithMockOAuth2User(attributes = {"name:99999"})  
@DisplayName("SecurityContext 는 세션에 저장된다.")  
void testSecurityContextStoredInSession() throws Exception {  
    MvcResult mvcResult = mockMvc.perform(get("/oauth2/login"))  
            .andExpect(status().isOk())  
            .andReturn();  
  
    HttpSession session = mvcResult.getRequest().getSession(false);  
    assertThat(session).isNotNull();  
  
    SecurityContext securityContext = (SecurityContext) session.getAttribute("SPRING_SECURITY_CONTEXT");  
    assertThat(securityContext).isNotNull();  
  
    OAuth2User principal = (OAuth2User) securityContext.getAuthentication().getPrincipal();  
    assertThat(principal.getName()).isEqualTo("99999");  
}

위 테스트를 통해서

  1. Security Context 는 세션에 저장된다.
  2. 저장된 녀석에서 인증 정보를 불러오면 우리가 커스텀한 어노테이션관련 정보가 잘 가져와진다.

는 것을 검증 할 수 있다.

그런데, 우리가 컨텍스트를 만들어서 직접 바꿔치기하는 방식으로는 운영 환경의 security context 동작을 테스트 할 수 없지않나? 라는 생각도 들었다. 그래서 직접 컨피규레이션을 조정해서 인증 시 세션을 사용하지 않도록 변경 하고 테스트를 해 보았다.

@Bean  
public SecurityFilterChain config(HttpSecurity http) throws Exception {  
    return http  
            .authorizeHttpRequests(  
                    authorizationManagerRequestMatcherRegistry -> authorizationManagerRequestMatcherRegistry  
                            .requestMatchers("/oauth2/login").permitAll()  
                            .anyRequest().authenticated()  
            )  
            .oauth2Login(  
                    httpSecurityOAuth2LoginConfigurer ->  
                            httpSecurityOAuth2LoginConfigurer.defaultSuccessUrl("/oauth2/login")  
            )  
            .csrf(AbstractHttpConfigurer::disable)  
            .sessionManagement(a -> a.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  
            .build();  
}

...

@RestController  
@RequiredArgsConstructor  
@Log4j2  
public class AuthController {  
  
    private final AuthService authService;  
  
    @GetMapping("/oauth2/login")  
    public void oauth2Login(@AuthenticationPrincipal OAuth2User oAuth2User){  
        log.info("[{}] 유저 로그인 \n 상세정보 : {}", () -> oAuth2User.getAttribute("login"), oAuth2User::toString);  
        authService.saveIfNotPresent(LoginRequestDto.of(oAuth2User));  
    }  
}

위와 같이 SessionCreationPolicy.STATELESS를 사용하면, 인증 시 세션을 사용하지 않겠다는 명시를 할 수 있다 (세션이 아예 생성되지 않는것은 아니다.)

  • 로그인 성공 시에는 위 AuthController 에서 로깅되도록 해두었다.
  • 로그인 성공 시 원래는 인증이 완료되지만, (인증 객체가 세션에 저장되지만) 현재 세션을 사용하지 않으므로 test url 을 permitAll() 해두었다.

결과는 NPE 가 발생한다.


추가) OAuth2 Client 환경설정하기

2. application.yml

security:  
  oauth2:  
    client:  
      registration:  
        github:  
          client-id: {client-id}
          client-secret: {client-secret}
  • client id, secret 은 여러분의 것을 넣어야 한다. 발급받아보자.

3. 깃허브 client id, secret 발급

  • settings -> developer settings

  • OAuth2 Apps -> new OAuth App 클릭
  • 인증을 요청할 어플리케이션의 이름, 홈페이지 URL을 작성
  • 그 후 callBack URL을 정해줘야 하는데
  • OAuth2 Client 에서 기본으로 정해놓은 url 폼이 있다.
    - ..../login/oauth2/code/{로그인플랫폼별 식별자}
    - 나의 경우 깃헙이므로 깃헙으로.

위 절차는 비교적 다른곳에 많아서 후순위로 미루었다.


0개의 댓글