SpringBoot + React 환경에서 Google Oauth2 flow

개발하는 구황작물·2024년 4월 28일
1
post-thumbnail

이 글은 OAuth2에 대해 어느정도 알고 있다는 전재 하에 작성되었습니다.

사전지식

  • OAuth2 정의
  • OAuth2 타입(Implicit Grant, Authorization Code)
  • Google Console api 사이트에서 google oauth2 설정 방법

참고로 Authorization Code 방식으로 진행하였습니다.

OAuth2 인증은 프론트에서 일어나야 하는가 백엔드에서 일어나야 하는가?

처음에는 프론트에서 처리할지 백엔드에서 처리할지에 대해 고민은 전혀 하지 않고 백엔드에서 다 처리하고 토큰(혹은 세션) 만 잘 전달해주면 되겠지 하고 그냥 백엔드에서 전부 처리하였다.

그러나 백엔드에서 모든 과정을 처리하려 하니 프론트엔드로 JWT를 전달하는데 어려움을 겪었고 다른 방법을 찾기로 하였다.

1. 프론트엔드에서 전부 처리

말 그대로 프론트에서 모든 과정을 처리하고 사용자 정보만 백엔드로 넘기는 방법이다. (이후 백엔드에서 JWT를 만들어 프론트로 전달해주면 된다.)

필자는 백엔드이므로 패스하였다.

2. 백엔드에서 전부 처리

처음에 진행했던 방법으로 백엔드에서 Spring Security와 OAuth2를 활용한 방식이다.

진행 Flow는 아래와 같다.

  1. 사용자가 로그인 버튼 클릭
  2. 버튼을 누르면 프론트엔드는 백엔드로 로그인 요청
  3. 백엔드는 ClientId, redirectURL을 포함하여 인증 서버로 전송
  4. 인증 서버는 이를 확인하고 로그인 페이지를 사용자에게 전송
  5. 사용자가 로그인 진행
  6. 로그인 진행 후, 인증 서버는 이를 확인하여 Auth Code를 백엔드 Redirect URL로 전달
  7. 백엔드는 AuthCode를 가지고 다시 인증 서버로 사용자 정보 요청
  8. 인증 서버는 백엔드의 Redirect URL을 통해 사용자 정보를 전달
  9. 백엔드는 사용자 정보를 통해 세션 혹은 토큰을 프론트엔드로 성공 처리하는 URL로 Redirect

위의 과정에서 백엔드 -> 프론트엔드로 JWT를 보낼 수 있는 적절한 방법을 찾지 못했고 결국 다른 방법으로 구현하기로 하였다.

+) 24/07/18
나중에 추가적으로 공부를 한 사실인데, 쿠키에 담으면 JWT전달을 할 수 있다.

문제는... CORS 인데 https + isSecure true, sameSite=NONE(혹은 프론트와 도메인이 같게하고 sameSite=STRICT로 하거나), httpOnly=true로 하면 된다. (프론트에서는 요청 보낼 때 isCredential=true 설정을 해주어야 한다.)

추가적으로 SecurityConfig의 CORS 설정시 OriginAllow에 정확한 도메인 지정과 AllowMethod에 정확한 메서드 지정(GET,POST,PATCH,OPTIONS...)이 필요하다

3. 프론트엔드 + 백엔드

프론트엔드에서 인증 서버와의 통신으로 Auth Code 까지 받은 후, 이를 백엔드로 전달하여 백엔드에서 나머지 인증을 진행한 후, Response Header에 토큰(혹은 세션)을 붙여 이를 프론트엔드로 응답을 보내는 방법으로 진행되었다.

Flow는 아래와 같이 이루어졌다.

  1. 사용자가 프론트엔드로 로그인 요청
  2. 프론트엔드는 인증서버로 ClientId, Redirect URL을 가지고 로그인 페이지를 요청한다.
  3. 인증 서버는 사용자에게 로그인 페이지를 제공한다.
  4. 사용자가 로그인한다.
  5. 인증 서버는 사용자 정보를 확인하고 Auth Code를 프론트엔드에 전달한다.
  6. 프론트엔드는 이 Auth Code를 백엔드로 전달한다.
  7. 백엔드는 AuthCode를 인증서버로 전달하여 사용자 정보를 요청한다.
  8. 인증서버는 사용자 정보를 백엔드로 전송한다.
  9. 백엔드는 사용자 정보로 토큰(혹은 세션)을 만들고 프론트에서 요청한 URL 응답 헤더에 토큰을 붙여 전달한다.

