Java / / 2023. 10. 12. 11:01

[Spring Boot] Spring Security + OAuth2.0 + JWT 소셜 로그인 구현 정리(Google, Kakao ...)

반응형

(서론) Spring Boot, Spring Security, OAuth2.0, JWT 소셜 로그인 구현

최근 사이드 프로젝트를 진행하고 있는데, 소셜 로그인 기능을 넣어보고 싶었습니다.

 

소셜 로그인이라고 하면 서비스되고 있는 많은 애플리케이션에서 사용 중인 Google, Kakao, Naver, GitHub 등을 사용한 로그인입니다. 개발자 입장에서는 로그인 프로세스를 개발할 필요가 없고, 보안 및 유지관리 작업에 신경을 덜 쓸 수 있다는 등의 장점이 있습니다.

 

로그인을 구현하기 위한 시간을 단축시켜 준다고 했지만, 직접 소셜 로그인을 구현하는 것은 쉬운일이 아니었습니다. 처음 접하는 것이다 보니 알아야 할 것들이 많았습니다.

 

오늘은 Spring Security와 oauth2.0을 포함하여 여러가지 프레임워크와 라이브러리를 사용하여 소셜 로그인을 구현했던 내용을 정리해 보려고 합니다. 저도 해당 내용이 아직 완벽하게 이해하지 못한 부분도 있어 정확한 것은 아닐 수 있기 때문에, 이상한 내용이 있다면 조언해주시면 감사하겠습니다.


소셜 로그인

Spring Boot에서는 다양한 서비스를 사용한 소셜 로그인을 구현할 수 있습니다. 제가 구현한 Spring Boot 프로젝트에서는 소셜 로그인(OAuth)를 구현하기 위해서 일반적으로 아래와 같은 두 가지 하위 프레임워크를 사용합니다. 꼭 알아야 소셜 로그인의 이해가 가능합니다. 추가로 JWT 토큰을 사용할 것입니다.

 

  1. Spring Security - 스프링 보안 프레임워크
  2. OAuth2.0 프레임워크
  3. Json Web Token(JWT)

 

1. Spring Security

Spring Security는 Spring 기반의 애플리케이션에서 권한 및 인증, 인가 등의 보안을 담당하고 있습니다. Spring Secuirty는 인증 및 인가를 Filter 흐름에 따라서 처리하게 구현되어 있습니다. 따라서 우리가 따로 보안적인 로직을 구현하지 않아도 됩니다.

 

스프링 시큐리티 흐름 간략히 알아보기

 

  • Spring Security는 Servlet Filter와 이들로 구성된 Filterchain을 사용합니다.
  • SecurityContextPersisteceFilter부터 시작하여 순서대로 필터를 거쳐나갑니다.
  • 필터 실행시, 화살표로 연결되어 있는 class를 거치면서 실행되고, 특정 필터를 제거하고 추가하는 등의 커스텀이 가능하다.

 

2. OAuth2.0 (Open Authentication 2.0)

OAuth2.0는 인증과 권한획득(인가)을 위한 개방형 표준 프로토콜입니다.

 

먼저 인증과 인가에 대한 개념만 간단히 알아보겠습니다. (인증 ≫ 인가 순으로 이어질 수 있습니다.)

Authentication - 인증 : 사용자의 신원을 증명하고 확인하는 과정
Authorization - 인가 : 인증과 달리 사용자 또는 애플리케이션이 액세스 할 수 있는 리소스 또는 작업

 

OAuth2.0은  Client(우리의 애플리케이션)에 Resource Owner(로그인하는 주체)를 대신해서 Resource Server에서 제공하는 자원에 대한 접근을 위임합니다. 처음 들어보면 말이 어려운데, 사용자가 구글, 카카오 등의 로그인을 하고 정보제공 동의를 진행하면, 우리의 애플리케이션은 해당 정보에 접근이 가능하게 된다는 의미입니다.

 

간단하게 OAuth2.0의 구성 요소를 보고 가겠습니다.

1. Resource Owner: 사용자
2. Client: 리소스 서버에서 제공해 주는 자원을 사용하는 외부 플랫폼
3. Authorization Server: 외부 플랫폼이 리소스 서버의 사용자 자원을 사용하기 위한 인증 서버
4. Resource Server: 사용자의 자원을 제공해주는 서버 (이름, 나이, 프로필 사진 등)

 

OAuth2.0은 보안 수준도 어느 정도 검증 되어 있고, 플랫폼의 API를 사용하여 사용자 인증 및 리소스 권한 획득(인가)을 할 수 있도록 해주는 역할을 맡고 있습니다. OAuth2.0이 생기기 이전에 보안 수준이 검증되지 않은 플랫폼에서 ID, PW를 동일하게 사용하는 상황이 발생했고 이는 보안에 취약해질 수밖에 없기 때문에 문제가 있었다고 합니다.

 

OAuth2.0의 인증을 사용하게 되면 신뢰할 수 있는 플랫폼이 인증과 인가를 외부 플랫폼에 부여하기 때문에 위에서 본 문제를 해결할 수 있고, user는 회원가입을 다시 할 필요가 없기 때문에 서로 장점이 있습니다.

 

아래에서 OAuth2.0의 흐름을 잠시 보고 가겠습니다.

 

OAuth2.0을 이용한 권한 부여 흐름

 

  1. 클라이언트(애플리케이션)는 Authorization Server로 접근 권한을 요청
    • 이때, 요청 패러미터에는 client_id, redirect_uri, response_type=code를 포함
  2. 클라이언트로부터 접근 권한 요청을 받은 Authorization Server는 소셜 로그인을 할 수 있는 로그인 창을 띄운다.
  3. 리소스 오너는 소셜 로그인 창을 통해 로그인을 진행
  4. Authorization Server는 리소스 오너로부터 전달받은 데이터가 맞는지 여부를 판단하고 권한 승인 코드를 반환
  5. 클라이언트는 권한 승인 코드( Authorized code )를 통해 리소스 서버에 보호된 자원을 요청할 수 있는 Access Token을 요청
  6. Access Token을 전달받은 클라이언트는 해당 토큰을 통해 리소스 서버에 필요한 요청을 보낸다.

 

OAuth2.0에는 4가지의 인증으로 구분됩니다.

OAuth2.0 인증 종류

1. Authorization Code Grant: 권한 코드 승인 방식
2. Implicit Grant: 암시적 승인 방식
3. Password Credentials Grant: 비밀번호 자격 증명 방식
4. Client Credentials Grant: 클라이언트 자격 증명 방식

