SpringBoot + React 환경에서 Google Oauth2 flow

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

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

사전지식

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

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

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

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

그러나 백엔드에서 모든 과정을 처리하려 하니 프론트엔드가 백엔드한테 끼워 맞춰야 하는 느낌을 강하게 받았고 결국 백엔드에서 모든 과정을 처리하는 방법은 좋은 방법이 아니라는 것을 깨닫게 되었다.

그래서 다시 원점으로 돌아가 OAuth2 인증 플로우 구성부터 시작하게 되었다.

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

프론트에서 OAuth2 인증을 모두 처리한다면 Client Id, Client Secret 가 둘다 노출이 될 가능성이 높다.

위의 방법은 고려조차 하지 않고 패스하였다.

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

위의 과정은 단점이 존재한다.

  1. 로그인 성공 후, 백엔드 URL로 리다이렉트가 발생하는데 이로 인해 백엔드 URL이 노출되게 된다.

  2. 백엔드에서 로그인을 성공적으로 진행하고 프론트엔드로 리다이렉트 시킬 때 토큰(혹은 세션)을 URL 파라미터에 붙여서 보내야 한다. 위의 방식은 안전하지 못한 방식이라 생각했다.

결국 위의 방식을 철회하고 다른 방식을 찾아보게 되었다.

3. 프론트엔드 + 백엔드

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

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

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

위와 같은 방식으로 2번에서의 단점을 없엘 수 있었다.

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

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

0개의 댓글