중간에 프론트에서 백엔드로 AuthCode를 전달하는 과정 때문에 프론트엔드와 백엔드가 책임을 나눠가지는 방식은 추천하는 방식은 아니지만 구현이 쉽다는 장점이 있어 이 방법을 채택하기로 하였다.

React 코드

GoogleLogin.js

import { useEffect, useRef } from 'react'

export default function GoogleLogin({ onGoogleSignIn = () => {}, text = 'signin_with' }) {
  const googleSignInButton = useRef(null)

  useScript('https://accounts.google.com/gsi/client', () => {
    window.google.accounts.id.initialize({
      client_id: import.meta.env.VITE_CLIENT_ID,
      callback: onGoogleSignIn,
    })
    window.google.accounts.id.renderButton(googleSignInButton.current, {
      theme: 'filled_black',
      size: 'large',
      text,
      width: '250',
    })
  })

  return <div ref={googleSignInButton}></div>
}

const useScript = (url, onload) => {
  useEffect(() => {
    const script = document.createElement('script')

    script.src = url
    script.onload = onload

    document.head.appendChild(script)

    return () => {
      document.head.removeChild(script)
    }
  }, [url, onload])
}

이때 Client_id는 .env에 저장한 후, .gitignore에 .env를 추가해야 한다.

Login.js

import React, { useEffect } from 'react'
import { Link } from 'react-router-dom'
import GoogleLogin from './GoogleLogin'
import { setCookies, getCookies, setTokenAtCookies } from '../../../cookie/Cookie'
import axios from 'axios'

const Login = () => {
  const onGoogleSignIn = async (res) => {
    const { credential } = res
    const result = await axios.post(
      'http://localhost:8080/api/googleLogin',
      JSON.stringify({ code: credential }),
      {
        headers: {
          Accept: 'application/json',
          'Content-Type': 'application/json',
        },
      },
    )
    const status = result.status
    if (status !== 200) console.error('login failed')
    console.log(result.headers)
    const accessToken = result.headers.authorization
    const refreshToken = result.headers.refresh

    setTokenAtCookies(accessToken, refreshToken)
  }

  return (
    <div className="bg-body-tertiary min-vh-100 d-flex flex-row align-items-center">
      <CContainer>
        <CRow className="justify-content-center">
          <CCol md={8}>
            <CCardGroup>
              <CCard className="p-4">
                <CCardBody>
                  <CForm>
                    <h1>Login</h1>
                    <p className="text-body-secondary">Sign In to your account</p>
                    <GoogleLogin onGoogleSignIn={onGoogleSignIn} text="Google login" />
                  </CForm>
                </CCardBody>
              </CCard>
            </CCardGroup>
          </CCol>
        </CRow>
      </CContainer>
    </div>
  )
}

export default Login

Spring Boot 코드

백엔드에서 OAuth2 인증 과정을 전부 진행했을 때와 달리 'org.springframework.boot:spring-boot-starter-oauth2-client' dependency 대신 'com.google.api-client:google-api-client:2.4.0' 를 추가해야 했다. 또한 Security filter chain에 oauth2를 빼야 했다.

dependencies {
  ...
  implementation 'com.google.api-client:google-api-client:2.4.0'
  implementation 'com.auth0:java-jwt:4.4.0'
}

AuthController

@RequiredArgsConstructor
@RestController
public class AuthController {
    private final AuthService authService;

    @PostMapping("/api/googleLogin")
    public ResponseEntity<?> googleAuthLogin(@RequestBody IdToken request, HttpServletResponse response) {
        TokenDto tokenDto = authService.login(request.code());
        response.addHeader("Authorization", tokenDto.accessToken());
        response.addHeader("Refresh", tokenDto.refreshToken());

        return ResponseEntity.ok().build();
    }
}

