Springboot 구글 OAuth2 로그인 + JWT

60jong·2022년 7월 9일
2

Spring

목록 보기
1/9
post-thumbnail
post-custom-banner

이번에 앱 런칭(Todoary)을 준비하며 보안을 위해 Spring Security를 사용해 보았다.

개발은 Rest API 서버 개발이다.

요구사항은

  • 일반 로그인 -> 자동 로그인 선택 가능
  • 소셜 로그인 (구글 , 애플) -> 자동 로그인
  • 매 요청마다 JWT (Access Token, Refresh Token) 를 이용한 인증
  • JWT는 DB에 저장되는 유저의 id를 이용한다.
  • Access Token은 2시간, Refresh Token은 90일의 생명주기를 같는다.
  • Refresh Token은 DB에 저장한다.

이다.

Spring Security란?

Spring의 하위 프레임워크로, Spring 기반으로 애플리케이션의 보안(인증, 권한, 인가)을 담당한다. 인증 및 인가는 주로 Filter의 흐름을 통해 이루어진다.

  • Filter : 클라이언트에서 요청이 들어오면 Dispatcher Servlet을 거쳐 알맞은 Controller로 요청이 진행되는데, Dispatcher Servlet에 이르기 전에 Filter를 먼저 거치고, 응답이 나가기 전에도 거치게 된다.
  • 키워드
    • 인증 : Authentication, 권한을 확인
    • 인가 : Authorization, 권한을 부여
    • 권한 : Authority

Spring Security에서는 권한(ROLE)을 통해 uri 접근을 제한한다. 

http
		.authroizeRequests()
        .antMathcers("/auth/**").permitAll()
        .anyRequest().authenticated();
        
