[Spring Boot] OAuth2 + JWT + React 적용해보리기

오늘 팀원이랑 이야기를 해보다가 우려했던 일이 벌어졌다.. 우려했던 일이란?

Jwt 관련 내용은 다음 글에 있습니다.
Jwt 관련 내용

목차

  • 글을 쓰게된 이유
  • OAuth2란?
  • With Spring Boot
  • With Spring Boot + React
  • 구현

글을 쓰게 된 이유

OAuth2를 Rest API + React 구조로 적용하려고 하니 정보가 너무 없다.
관련 자료는 엄청나게 많은데, 해당하는 자료는 잘못된? 정보가 너무 많거나 구멍 나있는 부분이 너무나 많았다. 그래서 내가 삽질하면서 알아낸 정보들을 정리하고 공유하려고 한다.


OAuth2란?

간단하게 말해서 회원에 관한 모든 동작(id관리, 비밀번호 관리 등등 회원 쪽 기능)을 타 서비스에 의탁하는 것이다.

OAuth2

OAuth2는 회원의 계정정보를 다른 서비스에 의탁하기 위한 프로토콜이다.
대표적으로 사용되는 서비스로는 Kakao, Google, Facebook, Naver 등이 있다.

오늘은 Google과 Naver로만 구현해볼 것이다!

OAuth2 서버 구조에 따른 방식

OAuth2는 서비스의 서버 구조마다 다른 흐름으로 동작한다.

대표적으로 3가지 방식이 있다.

  1. 프론트에서 모든 인증 과정을 수행
  2. 백엔드에서 모든 인증 과정을 수행
  3. 프론트 + 백엔드 혼합으로 인증 과정을 수행
  • 프론트에서 모든 인증 과정을 수행
    이러한 방식으로 수행하려는 서비스는 보통 순수 프론트(React)로만 서비스가 수행될 경우를 말한다. 백엔드 서버와의 Http Messaging과정이 존재하지 않는다.

    React에서는 Next-Auth라는 라이브러리가 OAuth2의 모든 과정을 수행해주더라..

  • 백엔드에서 모든 인증 과정을 수행
    이 방식은 백엔드에서 페이지 관리까지 수행할 경우에 사용한다. RestFul API에는 부적합하다.

  • 프론트 + 백엔드 혼합으로 인증과정을 수행

이 방식은 RestFul API 방식에 적당한 방식이다. 해당 방식으로 구현을 할 예정이다.

3번 구조의 OAuth2 흐름

흐름을 먼저 그림으로 살펴보자.

그림으로만 보아서 이해가 안된다. 좀 더 상세히 글로 살펴보자.

  1. 사용자(사람)은 웹브라우저(리액트)에서 원하는 로그인 서비스를 선택한다.
  2. 리액트에서 다음과 같은 url로 요청을 하게 된다.
http://localhost:8080/oauth2/authorization/kakao?redirect_uri=@@@
  1. 백엔드 서버는 해당 요청이 오면 "oauth2/"부분에서 uri 캐치를 하여 이용 서비스에게로 리다이렉트를 하게 된다. 리다이렉트 uri는 다음과 같다.
https://kauth.kakao.com/oauth/authorize
?client_id=  
    ${kakao.clientID}    //1
	  &redirect_uri=${kakao에 등록한 redirectUri} // 2
	  &response_type=code //3

하나씩 살펴보자.
1번 : 카카오 OAuth2 서비스에 등록한 clientID이다.

2번 : 사용자가 인증을 성공했을 때, 카카오 서비스의 Authorization Code를 전달해주어야 하기 때문에 백엔드의 uri를 입력한다. 이 때, 카카오 서비스는 등록된 redirect uri와 쿼리 파라미터로 넘어온 redirect uri를 비교하고 두 값이 맞으면 해당 uri로 authorization code를 넘겨준다.

3번 : authorization code type이다.

google과 facebook과 같은 해외에서도 많이 쓰이는 서비스들은 주로 spring boot에 이미 설정되어 있다. 하지만 kakao나 naver는 yml에 리다이렉션 uri를 따로 설정해두어야 한다.

  1. 해당 url로 요청을 하면, 사용자의 브라우저에는 카카오 로그인 창이 나온다.
  2. 사용자가 로그인을 성공한다.
  3. kakao 서버에서 입력해둔 "redirect uri"로 code를 담아서 보내준다.
  4. 백엔드 서버는 해당 uri로 authorization code를 받는다.
  5. 백엔드 서버는 authorization code 요청에 담아서 token 요청 uri에 access token을 요청한다. 요청 uri는 다음과 같다.