우리는 첫 번째인 Authorization Code Grant 방식을 사용한다고 생각하면 되겠습니다. 일반적으로 서버에서 인증을 처리할 때 사용하는 방식으로 Resource Owner로부터 리소스 사용의 허락을 의미하는 Authorized code를 사용하여 Access Token을 요청합니다. Access Token은 client로 전달되지 않기 때문에 안전하다는 특징이 있습니다.


소셜 로그인 코드 알아보기

서비스 등록

우선 제가 서비스에 사용할 Google, Kakao에서 제공하는 OAuth 앱, 서비스를 등록해 주었습니다. 이 외에도 Facebook, Naver, GitHub 등 몇 가지 서비스가 더 있으니 필요하면 등록하셔서 사용하면 되겠습니다.

 

구글 및 카카오 등의 OAuth 등록은 그렇게 어렵지 않았습니다. 구글링 하면 많은 포스팅이 나오는데 참고하셔서 따라 하시면 문제없이 할 수 있었습니다.

 

Google : https://notspoon.tistory.com/45

Kakao : https://velog.io/@delvering17/JSP-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%84%A4%EC%9D%B4%EB%B2%84-3.-%EC%B9%B4%EC%B9%B4%EC%98%A4-developers-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%84%A4%EC%A0%95

 

Rest API Key(client-id)와 client-secret은 OAuth2.0 설정 시 사용되기 때문에 복사해 두는 것이 좋습니다. 그리고 외부로 노출시키지 않는 것을 추천드립니다.


전체적인 시퀀스 다이어그램 살펴보기

  • 시퀀스 상단 - OAuth2.0 소셜 로그인
  • 시퀀스 하단 - JWT 토큰 만료 
  • 자세한 설명은 아래 Back - end 코드에서 볼 수 있습니다.

예제 코드

Front는 React로 만들었고 Back은 Spring boot로 만들었습니다. 두 가지 모두 있는 경우를 가정하고 정리하는 것이니 참고해 주시면 감사하겠습니다. Front는 따로 정리하지 않을 것이고 Back에 대한 내용만 정리할 예정입니다.

 

Front - end 로그인 버튼

구글, 카카오 로그인 등의 버튼을 Front에 만들고 클릭 시 백엔드에 요청하도록 아래의 uri를 넣어주었습니다.

 

# {provider-id}에는 google, kakao와 같은 이름을 넣어주시면 되겠습니다.
http://localhost:8080/oauth2/authorization/{provider-id}?redirect_uri=http://localhost:3000/redirect

 

redirect uri는 인증이 모두 끝난 후, 백엔드 API에 접근이 가능하도록 저희가 발급한  JWT의 Access Token을 query-string으로 받을 수 있는 front - end 페이지라고 생각하시면 되겠습니다. Front - end에서는 해당 토큰을 localstorage에 저장했고, Back - end에 API 요청 시 헤더에 추가해서 요청을 보냈습니다.

 

# 백엔드 --Redirect--> 프론트엔드
http://localhost:3000/redirect?token={jwt-token}

 

Back - end 로그인