이런 식으로, 

	- .authroizeRequests() : 요청에 대해 권한을 체크하겠다.
    - .antMathcers("/auth/**") : /auth/** 형식의 uri는 | .permitAll() : 모두 허용한다.
    - .anyRequest() : 그 외의 요청들은 | .authenticated() : 인증이 필요하다.
    
하지만, 유효한 JWT를 통해서만 정보를 응답받을 수 있고, 모든 Role은 User이므로 권한 체크는 생략하기로 한다.


프로젝트 구조

  • build.gradle
dependencies {
    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'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    implementation('javax.xml.bind:jaxb-api')
    implementation('io.jsonwebtoken:jjwt-api:0.11.5')
    implementation('io.jsonwebtoken:jjwt-impl:0.11.5')
    implementation('io.jsonwebtoken:jjwt-jackson:0.11.5')
}



프로젝트 설명

Spring Security에서 Security설정을 하기 위해서는 @EnableWebSecurity 어노테이션과
WebSecurityConfigurerAdapter를 상속 받는 클래스가 필요하다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	```
}

먼저, @EnableWebSecurity(debug=true)를 통해 현재 Security filter chain을 확인해보자.

Security filter chain: [
  DisableEncodeUrlFilter
  WebAsyncManagerIntegrationFilter
  SecurityContextPersistenceFilter
  HeaderWriterFilter
  LogoutFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  SessionManagementFilter
  ExceptionTranslationFilter
]

우리는 OAuth2 로그인도 진행할 것이기 때문에 http.oauth2Login()을 추가해보자.

@Configuration
@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();

        http
                .oauth2Login()
                .userInfoEndpoint()
                .userService(principalOAuth2DetailsService);
    }
}

.userInfoEndpoint().userService(principalOAuth2DetailsService)는 밑에서 설명.

Security filter chain: [
  DisableEncodeUrlFilter
  WebAsyncManagerIntegrationFilter
  SecurityContextPersistenceFilter
  HeaderWriterFilter
  LogoutFilter
  OAuth2AuthorizationRequestRedirectFilter
  OAuth2LoginAuthenticationFilter
  DefaultLoginPageGeneratingFilter
  DefaultLogoutPageGeneratingFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  SessionManagementFilter
  ExceptionTranslationFilter
]

중간에 OAuth2~~로 시작하는 filter들이 추가된 것을 확인할 수 있다.


구글 OAuth2 로그인 흐름

  1. http://localhost:8080/oauth2/authorization/google로 접속

  2. Google Server는 로그인 페이지 응답

  3. ID, PWD 입력 시, Google Server에서 인증 시도

  4. 인증 성공 시, Client로 Authentication token 발급 & Redirect

  5. Client는 Authentication token을 이용해 Google Server에 Access Token 발급 요청

우리는 자체적으로 Access Token과 Refresh Token을 발급 및 관리할 것이기 때문에
위 과정은 생략하고 바로 인증된 OAuth2User 객체를 받아와서 처리한다.

따라서 SecurityConfig

http
                .oauth2Login()
                .userInfoEndpoint()
                .userService(principalOAuth2DetailsService); 

를 추가한 것이다.

  • .oauth2Login() : OAuth2 로그인을 이용한다.
  • .userInfoEndpoint() : 로그인된 유저의 정보를 가져온다.
  • .userService(principalOAuth2DetailsService) : 가져온 유저의 정보를 principalOAuth2DetailsService 객체가 처리한다.

DefaultOAuth2UserService

언급된 principalOAuth2DetailsService 객체의 클래스를 먼저 보자.

@Component
public class PrincipalOAuth2DetailsService extends DefaultOAuth2UserService {
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        System.out.println(oAuth2User.getAttributes());

        return oAuth2User;
    }
}

PrincipalOAuth2DetailsService 클래스는 DefaultOAuth2UserService클래스를 확장하는데, OAuth2 로그인에 성공하면, OAuth2UserRequest에 유저 정보가 담기게 된다.

따라서 아래 코드를 다시 설명하면,

http
                .oauth2Login()
                .userInfoEndpoint()
                .userService(principalOAuth2DetailsService); 

OAuth2 로그인을 진행하고, 로그인 성공 시에 유저 정보를 가져와 principalOAuth2DetailsService 객체의 loadUser 함수를 호출하며 OAuth2UserRequest에 유저 정보를 담게 된다.


그러면 로그인을 진행해 console에 oAuth2User가 찍히는지 확인해보자.

  1. http://localhost:8080/oauth2/authorization/google 접속
  1. 로그인 성공 시

    {
        sub=xxxxxxxxxxxxxxx, 
        name=유경종, 
        given_name=경종, 
        family_name=유, 
        picture=https:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, 
        email=xxxxxxxxxxxxx@gmail.com, 
        email_verified=true, 
        locale=ko
    }

    (콘솔에 찍힌 것을 가져옴)

올바르게 유저 정보가 넘어온 것을 확인할 수 있다.

이 정보를 이용해 회원 정보가 DB에 있다면 로그인을 진행하며 Token을 발급하고, DB에 없다면 강제로 회원가입을 진행시켜준 뒤, 로그인과 Token 발급을 진행하려고 한다.


일반 로그인 흐름

  1. 회원가입이 먼저
    POST : http://localhost:8080/auth/signup 요청할 때,
    @RequestBody, JSON 형식으로 가입 정보를 담아서 요청
    {
    	"username" : "유경종",
        "nickname" : "YKJ",
        "email" : "xxxxxx@gmail.com",
        "password" : "123456"
    }
  1. DB에 회원 정보가 있는지 확인, 없다면 회원가입을 진행

  2. 회원가입 정보를 통해 로그인
    일반 로그인 : http://localhost:8080/auth/signin -> Access Token 발급
    자동 로그인 : http://localhost:8080/auth/signin/auto -> Access Token, Refresh Token 발급


구현 내용


일반 로그인

회원가입 구현

/auth/signup으로 PostMapping된 join Bean은 회원가입할 때 필요한 정보를 담은 PostSignUpReq라는 DTO 객체를 요청 시에 입력 받는다.

{
    "username" : "유경종",
    "nickname" : "YKJ",
    "email" : "xxxxxx@gmail.com",
    "password" : "123456"
}  

이를 이용해 email로 중복 validation을 체크하고 중복이 없다면, DB에 추가한다. 이때, 비밀번호는 PasswordEncoder로 복호화가 불가능하게 암호화해 저장한다.

(사용자 인증시에는 matches()로 raw pwd(입력한)와 encoded pwd(저장된) 일치 여부를 확인할 수 있다.)

  • Test

DB에 저장될 때, password는 BCrypt 암호화를 거친 모습을 볼 수 있다.

로그인 구현

/auth/signin, /auth/signin/auto으로 PostMapping된 login, loginAuto Bean들은
email과 password를 입력 받아서 먼저 이를 통해 유효한 email-pwd인지 인증authenticate한다.

유효하다면, Access Token or Access Token & Refresh Token을 발급하게 된다.

  • Test (일반 로그인)

일반 로그인은 Access Token만 발급한다.


  • Test (자동 로그인)

자동 로그인은 Access Token과 Refresh Token을 발급하고, DB에도 user_id에 맞게 Refresh Token이 저장된 것을 볼 수 있다.


구글 로그인

http://localhost:8080/oauth2/authorization/google 에서 로그인에 성공할 경우, OAuth2SuccessHandler 객체가 호출되는데 이 곳에서 Access Token & Refresh Token을 발급하게 된다.

  • Test


구글 로그인에 성공하면 Access Token과 Refresh Token을 모두 발급하는데 정상적으로 응답 받았고, DB에도 회원정보, Refresh Token이 정상적으로 저장됨을 확인할 수 있다.


자세한 코드는 https://github.com/60jong/Security-Study 참고

profile
울릉도에 별장 짓고 싶다
post-custom-banner

1개의 댓글

comment-user-thumbnail
2024년 3월 28일

안녕하세요! 글 너무 잘 봤습니다.

다름이 아니라 궁금한점이 있는데 OAuth2 로그인 성공시 AccessToken 을 Resource Server 에서 받아오는게 아닌 JwtTokenProvider 를 이용해 AccessToken을 만드는거 같은데 설명하실때

http://localhost:8080/oauth2/authorization/google로 접속

Google Server는 로그인 페이지 응답

ID, PWD 입력 시, Google Server에서 인증 시도

인증 성공 시, Client로 Authentication token 발급 & Redirect

Client는 Authentication token을 이용해 Google Server에 Access Token 발급 요청

다음과 같은 순서로 AccessToken 을 발급받는다고 하셨는데 Google Server 에 AccessToken을 발급요청 하지 않고 자체적으로 만들어진 이유가 혹시 있을까요 ??

답글 달기