https://kauth.kakao.com/oauth/token

token 요청 uri도 kakao나 naver는 yml에 따로 설정해두어야 한다.
8. authorization code가 올바르다면, accessToken이 응답될 것이다.

  1. 백엔드 서버는 accessToken을 이용하여 카카오 resource Server에 회원 정보를 요청한다.

  2. accessToken이 올바르다면 카카오 resource Server는 백엔드에게 사용자 정보를 넘겨준다.

  3. 백엔드는 사용자 정보를 받고, JWT (access token, refresh token)을 생성한다. (해당 정보로 db의 추가 정보 값을 조회해보고 조회가 안된다면 최초로그인이다.)

  4. 백엔드 서버는 해당 토큰을 요청에 포함하여 프론트엔드로 리다이렉트 시킨다. 리다이렉트 시킬때 uri는 2번에서 전달받은 redirect_uri이다.

사실상 요청은 카카오 서버가 했는데 어떻게 프론트로 응답을 할 수가 있나요?

이 때 , 대표적인 방법으로는 따로 로그인 과정 중에 필요한 uri를 약속하고 해당 uri로 리다이렉트 시켜버린다.

  • Redirect가 너무 많은거 아닌가요?

    맞다. 여기서 언급된 redirect가 2개가 있다.

  1. 카카오 서버가 백엔드로 auth code를 주기위한 redirect uri
  2. 사용자의 정보를 조회하고 프론트로 다시 요청하라고 하기위한 redirect uri

With Spring Boot

그럼 스프링 부트에서는 어떻게 해야하나?

먼저, 바로 위에서 이야기 했던 "Redirect URL"을 Spring Boot에서 인식하도록 yml에 필요한 설정을 해야 한다. 설정은 다음과 같다.

spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: <your id>
            client-secret: <your secret>
            redirect-uri: <your url>/login/oauth2/code/kakao
            authorization-grant-type: authorization_code
            client-authentication-method: POST
            client-name: Kakao
            scope:
              - profile
              - account_email
          naver:
            client-id: <your id>
            client-secret: <your secret>
            redirect-uri:  <your url>/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            scope:
              - name
              - email
          google:
            client-id: <your id>
            client-secret: your secret>
            scope:
              - profile
              - email
        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
          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
 

살펴보면 서비스는 총 3가지가 있는 것을 볼 수 있다. (kakao, naver, google)

근데 kakao, naver 두 가지와 google은 내용이 다른 것을 볼 수 있다.
이유는 google은 spring boot에서 자체적으로 등록이 되어있지만, kakao와 naver는 등록되어있지 않아서 추가적인 정보를 입력해준 것이다.

Spring Boot Flow

모든 과정은 Spring Security Filter 과정에서 수행된다. 한마디로 Login Controller는 존재하지 않는다.

  1. 가장먼저 OAuth2LoginAuthenticationFilter에서 OAuth2 로그인 과정이 수행된다.
  2. OAuth2 Filter 단에서 직접 커스텀한 OAuth2 Service의 "loadUser"메소드가 실행된다.
  3. 로그인을 성공하게 되면 Success Handler의 "onAuthenticationSuccess" 메소드가 실행된다.
  4. Success Handler에서 최초 로그인 확인 및 JWT 생성 및 응답 과정이 실행된다.

구현

구현 내용은 정말 많다. 대표적으로 google을 통해서 해보자!
몇몇 부분(controller 부분 및 기타 등등) 구멍난 곳이 많다. 근데 목적만 실행함에 있어서는 무리없다.

아래의 내용은 리액트가 빠졌습니다. 그리고 jwt 생성 후, 프론트를 redirect 시키는 부분은 없습니다.

구현 내용

  1. gradle
  2. Security Config 설정
  3. OAuth2 Service 구현
  4. OAuth2 Attribute 구현
  5. OAuth2 Success Handler 구현
  6. JWT Util 구현
  7. 기타 Domain, DTO 구현
  8. 기타 Util 구현
  9. JWT Filter 구현 및 등록

gradle

gradle은 다음과 같다.

dependencies {


	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
	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 'io.jsonwebtoken:jjwt-api:0.11.2'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'
	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'
}

Security Config 설정

SecurityConfig.java 생성