먼저 전체적인 동작 과정을 보는 것이 좋습니다. 지금은 이해가 안 가도 추후에 코드를 하나씩 보면서 그림과 함께 살펴보면 이해가 더 잘 가더라고요. 그림에 나와있는 uri는 조금 다를 수 있습니다. 참고해서 봐주시면 감사하겠습니다.

 

  • frontend client에서 엔드포인트에서 요청을 보냄
    • http://localhost:8080/oauth2/authorize/{provider}?redirect_uri={추후 이동할 프론트 uri}
    • provider : google, kakao
    • redirect_uri : OAuth2 provider가 성공적으로 인증을 완료했을 때 redirect 할 URI를 지정 (이 때,OAuth2의 redirectUri 와는 다르다)
  • 엔드포인트로 인증 요청을 받으면, Spring Security의 OAuth2 클라이언트는 user를 provider가 제공하는 AuthorizationUrl로 redirect 시킨다.
    • Authorization request와 관련된 state는 authorizationRequestRepository 에 저장된다 (Security Config에 정의)
    • provider에서 제공한 AutorizationUrl에서 허용/거부가 정해짐 (권한 제공 동의 화면)
    • 이때 만약 유저가 앱에 대한 권한을 모두 허용하면 provider는 사용자를 callback url로 redirect (http://localhost:8080/oauth2/callback/{provider})
    • 이때 사용자 인증코드 (authroization code)도 함께 갖고 있다.
    • 만약 거부하면 callbackUrl로 똑같이 redirect 하지만 error가 발생
  • Oauth2에서의 콜백 결과가 에러인 경우
    • Spring Security는 oAuth2AuthenticationFailureHanlder를 호출 (Security Config에 정의)
  • Oauth2 에서의 콜백 결과가 성공인 경우
      • 백엔드에서 인가 코드를 이용하여 Authorization Server에 액세스 토큰 요청
      • 사용자 인증코드 (authorization code)도 포함하고 있다면 Spring Security는 access_token에 대한 authroization code를 교환하고, customOAuth2UserService 를 호출(Security Config에 정의)
      • 액세스 토큰을 이용하여 Resource Server에 유저 데이터 요청
  • customOAuth2UserService는 인증된 사용자의 세부사항을 검색한 후에 DB에 Create 혹은 동일 Email로 Update 하는 로직
    • 최종적으로 sercurity가 인증 여부를 확인할 수 있도록 OAuth2User 객체 반환
  • oAuth2AuthenticationSuccessHandler가 호출
    • 사용자에 대한 JWT 인증 토큰을 생성하고 쿼리 문자열의 JWT 토큰과 함께 redirect_uri로 사용자를 보냄
    • 리프레시 토큰은 수정 불가능한 쿠키에 저장
    • Access Token 프로트엔드 리다이렉트 URI에 쿼리스트링에 토큰을 담아 리다이렉트 (Redirect: GET http://localhost:3000/oauth/redirect?token={jwt-token})
    • oauth 동작 과정에서 나오는 access token과는 다르다.
  • 프론트엔드에서 토큰을 저장 후, API 요청 시 헤더에 Authroization: Bearer {token}을 추가하여 요청
  • 백엔드에서는 토큰을 확인하여 권한 확인
  • 토큰이 만료된 경우, 쿠키에 저장된 리프레시 토큰을 이용하여 액세스 토큰과 리프레시 토큰을 재발급

이제 Back - end 코드를 정리해 보겠습니다. 코드가 많은데 모든 것을 담지 못할 수도 있습니다. 일단 최대한 담아보도록 하겠습니다.

 

gradle 의존성

// 생략...

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' //oauth2
	implementation 'org.springframework.boot:spring-boot-starter-security' //스프링 시큐리티
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'

	implementation 'org.apache.commons:commons-lang3:3.12.0'
	implementation 'org.springframework.boot:spring-boot-devtools'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

	implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
    
    ...
}

 

주요 의존성으로 securoity, oauth2-client, jwt 등이 있습니다. (Spring boot 2.0부터 기존 spring-security-oauth 대신 spring-security-oauth2-clinet를 사용하도록 연동방법이 변경되었습니다.)

 

application.yml

server:
  port: 8080

spring:
  datasource:
    url: 
    driver-class-name: 
    username:
    password:

  jpa:
    hibernate:
      ddl-auto: create #애플리케이션 실행시점에 테이블 생성
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        use_sql_comments: true
        
#추가 yml
  profiles:
    include: oauth

# jwt secret key 설정
jwt.secret: '8sknjlO3NPTBqo319DHLNqsQAfRJEdKsETOds'

# 토큰 관련 secret Key 및 RedirectUri 설정
app:
  auth:
    tokenSecret: 926D96C90030DD58429D2751AC1BDBBC
    tokenExpiry: 1800000
    refreshTokenExpiry: 604800000
  oauth2:
    authorizedRedirectUris:
      - http://localhost:3000/user/myTable

 

application-oauth.yml

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: '{구글 client-id}'
            client-secret: '{구글 client-secret}'
            scope: profile,email

          kakao:
            client-id: '{카카오 client-id}'
            client-secret: '{카카오 client-secret}'
            redirect-uri: http://localhost:8080/login/oauth2/code/kakao
            authorization-grant-type: authorization_code
            client-authentication-method: POST
            scope: profile_nickname, account_email
            client-name: Kakao

        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

설정 파일은 application.yml과 oauth2.0에 필요한 정보만 따로 담아준 application-oauth.yml  두 가지로 구성했습니다.

 

application.yml 파일에서  '#추가 yml - profiles: include: oauth' 부분을 통해 include 해주었습니다. google, kakao 등의 client-id와 client-secret은 위의 각 플랫폼에서 애플리케이션을 사전으로 등록하고 발급받은 설정 정보를 넣으면 됩니다.

 

 

프로젝트 구조

프로젝트 구조는 다른 포스팅을 참고하여 내에게 맞게 변형했습니다. 본인의 프로젝트에 맞게 변경하여 사용하면 됩니다.

├── Application.java
├── api
│   ├── controller
│   │   ├── auth
│   │       └── AuthController.java
│   ├── model
│   │   └── user
│   │       ├── User.java
│   │       └── UserRefreshToken.java
│   └── repository
│       └── user
│           ├── UserRefreshTokenRepository.java
│           └── UserRepository.java
├── common
│   ├── ApiResponse.java
│   └── ApiResponseHeader.java
├── config
│   ├── properties
│   │   └── AppProperties.java
│   ├── security
│   │   ├── JwtConfig.java
│   │   └── SecurityConfig.java
│   └── WebConfig.java
├── oauth
│   ├── model
│   │   ├── ProviderType.java
│   │   ├── Role.java
│   │   └── UserPrincipal.java
│   ├── exception
│   │   └── RestAuthenticationEntryPoint.java
│   ├── filter
│   │   └── TokenAuthenticationFilter.java
│   ├── handler
│   │   ├── OAuth2AuthenticationFailureHandler.java
│   │   ├── OAuth2AuthenticationSuccessHandler.java
│   │   └── TokenAccessDeniedHandler.java
│   ├── info
│   │   ├── OAuth2UserInfo.java
│   │   ├── OAuth2UserInfoFactory.java
│   │   └── impl
│   │       ├── GoogleOAuth2UserInfo.java
│   │       └── KakaoOAuth2UserInfo.java
│   ├── repository
│   │   └── CookieAuthorizationRequestRepository.java
│   ├── service
│   │   ├── CustomOAuth2UserService.java
│   └── token
│       ├── AuthToken.java
│       └── AuthTokenProvider.java
└── utils
    ├── CookieUtil.java
    └── HeaderUtil.java

 


WebConfigure

/**
 * CORS 설정
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("/*")//외부에서 들어오는 모든 url 허용
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")//허용되는 Method
                .allowedHeaders("*")//허용되는 헤더
                .allowCredentials(true)//자격증명 허용
                .maxAge(3600);//허용시간
    }
}

 

SercurityConfigure

@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig {

    private final CustomUserDetailsService userDetailsService;
    private final CustomOAuth2UserService oAuth2UserService;
    private final AuthTokenProvider tokenProvider;
    private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;
    private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;
    private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
    private final TokenAccessDeniedHandler tokenAccessDeniedHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        //httpBasic, csrf, formLogin, rememberMe, logout, session disable
        http
                .cors()
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .csrf().disable()
                .formLogin().disable()
                .httpBasic().disable()
                .rememberMe().disable()//로그인 상태 유지 X
                .exceptionHandling()
                .authenticationEntryPoint(new RestAuthenticationEntryPoint())// 401
                .accessDeniedHandler(tokenAccessDeniedHandler);// 403

        //요청에 대한 권한 설정
        http.authorizeHttpRequests()
                .requestMatchers("/oauth2/**").permitAll()// CORS 사전 요청
                .anyRequest().authenticated();

        //OAuth 2.0 로그인을 활성화
        http.oauth2Login()
                .authorizationEndpoint().baseUri("/oauth2/authorization")//OAuth 2.0 인증 요청을 처리하는 URL을 지정
                .authorizationRequestRepository(cookieAuthorizationRequestRepository)//인증 요청을 cookie 에 저장 (기본적으로는 HttpSessionOAuth2... 사용)
                .and()
                .redirectionEndpoint().baseUri("/*/oauth2/code/*")//소셜 인증 후 redirect url - 액세스 토큰을 받기 위해 리디렉션(인가)
                .and()
                .userInfoEndpoint().userService(oAuth2UserService)//가져온 사용자 정보를 사용할 서비스 class 지정 //userService()는 OAuth2 인증 과정에서 Authentication 생성에 필요한 OAuth2User 를 반환하는 클래스를 지정한다.
                .and()
                .successHandler(oAuth2AuthenticationSuccessHandler) //oauth 인증 성공(동의하고 계속하기) 시 호출되는 handler
                .failureHandler(oAuth2AuthenticationFailureHandler);//oauth 인증 실패 시 호출되는 handler

        http.logout()
                .clearAuthentication(true)
                .deleteCookies("JSESSIONID");

        // jwt filter 설정
        // 클라이언트의 요청에 포함되어있는 AccessToken의 유효 확인
        http.addFilterBefore(new TokenAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();

    }

}
  • authorizationEndpoint : 소셜 로그인 요청을 보내는 url을 설정합니다. (기본 url은 /oauth2/authorization/{provider} 입니다.)
  • authorizationRequestRepository : Spring oauth2는 기본적으로 HttpSessionOAuth2AuthorizationRequestRepository를 사용하고 Authorization Request를 저장합니다. 하지만 저는 session이 아닌 jwt를 사용할 것이기 때문에, 직접 구현한 cookieAuthorizationRequestRepository를 사용하여 cookie를 사용하는 방식으로 변경하였습니다.
  • redirectEndpoint : 소셜 인증 후 redirect 되는 uri입니다. (기본 url은 /login/oauth2/code/{provider} 입니다.)
  • userInfoEndpoint : 회원 정보를 처리하기 위한 class입니다. OAuth2userService의 기본 구현체는 DefaultOAuth2 UserService이지만, 로직 상 추가로 구현이 필요합니다. 따라서 해당 class를 상속받는 CustomOAuth2UserService를 구현하여 적용하였습니다.
  • successHandler : oauth 인증 성공 시 호출되는 handler
  • failureHandler : oauth 인증 실패 시 호출되는 handler

 


 

ProvideType

@Getter
public enum ProviderType {
    GOOGLE,
    KAKAO;
}

google, kakao 두 가지의 소셜 로그인을 사용했습니다. 추가로 등록하실 분이라면 provideType을 추가해 주시면 되겠습니다.

 

Role

@Getter
@RequiredArgsConstructor
public enum Role {

    USER("ROLE_USER", "회원"),
    ADMIN("ROLE_ADMIN", "관리자"),
    GUEST("GUEST", "게스트");

    private final String key;
    private final String titie;

    public static Role of(String key) {
        return Arrays.stream(Role.values())
                .filter(r -> r.getKey().equals(key))
                .findAny()
                .orElse(GUEST);
    }
}

각자의 서비스에 맞추어 역할을 정하고 추가하거나 제거하면 되겠습니다.

 

User

@Setter
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "USERS")
public class  User {

    @JsonIgnore
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "USER_SEQ")
    private Long userSeq;

    @Column(name = "USER_ID")
    private String userId;

    @Column(name = "USERNAME")
    private String username;

    @JsonIgnore
    @Column(name = "PASSWORD")
    private String password;

    @Column(name = "EMAIL")
    private String email;

    @Column(name = "EMAIL_VERIFIED_YN")
    private String emailVerifiedYn;

    @Column(name = "BIRTHDAY")
    private String birthday;

    @Column(name = "PROVIDER_TYPE")
    @Enumerated(EnumType.STRING)
    private ProviderType providerType;

    @Column(name = "ROLE_TYPE")
    @Enumerated(EnumType.STRING)
    private Role role;

    @Column(name = "CREATED_AT")
    private LocalDateTime createdAt;

    @Column(name = "MODIFIED_AT")
    private LocalDateTime modifiedAt;

    @Builder
    public User(String userId, String username, String email, String emailVerifiedYn, String birthday, ProviderType providerType, Role role, LocalDateTime createdAt, LocalDateTime modifiedAt) {
        this.userId = userId;
        this.username = username;
        this.password = "NO_PASS";
        this.email = email != null ? email : "NO_EMAIL";
        this.emailVerifiedYn = emailVerifiedYn;
        this.birthday = birthday != null ? birthday : "";
        this.providerType = providerType;
        this.role = role;
        this.createdAt = createdAt;
        this.modifiedAt = modifiedAt;
    }
}

본인의 서비스에 맞게 user를 설정하시고 생성하면 되겠습니다.

 

UserRepository

public interface UserRepository extends JpaRepository<User, Long> {
    User findByUserId(String userId);
    Optional<User> findByEmail(String email);
}

회원 정보를 다루기 위한 repository입니다.

 

UserRefreshToken

@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
@Table(name = "USER_REFRESH_TOKEN")
public class UserRefreshToken {

    @JsonIgnore
    @Id
    @Column(name = "REFRESH_TOKEN_SEQ")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long refreshTokenSeq;

    @Column(name = "USER_ID")
    private String userId;

    @Column(name = "REFRESH_TOKEN")
    private String refreshToken;

    @Builder
    public UserRefreshToken(String userId, String refreshToken) {
        this.userId = userId;
        this.refreshToken = refreshToken;
    }
}

refresh Token을 DB에서 다루기 위한 entity를 설정합니다.

 

UserRefreshTokenRepository

public interface UserRefreshTokenRepository extends JpaRepository<UserRefreshToken, Long> {
    UserRefreshToken findByUserId(String userId);
    UserRefreshToken findByUserIdAndRefreshToken(String userId, String refreshToken);
}

 

UserPrincipal

@Setter
@Getter
@RequiredArgsConstructor
public class UserPrincipal implements OAuth2User, UserDetails, OidcUser {
    private final String userId;
    private final String password;
    private final ProviderType providerType;
    private final Role role;
    private final Collection<GrantedAuthority> authorities; //사용자 권한
    private Map<String, Object> attributes; //사용자 추가정보

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getName() {
        return userId;
    }

    @Override
    public String getUsername() {
        return userId;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public Map<String, Object> getClaims() {
        return null;
    }

    @Override
    public OidcUserInfo getUserInfo() {
        return null;
    }

    @Override
    public OidcIdToken getIdToken() {
        return null;
    }

    public static UserPrincipal create(User user) {
        return new UserPrincipal(
                user.getUserId(),
                user.getPassword(),
                user.getProviderType(),
                Role.USER,
                Collections.singletonList(new SimpleGrantedAuthority(Role.USER.getKey()))//사용자의 권한을 나타내는 인터페이스 구현
        );
    }

    public static UserPrincipal create(User user, Map<String, Object> attributes) {
        UserPrincipal userPrincipal = create(user);
        userPrincipal.setAttributes(attributes);//사용자 추가정보

        return userPrincipal;
    }

}

Spring Security에서 사용하는 UserPrincipal 클래스입니다. 이는 다양한 방법으로 인증된 사용자를 나타내고 있어요. 현재 인증된 사용자를 나타내기 위해 사용되며 이 class에서는 사용자 ID, 권한, 추가 정보 등을 얻을 수 있습니다.

 

CustomOAuth2UserService

/**
 * loadUser 함수는 다음과 같이 작동합니다.
 * 1. OAuth2UserRequest의 인스턴스를 사용하여 OAuth 2.0 프로바이더에 요청합니다.
 * 2. OAuth 2.0 프로바이더에서 응답을 받습니다.
 * 3. 응답에서 사용자 정보를 추출합니다.
 * 4. 사용자 정보를 사용하여 OAuth2User의 인스턴스를 만듭니다.
 * 5. OAuth2User의 인스턴스를 반환합니다.
 *
 * loadUser() 메서드는 OAuth 2.0 프로바이더에서 인증된 사용자의 정보를 포함하는 OAuth2User 객체를 반환합니다. OAuth2User 객체는 다음과 같은 속성을 제공합니다.
 * - username: 인증된 사용자의 이름
 * - authorities: 인증된 사용자의 권한 목록
 * - attributes: 인증된 사용자의 속성 목록
 */

@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    //구글 로그인 버튼 클릭 -> 구글 로그인창 -> 로그인 완료 -> code를 리턴(OAuth-Clien라이브러리가 받아줌) -> code를 통해서 AcssToken요청(access토큰 받음)
    //OAuth2-client 라이브러리가 code단계 처리후 OAuth2UserRequest객체에 엑세스 토큰, 플랫폼 사용자 고유 key값을 반환해준다.
    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest); //oauth에서 가져온 user 정보

        try {
            return process(oAuth2UserRequest, oAuth2User);//인증된 사용자 정보
        } catch (AuthenticationException ex) {//인증 예외
            throw ex;
        } catch (Exception ex) {
            ex.printStackTrace();
            throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());//일반 예외 - 시스템 문제로 내부 인증 관련 처리 요청 x
        }
    }

    protected OAuth2User process(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) {

        //플렛폼 구분 - GOOGLE, KAKAO
        ProviderType providerType = ProviderType.valueOf(oAuth2UserRequest.getClientRegistration().getRegistrationId().toUpperCase());

        //플렛폼 별 사용자 추가정보
        OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, oAuth2User.getAttributes());

        //User savedUser = userRepository.findByEmail(oAuth2UserInfo.getEmail()).orElse(null);
        User savedUser = userRepository.findByUserId(oAuth2UserInfo.getId());

        //가입된 경우
        if(savedUser != null) {
            if(!savedUser.getProviderType().equals(providerType)) {
                throw new RuntimeException("Looks like you're signed up with " + providerType +
                        " account. Please use your " + savedUser.getProviderType() + " account to login.");
            }
            updateUser(savedUser, oAuth2UserInfo);
        }
        //미가입 경우
        else {
            savedUser = registerUser(providerType, oAuth2UserInfo);
        }
        return UserPrincipal.create(savedUser, oAuth2User.getAttributes());
   }

    private User registerUser(ProviderType providerType, OAuth2UserInfo oauth2UserInfo) {

        LocalDateTime now = LocalDateTime.now();

        User user = User.builder()
                .userId(oauth2UserInfo.getId())
                .username(oauth2UserInfo.getName())
                .email(oauth2UserInfo.getEmail())
                .emailVerifiedYn("Y")
                .providerType(providerType)
                .role(Role.GUEST)
                .createdAt(now)
                .modifiedAt(now)
                .build();

        return userRepository.saveAndFlush(user);//1. 즉시 저장소에 반영되어야 하는 경우 2. 트랜잭션 내에서 엔티티의 변경 내용을 읽어야 하는 경우
    }

    private User updateUser(User savedUser, OAuth2UserInfo oAuth2UserInfo) {
        if(oAuth2UserInfo.getName() != null && !savedUser.getUsername().equals(oAuth2UserInfo.getName())) {
            savedUser.setUsername(oAuth2UserInfo.getName());
        }

        return savedUser;
    }
}