AuthService

@Slf4j
@Transactional
@Service
public class AuthService {
    private final GoogleIdTokenVerifier verifier;
    private final JwtTokenizer jwtTokenizer;
    private final UsersService usersService;

    public AuthService( @Value("${spring.security.oauth2.client.registration.google.client-id}")String clientId, JwtTokenizer jwtTokenizer, UsersService usersService) {
        this.jwtTokenizer = jwtTokenizer;
        this.usersService = usersService;
        NetHttpTransport transport = new NetHttpTransport();
        JsonFactory jsonFactory = new GsonFactory();
        this.verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory)
                .setAudience(Collections.singleton(clientId))
                .build();
    }

    public TokenDto login(String code) {
        try {
            GoogleIdToken idToken = verifier.verify(code);

            if(idToken == null) {
                log.info("idToken is null");
                return null;
            }
            GoogleIdToken.Payload payload = idToken.getPayload();
            String email = payload.getEmail();
            String firstName = (String) payload.get("given_name");
            String lastName = (String) payload.get("family_name");

            UsersRequestDto dto = UsersRequestDto.builder()
                    .email(email)
                    .name(firstName + lastName)
                    .provider("google")
                    .build();
            Users users = usersService.findOrCreateUsers(dto);

            String accessToken = "Bearer " + jwtTokenizer.createAccessToken(email);
            String refreshToken = "Bearer " + jwtTokenizer.createRefreshToken(users.getId());
            log.info("access token = {}", accessToken);
            saveAuthentication(users);

            return TokenDto.builder()
                    .accessToken(accessToken)
                    .refreshToken(refreshToken)
                    .build();
        } catch (Exception e) {
            log.error("error : ", e);
            throw new AuthException(AuthErrorCode.LOGIN_FAILED);
        }
    }

    public void saveAuthentication(Users users) {
        UserDetails userDetails = new UserAccount(users);

        List<GrantedAuthority> roles = new ArrayList<>();
        roles.add(new SimpleGrantedAuthority(users.getRole()));

        Authentication authentication =
                new UsernamePasswordAuthenticationToken(userDetails, null, roles);

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

JWTTokenizer

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenizer {
    @Value("${jwt.secretKey}")
    private String secretKey;

    @Value("${jwt.access.expiration}")
    private Long accessTokenExpirationPeriod;

    @Value("${jwt.refresh.expiration}")
    private Long refreshTokenExpirationPeriod;

    @Value("${jwt.access.header}")
    private String accessHeader;

    @Value("${jwt.refresh.header}")
    private String refreshHeader;

    private Algorithm jwtAlgorithm;

    private final Redis2Utils redisUtils;

    @PostConstruct
    public void setJwtAlgorithm() {
        this.jwtAlgorithm = Algorithm.HMAC512(secretKey);
    }

    private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
    private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
    private static final String EMAIL_CLAIM = "email";
    private static final String BEARER = "Bearer ";

    public String createAccessToken(String email) {
        Date now = new Date();

        return JWT.create()
                .withSubject(ACCESS_TOKEN_SUBJECT) //jwt subject 지정
                .withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod)) //토큰 만료
                .withClaim(EMAIL_CLAIM, email) //payload
                .sign(jwtAlgorithm); //algorithm
    }

    public String createRefreshToken(Long userId) {
        Date now = new Date();

        // 기존 refresh token 삭제
        redisUtils.deleteObject(userId);

        String refreshToken = JWT.create()
                .withSubject(REFRESH_TOKEN_SUBJECT)
                .withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
                .sign(jwtAlgorithm);
        redisUtils.addObject(userId, refreshToken, refreshTokenExpirationPeriod);

        return refreshToken;
    }

    public void sendAccessToken(HttpServletResponse response, String accessToken) {
        response.setHeader(accessHeader, accessToken);
        log.info("set accessToken to header");
    }

    public void sendRefreshToken(HttpServletResponse response, String refreshToken) {
        response.setHeader(refreshHeader, refreshToken);
        log.info("set refreshToken to header");
    }

    public Optional<String> extractEmail(HttpServletRequest request) {
        Optional<String> accessToken = extractAccessToken(request);
        try {
            if (accessToken.isPresent()) {
                String token = accessToken.get();
                String optionalEmail = JWT.require(Algorithm.HMAC512(secretKey))
                        .build()
                        .verify(token)
                        .getClaim(EMAIL_CLAIM)
                        .asString();
                return Optional.of(optionalEmail);
            }
            return Optional.empty();
        } catch (Exception e) {
            log.error("jwt not valid", e);
            throw new IllegalArgumentException(e);
        }

    }

    public String getEmail(String accessToken) {
        DecodedJWT jwt = JWT.decode(accessToken);
        return jwt.getClaim(EMAIL_CLAIM).asString();
    }

    public Optional<String> extractAccessToken(HttpServletRequest request) {
        return Optional.ofNullable(request.getHeader(accessHeader))
                .filter(at -> at.startsWith(BEARER))
                .map(at -> at.replace(BEARER, ""));
    }

    public Optional<String> extractRefreshToken(HttpServletRequest request) {
        return Optional.ofNullable(request.getHeader(refreshHeader))
                .filter(at -> at.startsWith(BEARER))
                .map(at -> at.replace(BEARER, ""));
    }

    public boolean isTokenValid(String token) {
        try {
            JWT.require(jwtAlgorithm)
                    .build()
                    .verify(token);
            return true;
        } catch (TokenExpiredException e) {
            log.error("token expired : {}", e.getMessage());
            return false;
        } catch (Exception e) {
            log.error("jwt error : {}", e.getMessage());
            return false;
        }
    }

    public boolean isTokenExpired(String token) {
        DecodedJWT jwt = JWT.decode(token);
        Date expDate = jwt.getExpiresAt();
        Date now = new Date();

        return now.after(expDate);
    }
}