@RequiredArgsConstructor
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomOAuth2UserService oAuth2UserService;
    private final OAuth2SuccessHandler successHandler;
    private final TokenService tokenService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/token/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtExceptionFilter(),
                        OAuth2LoginAuthenticationFilter.class)
                .oauth2Login().loginPage("/token/expired")
                .successHandler(successHandler)
                .userInfoEndpoint().userService(oAuth2UserService);

        http.addFilterBefore(new JwtAuthFilter(tokenService), UsernamePasswordAuthenticationFilter.class);
    }
}

주요 코드를 살펴보자.

  • oauth2Login : oauth2Login 설정을 시작한다는 뜻이다.
  • loginPage : login 페이지 url을 직접 설정해준다는 뜻이다.
  • successHandler : 로그인 성공 시, handler를 설정해준다.
  • userInfoEndpoint : oauth2 로그인 성공 후 설정을 시작한다는 말이다.
  • userService : oAuth2UserService에서 처리하겠다는 말이다.

OAuth2 Service 구현

OAuth2UserService.java 생성

@Slf4j
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    	//  1번
        OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
        
        //	2번
        OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);
        
		//	3번
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        log.info("registrationId = {}", registrationId);
        log.info("userNameAttributeName = {}", userNameAttributeName);
        
        // 4번
        OAuth2Attribute oAuth2Attribute =
                OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        var memberAttribute = oAuth2Attribute.convertToMap();

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                memberAttribute, "email");
    }
}

코드를 살펴보자.

  1. DefaultOAuth2UserService 객체를 성공정보를 바탕으로 만든다.
  2. 생성된 Service 객체로 부터 User를 받는다.
  3. 받은 User로 부터 user 정보를 받는다.
  4. SuccessHandler가 사용할 수 있도록 등록해준다.

OAuth2 Atrribute 구현

OAuth2Attribute.java 생성



@ToString
@Builder(access = AccessLevel.PRIVATE)
@Getter
public class OAuth2Attribute {
    private Map<String, Object> attributes;
    private String attributeKey;
    private String email;
    private String name;
    private String picture;

    static OAuth2Attribute of(String provider, String attributeKey,
                              Map<String, Object> attributes) {
        switch (provider) {
            case "google":
                return ofGoogle(attributeKey, attributes);
            case "kakao":
                return ofKakao("email", attributes);
            case "naver":
                return ofNaver("id", attributes);
            default:
                throw new RuntimeException();
        }
    }

    private static OAuth2Attribute ofGoogle(String attributeKey,
                                            Map<String, Object> attributes) {
        return OAuth2Attribute.builder()
                .name((String) attributes.get("name"))
                .email((String) attributes.get("email"))
                .picture((String)attributes.get("picture"))
                .attributes(attributes)
                .attributeKey(attributeKey)
                .build();
    }

    private static OAuth2Attribute ofKakao(String attributeKey,
                                           Map<String, Object> attributes) {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");

        return OAuth2Attribute.builder()
                .name((String) kakaoProfile.get("nickname"))
                .email((String) kakaoAccount.get("email"))
                .picture((String)kakaoProfile.get("profile_image_url"))
                .attributes(kakaoAccount)
                .attributeKey(attributeKey)
                .build();
    }

    private static OAuth2Attribute ofNaver(String attributeKey,
                                           Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuth2Attribute.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .attributes(response)
                .attributeKey(attributeKey)
                .build();
    }

    Map<String, Object> convertToMap() {
        Map<String, Object> map = new HashMap<>();
        map.put("id", attributeKey);
        map.put("key", attributeKey);
        map.put("name", name);
        map.put("email", email);
        map.put("picture", picture);

        return map;
    }
}

해당 클래스는 provider 마다 제공해주는 정보 값들이 다르기 때문에, 분기처리를 위해서 구현한 클래스이다.

google, kakao, naver마다 다른 소스가 동작하도록 구현되어 있다.

OAuth2 Success Handler 구현

OAuth2SuccessHandler.java 생성