oauth 인증이 정상적으로 완료되었을 경우 회원 정보를 처리하기 위해 생성한 custom class입니다.

 

loadUser 메서드가 호출되면 OAuth2UserRequest 객체에는 oauth 인증 결과인 Access Token을 포함하고 있습니다. 해당 Access Token은 oauth2User 객체 정보를 얻어오는데, 해당 객체에는 Resorce Server에서 받아온 사용자 정보가 포함되어 있습니다. 

 

process() 메서드의 역할은 google, kakao 등 oauth 인증 요청 플랫폼을 구분하여 각각 사용자의 정보 형태에 맞는 OAuth2UserInfo 객체를 가져와 회원가입 또는 정보 갱신 로직을 처리하는 부분입니다.

 


OAuth2UserInfoFactory

public class OAuth2UserInfoFactory {

    public static OAuth2UserInfo getOAuth2UserInfo(ProviderType providerType, Map<String, Object> attributes) {
        switch (providerType) {
            case GOOGLE: return new GoogleOAuth2UserInfo(attributes);
            case KAKAO: return new KakaoOAuth2UserInfo(attributes);
            default: throw new IllegalArgumentException("Invalid Provider Type.");
        }
    }

}

 

OAuth2UserInfo

public abstract class OAuth2UserInfo {