SecurityConfig

@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
    private final JwtAuthorizationProcessingFilter jwtAuthorizationProcessingFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(FormLoginConfigurer::disable)
                .csrf(CsrfConfigurer::disable)
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(new AntPathRequestMatcher("/h2/**")).permitAll()
                        
                        .requestMatchers(new AntPathRequestMatcher("/api/googleLogin"), new AntPathRequestMatcher("/error"), new AntPathRequestMatcher("/index.html")).permitAll()
                        .requestMatchers(new AntPathRequestMatcher("/swagger-ui/**"), new AntPathRequestMatcher("/v3/**"), new AntPathRequestMatcher("/swagger-ui.html")).permitAll()
                        .requestMatchers(new AntPathRequestMatcher("/app/**"), new AntPathRequestMatcher("/topic/**"), new AntPathRequestMatcher("/web-socket-connection/**")).permitAll()
                        .anyRequest().authenticated());
        http
                .addFilterBefore(jwtAuthorizationProcessingFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();

    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:3000"));
        configuration.setAllowedMethods(List.of("*"));
        configuration.setAllowedHeaders(List.of("Authorization", "Refresh", "Content-type", "Origin", "Accept", "Access-Control-Allow-Origin", "Access-Control-Allow-Headers", "Access-Control-Allow-Methods"));
        configuration.setExposedHeaders(List.of("Authorization", "Refresh"));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);

        return source;
    }


}