@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final TokenService tokenService;
    private final UserRequestMapper userRequestMapper;
    private final ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
            throws IOException, ServletException {
        OAuth2User oAuth2User = (OAuth2User)authentication.getPrincipal();
        UserDto userDto = userRequestMapper.toDto(oAuth2User);

        log.info("Principal에서 꺼낸 OAuth2User = {}", oAuth2User);
        // 최초 로그인이라면 회원가입 처리를 한다.
        String targetUrl;
        log.info("토큰 발행 시작");

        Token token = tokenService.generateToken(userDto.getEmail(), "USER");
        log.info("{}", token);
        targetUrl = UriComponentsBuilder.fromUriString("/home")
                .queryParam("token", "token")
                .build().toUriString();
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

Success Handler에 진입했다는 것은, 로그인이 완료되었다는 뜻이다.
이 때가 정말 중요하다.

해당 클래스의 주요 기능은 크게 2가지이다.

  1. 최초 로그인인지 확인
  2. Access Token, Refresh Token 생성 및 발급
  3. token을 포함하여 리다이렉트

JWT Service 구현

원래는 독자적으로 Util을 구현하는게 좋은 방향으로 판단된다. 여기서는 Service에 모두 구현되어있다.

TokenService.java 생성


@Service
public class TokenService{
    private String secretKey = "token-secret-key";

    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }


    public Token generateToken(String uid, String role) {
        long tokenPeriod = 1000L * 60L * 10L;
        long refreshPeriod = 1000L * 60L * 60L * 24L * 30L * 3L;

        Claims claims = Jwts.claims().setSubject(uid);
        claims.put("role", role);

        Date now = new Date();
        return new Token(
                "accesstoken",
                "refreshToken");
    }


    public boolean verifyToken(String token) {
        try {
            Jws<Claims> claims = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token);
            return claims.getBody()
                    .getExpiration()
                    .after(new Date());
        } catch (Exception e) {
            return false;
        }
    }


    public String getUid(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }
}

임시로 토큰을 "accessToken, refreshToken"으로 생성해두었다.

기타 Domain, DTO 구현

해당 부분은 설명은 생략하겠다. 코드만 올린다!

Token.java 생성

@ToString
@NoArgsConstructor
@Getter
public class Token {
    private String token;
    private String refreshToken;

    public Token(String token, String refreshToken) {
        this.token = token;
        this.refreshToken = refreshToken;
    }
}

UserDTO는 필요하지만 User는 아직 필요없으니 User는 만들지 않겠다.

UserDTO.java 생성

@NoArgsConstructor
@Getter
public class UserDto {
    private String email;
    private String name;
    private String picture;

    @Builder
    public UserDto(String email, String name, String picture) {
        this.email = email;
        this.name = name;
        this.picture = picture;
    }
}

JWT Filter 구현 및 등록

JwtAuthFilter.java 생성

@RequiredArgsConstructor
public class JwtAuthFilter extends GenericFilterBean {
    private final TokenService tokenService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = ((HttpServletRequest)request).getHeader("Auth");

        if (token != null && tokenService.verifyToken(token)) {
            String email = tokenService.getUid(token);

            // DB연동을 안했으니 이메일 정보로 유저를 만들어주겠습니다
            UserDto userDto = UserDto.builder()
                    .email(email)
                    .name("이름이에용")
                    .picture("프로필 이미지에요").build();

            Authentication auth = getAuthentication(userDto);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        chain.doFilter(request, response);
    }

    public Authentication getAuthentication(UserDto member) {
        return new UsernamePasswordAuthenticationToken(member, "",
                Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")));
    }
}

내용을 보면, jwt의 인증이 성공하면 SecurityContext에 해당 정보를 저장하는 것을 볼 수 있다.

이제 Filter를 등록해보자.

SecurityConfig.java 수정

@RequiredArgsConstructor
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomOAuth2UserService oAuth2UserService;
    private final OAuth2SuccessHandler successHandler;
    private final TokenService tokenService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/token/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthFilter(tokenService),
                        UsernamePasswordAuthenticationFilter.class) 
                        //여기 위에 부분 추가했음!!
                .oauth2Login().loginPage("/token/expired")
                .successHandler(successHandler)
                .userInfoEndpoint().userService(oAuth2UserService);

        http.addFilterBefore(new JwtAuthFilter(tokenService), UsernamePasswordAuthenticationFilter.class);
    }
}

addFilterBefore 부분을 추가했고, 이제 스프링 시큐리티의 UsernamePasswordAuthenticationFilter가 실행되기 전에 JwtAuthFilter가 먼저 실행될 것이다.

완료했다.
이제 url에 다음을 쳐보자.
run하고

http://localhost:8080/oauth2/authorization/google

그러면 로그인 창이 뜰 것이다.

로그인을 하게 되면

accessToken : accessToken,
refreshToken : refreshToken이 정상적으로 응답된 것을 볼 수 있다.

log를 확인하면 Oauth2User에서 받은 user 정보가 log로 찍히는 것을 볼 수 있다.


참고

좋은 웹페이지 즐겨찾기