    protected Map<String, Object> attributes;

    public OAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    public Map<String, Object> getAttributes() {
        return attributes;
    }

    public abstract String getId(); //소셜 식별 값 : 구글 - "sub", 카카오 - "id", 네이버 - "id"
    public abstract String getName();
    public abstract String getEmail();
}

 

GoogleOAuth2UserInfo

public class GoogleOAuth2UserInfo extends OAuth2UserInfo {

    public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);//부모 class의 생성자를 호출(자식 class의 생성자에서만 사용가능)
    }

    @Override
    public String getId() {
        return (String) attributes.get("sub");
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }
}

 

KakaoOAuth2UserInfo

public class KakaoOAuth2UserInfo extends OAuth2UserInfo {

    public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getId() {
        return attributes.get("id").toString();
    }

    @Override
    public String getName() {
        Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");//profile

        if(properties == null) {
            return null;
        }

        return (String) properties.get("nickname");
    }

    @Override
    public String getEmail() {
        //return (String) attributes.get("email");
        return (String) ((Map<String, Object>) attributes.get("kakao_account")).get("email");
    }
}

각 플랫폼에 대한  class를 따로 만들어 주었습니다. 하나의 class 내부에서 처리하는 것도 가능합니다.

 

하지만, 각 플랫폼에서 응답을 주는 사용자 데이터의 형식이 조금씩 다르기 때문에 이처럼 구현한 것입니다. 각 플랫폼에서 넘어오는 데이터 형식이 궁금하다면 CustomOAuth2UserService 클래스의 loadUser() 메서드에서 OAuth2User 객체를 디버깅하시면 확인 가능합니다.

 

OAuth2User.attributes() : sub, name, given_name, family_name, picture, email, email_verified, local - (google)
OAuth2User.attributes() : id, connected_at, properties, kakao_account - (kakao)

 


OAuth2AuthenticationFailureHandler

/**
 * successHandler와 마찬가지로 최초 oauth 인증 요청 시, auth_code와 콜백 url로 리다이렉션
 * cookie에 저장된 redirect_uri를 가져옵니다. 그리고 인증에 대한 오류를 error라는 parameter로 url에 함께 붙여 보내게 됩니다.
 */
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        // 쿠키에 저장된 redirect_url 가져오기
        String targetUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue)
                .orElse("/");

        // query param 추가 후, uri 문자열로 변환
        targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("error", exception.getLocalizedMessage())
                .build().toUriString();

        cookieAuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);

        // redirect
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