JwtAuthorizationProcessFilter

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthorizationProcessingFilter extends OncePerRequestFilter {
    private static final String[] AUTHORIZATION_NOT_REQUIRED = new String[]{"/login", "/h2", "/web-socket-connection","/swagger-ui","/v3/api-docs","/topic/participant","/api/googleLogin"};
    private final JwtTokenizer jwtTokenizer;
    private final UsersRepository usersRepository;
    private final Redis2Utils redisUtils;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info("JwtAuthorizationProcessingFilter start");
        log.info("request.getRequestURI() = {}", request.getRequestURI());
        if (StringUtils.startsWithAny(request.getRequestURI(), AUTHORIZATION_NOT_REQUIRED)) {
            filterChain.doFilter(request, response);
            log.info("AUTHORIZATION_NOT_REQUIRED");
            return;
        }
        //accessToken 확인
        Optional<String> accessToken = jwtTokenizer.extractAccessToken(request);
        if (accessToken.isPresent()) {
            // 만약 accessToken 존재시 return;
            log.info("accessToken exist");
            boolean isAccessTokenValid = jwtTokenizer.isTokenValid(accessToken.get());
            if (isAccessTokenValid) {
                log.info("accessToken valid");
                setAuthentication(accessToken.get());
            } else {
                if(jwtTokenizer.isTokenExpired(accessToken.get())) {
                    log.info("access token expired");
                    Optional<String> refreshToken = jwtTokenizer.extractRefreshToken(request);
                    //refresh token 존재시 accessToken reissue 후 return;
                    if (refreshToken.isPresent()) {
                        //refreshToken valid check
                        checkRefreshToken(response, refreshToken, accessToken);
                    } else {
                        log.info("refresh token not exist");
                        throw new AuthException(REFRESH_TOKEN_NOT_EXIST);
                    }
                } else {
                    log.info("access token not valid");
                    throw new AuthException(JWT_NOT_VALID);
                }
            }
        } else {
            throw new AuthException(ACCESS_TOKEN_NOT_EXIST);
        }
        filterChain.doFilter(request, response);
    }

    private void checkRefreshToken(HttpServletResponse response, Optional<String> refreshToken, Optional<String> accessToken) {
        if (!jwtTokenizer.isTokenValid(refreshToken.get())) {
            log.info("refresh token not valid");
            throw new AuthException(JWT_NOT_VALID);
        }
        Users users = getUsers(accessToken.get());
        Optional<String> optionalRt = redisUtils.getObject(users.getId());
        if(optionalRt.isPresent()) {
            String rt = optionalRt.get();
            if(!rt.equals(refreshToken.get())) {
                throw new AuthException(JWT_NOT_VALID);
            }
        }
        reIssueToken(users, response);
    }

    private void reIssueToken(Users users, HttpServletResponse response) {
        log.info("checkRefreshTokenAndReIssueAccessToken start");

        String token = jwtTokenizer.createRefreshToken(users.getId());
        String accessToken = jwtTokenizer.createAccessToken(users.getEmail());

        //securityContext에 저장
        saveAuthentication(users);
        //response에 저장
        jwtTokenizer.sendAccessToken(response, accessToken);
        jwtTokenizer.sendRefreshToken(response, token);
        log.info("checkRefreshTokenAndReIssueAccessToken end");
    }

    private void setAuthentication(String accessToken) {
        Users users = getUsers(accessToken);
        saveAuthentication(users);
    }

    private Users getUsers(String accessToken) {
        String email = jwtTokenizer.getEmail(accessToken);
        return usersRepository.findByEmail(email)
                .orElseThrow(() -> new UserException(USER_NOT_FOUND));
    }

    private void saveAuthentication(Users users) {
        UserDetails userDetails = new UserAccount(users);

        List<GrantedAuthority> roles = new ArrayList<>();
        roles.add(new SimpleGrantedAuthority(users.getRole()));

        Authentication authentication =
                new UsernamePasswordAuthenticationToken(userDetails, null, roles);

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        log.info("should not filter = {}", request.getRequestURI());
        boolean result =  StringUtils.startsWithAny(request.getRequestURI(), AUTHORIZATION_NOT_REQUIRED);
        log.info("should not filter = {}", result);

        return result;
    }
}

IdToken

public record IdToken(String code) {
} // Authorization Code 전달

TokenDto

jwt 토큰 전달을 위해 사용

public record TokenDto(String accessToken, String refreshToken) {

    @Builder
    public TokenDto {
    }
}


Reference

https://blog.thelumayi.com/92
https://hudi.blog/oauth-2.0/
https://ttl-blog.tistory.com/1434#%F0%9F%A7%90%20%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94%20%EB%B0%A9%EB%B2%95-1

https://www.youtube.com/playlist?list=PLJkjrxxiBSFALedMwcqDw_BPaJ3qqbWeB

profile
어쩌다보니 개발하게 된 구황작물

0개의 댓글