SuccessHandler와 마찬가지로, 처음 oauth 인증 요청 시에 cookie에 저장된 redirect_url을 가지고 옵니다. 이후 인증에 대한 오류를 error라는 parameter로 url에 붙여서 clinet 측으로 보냅니다.

 

cookie를 사용하는 것은 최초로 인증 요청이 들어왔을 때 Front에서 redirect를 받기 원하는 페이지로 다시 redirect 해주기 위한 요청값을 저장하기 위함이에요.

 

참고로 해당 예시 코드에서는 cookie가 작동하는 부분이 없다는 것입니다. 이유는 oauth 인증 요청이 프론트에서 들어오는 것이 아니라 WebSecurityConfigure 클래스의 oauth2Login() 설정으로 인해 DefaultLoginPageGeneratingFilter에서 생성되는 기본 소셜 로그인 페이지를 사용하기 때문인데요.

 

해당 페이지를 확인해 보면 인증 요청 경로에 redirect_uri 데이터가 없으며, 때문에 아래에서 살펴볼 CookieAuthorizationRequestRepository 클래스의 saveAuthorizationRequest() 메서드가 동작할 때 redirectUriAfterLogin에 대한 데이터가 쿠키에 저장되지 않는 것을 확인할 수 있습니다.

 

OAuth2AuthenticationSuccessHandler

@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final AuthTokenProvider tokenProvider;
    private final AppProperties appProperties;
    private final UserRefreshTokenRepository userRefreshTokenRepository;
    private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        String targetUrl = determineTargetUrl(request, response, authentication);

        if(response.isCommitted()) {
            log.debug("Response has already been committed.");
            return;
        }

        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        Optional<String> redirectUri = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue);

        //cookie uri가 존재 + 허용된 도메인인가 : (미리 설정한 redirect url과 같은지 확인 작업)
        if(redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
            throw new IllegalArgumentException("Sorry! We've got an Unauthorized Redirect URI and can't proceed with the authentication");
        }

        // 사용자가 인증에 실패하면 /login 페이지로 리디렉션, error라는 쿼리 파라미터가 추가
        String targetUrl = redirectUri.orElse(getDefaultTargetUrl());

        OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;//인증에 사용된 oauth2 토큰
        ProviderType providerType = ProviderType.valueOf(authToken.getAuthorizedClientRegistrationId().toUpperCase());//제공자 유형

        OidcUser user = (OidcUser) authentication.getPrincipal();//사용자 정보
        OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
        Collection<? extends GrantedAuthority> authorities = ((OidcUser) authentication.getPrincipal()).getAuthorities();//권한

        Role roleType = hasAuthority(authorities, Role.ADMIN.getKey()) ? Role.ADMIN : Role.USER;

        Date now = new Date();

        // Access Token 생성
        AuthToken accessToken = tokenProvider.createAuthToken(
                userInfo.getId(),
                roleType.getKey(),
                new Date(now.getTime() + appProperties.getAuth().getTokenExpiry())
        );

        // Refresh Token 생성
        long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry();

        AuthToken refreshToken = tokenProvider.createAuthToken(
                appProperties.getAuth().getTokenSecret(),
                new Date(now.getTime() + refreshTokenExpiry)
        );

        // DB save
        UserRefreshToken userRefreshToken = userRefreshTokenRepository.findByUserId(userInfo.getId());
        if(userRefreshToken != null) {
            userRefreshToken.setRefreshToken(refreshToken.getToken());
        } else {
            userRefreshToken = UserRefreshToken.builder()
                    .userId(userInfo.getId())
                    .refreshToken(refreshToken.getToken())
                    .build();
            userRefreshTokenRepository.saveAndFlush(userRefreshToken);
        }

        int cookieMaxAge = (int) (refreshTokenExpiry / 60);

        // Access Token : LocalStorage / Refresh Token : Cookie(http only secure)에 저장
        CookieUtils.deleteCookie(request, response, REFRESH_TOKEN);
        CookieUtils.addCookie(response, REFRESH_TOKEN, refreshToken.getToken(), cookieMaxAge);

        return UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("token", accessToken.getToken())
                .build().toUriString();
    }

    protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        cookieAuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }

    private boolean hasAuthority(Collection<? extends GrantedAuthority> authorities, String authority) {
        if (authorities == null) {
            return false;
        }

        for (GrantedAuthority grantedAuthority : authorities) {
            if (authority.equals(grantedAuthority.getAuthority())) {
                return true;
            }
        }
        return false;
    }

    private boolean isAuthorizedRedirectUri(String uri) {
        URI clientRedirectUri = URI.create(uri);

        return appProperties.getOAuth2().getAuthorizedRedirectUris()
                .stream()
                .anyMatch(authorizedRedirectUri -> {
                    //validate host and port
                    URI authorizedUri = URI.create(authorizedRedirectUri);
                    if(authorizedUri.getHost().equalsIgnoreCase(clientRedirectUri.getHost())// getHost() - 도메인 부분 추출
                            && authorizedUri.getPort() == clientRedirectUri.getPort()) {
                        return true;
                    }
                    return false;
                });
    }

}

oauth 인증이 성공하면 CustomOAuth2UserService를 거치고 마지막으로 실행되는 곳입니다. 해당 핸들러는 security 사용자 인증 정보를 통해서 jwt access token을 생성하고, 최초 oauth 인증 요청 시 받았던 redirect_uri를 검증하여 해당 uri로 access token을 내려주는 코드가 구현되어 있습니다.

 


RestAuthenticationEntryPoint

/**
 * 사용자가 인증없이 요청시, 401 unauthorized 처리(인증)
 */
@Slf4j
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        log.info("Responding with unauthorized error. Message = {}", authException.getMessage());
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getLocalizedMessage());
    }
}

401 unauthorized 처리

 

TokenAccessDeniedHandler

/**
 * 사용자가 권한없는 요청시, 403 Forbidden 처리(인가)
 */
@Component
@RequiredArgsConstructor
public class TokenAccessDeniedHandler implements AccessDeniedHandler {

    private final HandlerExceptionResolver handlerExceptionResolver;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        //response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
        handlerExceptionResolver.resolveException(request, response, null, accessDeniedException);
    }
}

403 Forbidden 처리

 


AppProperties

@Getter
@Component
@ConfigurationProperties(prefix = "app")
public class AppProperties {

    private final Auth auth = new Auth();
    private final OAuth2 OAuth2 = new OAuth2();

    @Getter
    @Setter
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Auth {
        private String tokenSecret;
        private long tokenExpiry;
        private long refreshTokenExpiry;
    }

    public static final class OAuth2 {// final로 최종 코드임을 명시
        private List<String> authorizedRedirectUris = new ArrayList<>();//OAuth2 인증 후 리다리렉션 uri 목록

        public List<String> getAuthorizedRedirectUris() {
            return authorizedRedirectUris;
        }

        public OAuth2 authorizedRedirectUris(List<String> authorizedRedirectUris) {
            this.authorizedRedirectUris = authorizedRedirectUris;
            return this;
        }
    }
}

앞의 application.yml에서 설정해 준 내용들을 사용할 class입니다.

 

JwtConfig

@Configuration
public class JwtConfig {
    @Value("${jwt.secret}")
    private String secret;

    @Bean
    public AuthTokenProvider jwtProvider() {
        return new AuthTokenProvider(secret);
    }
}

 

AuthToken

@Slf4j
@Getter
@RequiredArgsConstructor
public class AuthToken {

    private final Key key;
    private final String token;

    private static final String AUTHORITIES_KEY = "role";

    AuthToken(String id, Date expiry, Key key) {
        this.key = key;
        this.token = createAuthToken(id, expiry);
    }

    AuthToken(String id, String role, Date expiry, Key key) {
        this.key = key;
        this.token = createAuthToken(id, role, expiry);
    }

    private String createAuthToken(String id, Date expiry) {
        return Jwts.builder()
                .setSubject(id)
                .signWith(key, SignatureAlgorithm.HS256)
                .setExpiration(expiry)
                .compact();
    }

    private String createAuthToken(String id, String role, Date expiry) {
        return Jwts.builder()
                .setSubject(id)
                .claim(AUTHORITIES_KEY, role)
                .signWith(key, SignatureAlgorithm.HS256)
                .setExpiration(expiry)
                .compact();
    }

    public boolean validate() {
        return this.getTokenClaims() != null;
    }

    public Claims getTokenClaims() {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (SecurityException e) {
            log.info("Invalid JWT signature.");
        } catch (MalformedJwtException e) {
            log.info("Invalid JWT token.");
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT token.");
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT token.");
        } catch (IllegalArgumentException e) {
            log.info("JWT token compact of handler are invalid.");
        }
        return null;
    }

    // Access Token 만료 갱신 시, 사용할 claims 을 얻기 위함.
    // 만료된 token의 claims 정보
    public Claims getExpiredTokenClaims() {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch(ExpiredJwtException e) {
            log.info("Expired JWT token.");
            return e.getClaims();
        }
        return null;
    }
}

 

AuthTokenProvider

@Slf4j
public class AuthTokenProvider {

    private final Key key;
    private static final String AUTHORITIES_KEY = "role";

    public AuthTokenProvider(String secret) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes());
    }

    public AuthToken createAuthToken(String id, Date expiry) {
        return new AuthToken(id, expiry, key);
    }

    public AuthToken createAuthToken(String id, String role, Date expiry) {
        return new AuthToken(id, role, expiry, key);
    }

    public AuthToken convertAuthToken(String token) {
        return new AuthToken(key, token);
    }

    public Authentication getAuthentication(AuthToken authToken) {

        if (authToken.validate()) {// 유효하면 true
            Claims claims = authToken.getTokenClaims();// Claims - JWT JSON 정보
            Collection<? extends GrantedAuthority> authorities =
                    Arrays.stream(new String[]{claims.get(AUTHORITIES_KEY).toString()})
                            .map(SimpleGrantedAuthority::new)
                            .collect(Collectors.toList());

            log.debug("claims subject := [{}]", claims.getSubject());
            User principal = new User(claims.getSubject(), "", authorities);// 권한 정보로 User 객체 생성

            return new UsernamePasswordAuthenticationToken(principal, authToken, authorities);//인증된 사용자
        } else {
            throw new TokenValidFailedException();
        }
    }

}

jwt 토큰을 생성하고 유효성을 검사하는 기능을 합니다.

 

TokenAuthenticationFilter

@Slf4j
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {

    private final AuthTokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String accessToken = HeaderUtils.getAccessToken(request);
        AuthToken authToken = tokenProvider.convertAuthToken(accessToken);

        if(authToken.validate()) {
            Authentication authentication = tokenProvider.getAuthentication(authToken);// 인증된 사용자
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.debug(authentication.getName() + "의 인증정보 저장.");
        } else {
            log.debug("not exists invalid jwt token.");
        }

        filterChain.doFilter(request, response);
    }
}

 

어느 서블릿 컨테이너에서나 요청 당 한 번의 실행을 보장하는 것을 목표로 합니다.
동일한 request안에서 한 번만 필터링을 할 수 있게 해주는 것이 OncePerRequestFilter의 역할이고보통 인증 또는 인가와 같이 한번만 거쳐도 되는 로직에서 사용합니다.

 

이는 요청이 들어올 때 거치는 필터이고 우리가 발급한 Access Token을 검사해서 인증된 사용자인지 아닌지 판단한다고 생각하면 됩니다.

 


AuthController

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AppProperties appProperties;
    private final AuthTokenProvider tokenProvider;
    private final UserRefreshTokenRepository userRefreshTokenRepository;

    private final static long THREE_DAYS_MSEC = 259200000;
    private final static String REFRESH_TOKEN = "refresh_token";

    @GetMapping("/refresh")
    public ApiResponse refreshToken (HttpServletRequest request, HttpServletResponse response) {
        // accessToken 확인
        String accessToken = HeaderUtils.getAccessToken(request);
        AuthToken authToken = tokenProvider.convertAuthToken(accessToken);

        //이해가 안되는 부분
//        if(!authToken.validate()) { // 유효 시 pass
//            return ApiResponse.invalidAccessToken();
//        }

        // expired access token 확인
        Claims claims = authToken.getExpiredTokenClaims();
        if(claims == null) { // 만료되지 않았으면 pass
            return ApiResponse.notExpiredTokenYet();
        }

        String userId = claims.getSubject();
        Role role = Role.of(claims.get("role", String.class));

        // refresh Token - cookie 확인
        //-----------------------------------------------------------------------------
        String refreshToken = CookieUtils.getCookie(request, REFRESH_TOKEN)
                .map(Cookie::getValue)
                .orElse(null);
        AuthToken authRefreshToken = tokenProvider.convertAuthToken(refreshToken);

        if(!authRefreshToken.validate()) { // 유효하면 pass
            return ApiResponse.invalidRefreshToken();
        }

        // userId refresh Token - DB 확인
        UserRefreshToken userRefreshToken = userRefreshTokenRepository.findByUserIdAndRefreshToken(userId, refreshToken);
        if(userRefreshToken == null) {
            return ApiResponse.invalidRefreshToken();
        }

        Date now = new Date();
        AuthToken newAccessToken = tokenProvider.createAuthToken(
                userId,
                role.getKey(),
                new Date(now.getTime() + appProperties.getAuth().getTokenExpiry())
        );

        long validTime = authRefreshToken.getTokenClaims().getExpiration().getTime() - now.getTime();

        // refresh Token의 기간이 3일 이하 -> 갱신
        if(validTime <= THREE_DAYS_MSEC) {
            // refresh Token 설정
            long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry();

            authRefreshToken = tokenProvider.createAuthToken(
                    appProperties.getAuth().getTokenSecret(),
                    new Date(now.getTime() + refreshTokenExpiry)
            );

            // refresh Token - DB update
            userRefreshToken.setRefreshToken(authRefreshToken.getToken());

            int cookieMaxAge = (int) refreshTokenExpiry / 60;
            CookieUtils.deleteCookie(request, response, REFRESH_TOKEN);
            CookieUtils.addCookie(response, REFRESH_TOKEN, authRefreshToken.getToken(), cookieMaxAge);
        }
        return ApiResponse.success("token", newAccessToken.getToken());
    }
}

Access Token을 만료 시 Refresh Token을 통해서 다시 토큰을 갱신하는 부분이라고 알고 있습니다.

 

조금 이해 안 되는 부분이 있는데, Access Token이 만료되어 요청을 보내는 부분인데 Access token의 검증이 필요한지 모르겠어요. 참고한 포스팅에는 해당 코드가 있던데, 직접 테스트를 해보면 만료된 토큰이라서 주석된 부분에서 걸려버리게 되거든요. 해당 부분 혹시 아시는 분 있다면 공유해 주시면 감사하겠습니다. 


ApiResponseHeader

@Getter
@Setter
@AllArgsConstructor
public class ApiResponseHeader {
    private int code;
    private String message;
}

 

ApiResponse

@Getter
@RequiredArgsConstructor
public class ApiResponse<T> {

    private final static int SUCCESS = 200;
    private final static int NOT_FOUND = 400;
    private final static int FAILED = 500;
    private final static String SUCCESS_MESSAGE = "SUCCESS";
    private final static String NOT_FOUND_MESSAGE = "NOT FOUND";
    private final static String FAILED_MESSAGE = "서버에서 오류가 발생하였습니다.";
    private final static String INVALID_ACCESS_TOKEN = "Invalid access token.";
    private final static String INVALID_REFRESH_TOKEN = "Invalid refresh token.";
    private final static String NOT_EXPIRED_TOKEN_YET = "Not expired token yet.";

    private final ApiResponseHeader header;
    private final Map<String, T> body;

    public static <T> ApiResponse<T> success(String name, T body) {
        Map<String, T> map = new HashMap<>();
        map.put(name, body);

        return new ApiResponse(new ApiResponseHeader(SUCCESS, SUCCESS_MESSAGE), map);
    }

    public static <T> ApiResponse<T> fail() {
        return new ApiResponse(new ApiResponseHeader(FAILED, FAILED_MESSAGE), null);
    }

    public static <T> ApiResponse<T> invalidAccessToken() {
        return new ApiResponse(new ApiResponseHeader(FAILED, INVALID_ACCESS_TOKEN), null);
    }

    public static <T> ApiResponse<T> invalidRefreshToken() {
        return new ApiResponse(new ApiResponseHeader(FAILED, INVALID_REFRESH_TOKEN), null);
    }

    public static <T> ApiResponse<T> notExpiredTokenYet() {
        return new ApiResponse(new ApiResponseHeader(FAILED, NOT_EXPIRED_TOKEN_YET), null);
    }
}

 

HeaderUtil

public class HeaderUtils {

    private final static String HEADER_AUTHORIZATION = "Authorization";
    private final static String TOKEN_PREFIX = "Bearer ";

    public static String getAccessToken(HttpServletRequest request) {
        String headerValue = request.getHeader(HEADER_AUTHORIZATION);//authorization 값 return

        if(headerValue == null) {
            return null;
        }

        if(headerValue.startsWith(TOKEN_PREFIX)) {
            return headerValue.substring(TOKEN_PREFIX.length());//token 값 반환
        }

        return null;
    }

}

Front에서 보내는 Header 값에서 Access Token을 다룬다.

 

CookieUtil

public class CookieUtils {

    public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();

        if(cookies != null && cookies.length > 0) { //쿠키가 있으면
            for(Cookie cookie : cookies) {
                if(name.equals(cookie.getName())) {
                    return Optional.of(cookie);
                }
            }
        }
        return Optional.empty();
    }

    public static Optional<String> readServletCookie(HttpServletRequest request, String name) {
        return Arrays.stream(request.getCookies())
                .filter(cookie -> name.equals(cookie.getName()))
                .map(Cookie::getValue)
                .findAny();
    }

    public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
         Cookie cookie = new Cookie(name, value);
         cookie.setPath("/");//cookie의 유효한 범위 - 모든 경로에서 유효
         cookie.setHttpOnly(true);
         cookie.setMaxAge(maxAge);

         response.addCookie(cookie);
    }

    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
        Cookie[] cookies = request.getCookies();

        Arrays.stream(cookies)
                .filter(cookie -> name.equals(cookie.getName()))
                .forEach(cookie -> {
                    cookie.setValue("");
                    cookie.setPath("/");
                    cookie.setMaxAge(0);
                    response.addCookie(cookie);
                });
    }

    /**
     * 쿠키에 저장할 객체를 Base64로 인코딩
     * 크기가 작을수록 더 효율적으로 HTTP 헤더에 포함되어 전송가능
     */
    public static String serialize(Object object) {
        return Base64.getUrlEncoder()
                .encodeToString(SerializationUtils.serialize(object));
    }

    /**
     * 쿠키에 저장된 객체를 Base64로 디코딩
     * 크기가 작을수록 더 효율적으로 HTTP 헤더에 포함되어 전송가능
     */
    public static <T> T deserialize(Cookie cookie, Class<T> cls) {
        return cls.cast(
                SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue()))//쿠키값을 Object.class 객체로 변환
        );
    }


}

 


양이 많다 보니 모든 것을 정리하지 못했을 수도 있습니다. 틀린 내용이 있을 수도 있으니 참고만 해주셨으면 좋겠습니다. 제가 참고한 포스팅에 내용이 정말 잘 정리되어 있으니 이해 안 되는 부분이 있다면 해당 포스팅들 참고하시면 도움이 정말 많이 되실 거예요. 양질의 내용 공유해 주신 아래 분들에게 정말 감사드립니다. 도움이 참 많이 되었습니다.

 

참고한 포스팅

https://deeplify.dev/back-end/spring/oauth2-social-login#%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%A1%B0

 

https://wildeveloperetrain.tistory.com/252

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유