[PreProject] [OAuth2.0 트러블슈팅 2편] 구글 OAuth2 관련 내용

NtoZ·2023년 8월 22일
0

PreProject

목록 보기
12/12

OAuth2

OAuth2를 이용하기 위한 정책 설정

  • 구글 클라우드 콘솔 apis

  • 사용자 인증 정보 설정하는 법
    사용자 인증 정보 , 승인된 자바스크립트 원본 + 승인된 리디렉션 URI

    • 승인된 자바스크립트 원본은 프론트엔드의 호스트를 기입하면된다.
    • 승인된 리디렉션 URI는 백엔드 애플리케이션 서버의 URI를 입력해야 한다.
  • 사용자 동의 화면

  • 구글을 통해 받을 수 있는 scope

    • scope중 profile은 사용자 정의 OAuth2SuccessHandler 클래스에서 애플리케이션 디버깅으로 어떤 정보가 있는지 확인할 수 있었다.
      확인할 수 있는 내용은 성, 이름, 풀네임, 프로필사진 등이었다.

구현

SecurityConfiguration.java

package com.codestates.stackoverflowbe.global.auth.config;

import com.codestates.stackoverflowbe.domain.account.service.AccountService;
import com.codestates.stackoverflowbe.global.auth.filter.JwtVerificationFilter;
import com.codestates.stackoverflowbe.global.auth.handler.*;
import com.codestates.stackoverflowbe.global.auth.filter.JwtAuthenticationFilter;
import com.codestates.stackoverflowbe.global.auth.jwt.JwtTokenizer;
import com.codestates.stackoverflowbe.global.auth.utils.CustomAuthorityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {

    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;
    private final AccountService accountService;
    private final SecurityCorsConfig corsConfig;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .headers().frameOptions().sameOrigin() // (해당 옵션 유효한 경우 h2사용가능) SOP 정책 유지, 다른 도메인에서 iframe 로드 방지
                .and()
//                .cors(Customizer.withDefaults()) //CORS 처리하는 가장 쉬운 방법인 CorsFilter 사용, CorsConfigurationSource Bean을 제공
//                .cors(configuration -> configuration
//                        .configurationSource(request -> new CorsConfiguration().applyPermitDefaultValues()))
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 정보 저장X
                .and()
                .formLogin().disable() // CSR 방식을 사용하기 때문에 formLogin 방식 사용하지 않음
                .httpBasic().disable() // UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter 등 비활성화
                .exceptionHandling() // 예외처리 기능
                    .authenticationEntryPoint(new AccountAuthenticationEntryPoint()) // 인증 실패시 처리 (UserAuthenticationEntryPoint 동작)
                    .accessDeniedHandler(new AccountAccessDeniedHandler()) //인가 거부시 UserAccessDeniedHandler가 처리되도록 설계
                .and()
                .apply(new CustomFilterConfigurer()) // 커스터마이징한 필터 추가
                .and() // 허용되는 HttpMethod와 역할 설정
                .authorizeHttpRequests( authorize -> authorize
                        .antMatchers(HttpMethod.GET, "/v1/accounts/**").hasRole("ADMIN")
                        .antMatchers(HttpMethod.POST, "/v1/accounts/**").permitAll()
                        .anyRequest().permitAll()
                )
                //⭐ oauth2Login이 성공했을 때 동작하게끔 되는 OAuth2AccountSuccessHandler
                .oauth2Login(
                        oauth2 -> oauth2
                                .successHandler(new OAuth2AccountSuccessHandler(jwtTokenizer, authorityUtils, accountService))
                );


        return httpSecurity.build();
    }

... 중략...
    

    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {

        @Override
        public void configure(HttpSecurity builder) throws Exception {
            // authenticationManager : 사용자가 로그인 요청시 입력한 아이디와 패스워드를 해당 객체로 전달하여 인증 수행하며, 결과에 따라 로직 처리
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); // AuthenticationManager 객체얻기

            JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer); // JwtAuthenticationFilter 객체 생성하며 DI하기

            // AbstractAuthenticationProcessingFilter에서 상속받은 filterProcessurl을 설정 (설정하지 않으면 default 값인 /Login)
            jwtAuthenticationFilter.setFilterProcessesUrl("/v1/accounts/authenticate");
            jwtAuthenticationFilter.setAuthenticationSuccessHandler(new AccountAuthenticationSuccessHandler(accountService));
            jwtAuthenticationFilter.setAuthenticationFailureHandler(new AccountAuthenticationFailureHandler());

            JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils);

            // Spring Security FilterChain에 추가
            builder
                    .addFilter(corsConfig.corsFilter())
                    .addFilter(jwtAuthenticationFilter)
                    //OAuth2LoginAuthenticationFilter : OAuth2.0 권한 부여 응답 처리 클래스 뒤에 jwtVerificationFilter 추가 (Oauth)
                    .addFilterAfter(jwtVerificationFilter, OAuth2LoginAuthenticationFilter.class);
        }


    }



}

OAuth2SuccessHandler.java

package com.codestates.stackoverflowbe.global.auth.handler;

import com.codestates.stackoverflowbe.domain.account.dto.AccountDto;
import com.codestates.stackoverflowbe.domain.account.entity.Account;
import com.codestates.stackoverflowbe.domain.account.service.AccountService;
import com.codestates.stackoverflowbe.global.auth.jwt.JwtTokenizer;
import com.codestates.stackoverflowbe.global.auth.login.dto.LoginResponseDto;
import com.codestates.stackoverflowbe.global.auth.utils.CustomAuthorityUtils;
import com.google.gson.Gson;
import com.nimbusds.openid.connect.sdk.Display;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;


@Slf4j
//OAuth2 인증이 성공한 이후 동작 (SimpleUrlAuthenticationSuccessHandler : 인증 성공했을 때 URL 지정 등 역할 수행)
public class OAuth2AccountSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private static String URL_S3_ENDPOINT = "http://se-sof.s3-website.ap-northeast-2.amazonaws.com";

    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;
    private final AccountService accountService;

    public OAuth2AccountSuccessHandler(JwtTokenizer jwtTokenizer,
                                       CustomAuthorityUtils authorityUtils,
                                       AccountService accountService) {
        this.jwtTokenizer = jwtTokenizer;
        this.authorityUtils = authorityUtils;
        this.accountService = accountService;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        //OAuth2인증이 성공
        log.info("# OAuth2AccountSuccessHandler success!");

        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        String email = (String) oAuth2User.getAttributes().get("email");
        String name = (String) oAuth2User.getAttributes().get("name");
        System.out.println("name: " + name);

        List<String> authorities = authorityUtils.createRoles(email);

        saveAccount(email, name); // email을 DB에 저장하여 관리하며 매핑하기
        redirect(request, response, email, authorities);

//        String profile = (String) oAuth2User.getAttributes().get("profile");
//        Account account = buildOAuth2Account(email, profile);
//        Account saveAccount = accountService.createAccountOAuth2(account);
    }


    private void saveAccount(String email, String displayName) {
        AccountDto.Post accountPostDto = new AccountDto.Post(email);
        accountPostDto.setDisplayName(displayName);
        //OAuth 전용 DB 저장 로직
        accountService.createAccountOAuth2(accountPostDto);
    }

    private void redirect(HttpServletRequest request, HttpServletResponse response,
                          String username, List<String> authorities) throws IOException {

        // accessToken과 refreshToken 생성
        String accessToken = delegateAccessToken(username, authorities);
        String refreshToken = delegateRefreshToken(username);

        // username(email)로 계정 찾아와서 Json 직렬화 이후 응답객체의 body에 입력하기
        Account account = accountService.findByEmail(username);
        String displayName = account.getDisplayName();

        //FE 애플리케이션 쪽의 URI 생성.
        String uri = createURI(request, accessToken, refreshToken).toString();
        LoginResponseDto loginResponseDto = new LoginResponseDto(displayName);

        Gson gson = new Gson();
        String result = gson.toJson(loginResponseDto);

        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(result);

//        response.setHeader("displayName", displayName);

        //SimpleUrlAuthenticationSuccessHandler에서 제공하는 sendRedirect() 메서드를 이용해 Frontend 애플리케이션 쪽으로 리다이렉트
        getRedirectStrategy().sendRedirect(request, response, uri);

    }


    private String delegateAccessToken(String username, List<String> authorities) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", username);
        claims.put("roles", authorities);

        String subject = username;
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }

    private String delegateRefreshToken(String username) {
        String subject = username;
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        return refreshToken;
    }
    private Object createURI(HttpServletRequest request, String accessToken, String refreshToken) {
        // HTTP 요청의 쿼리 파라미터나 헤더를 구성하기 위한 Map
        MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
        queryParams.add("access_token", accessToken);
        queryParams.add("refresh_token", refreshToken);

		//⭐이 uri는 프론트엔드에서 해당 액세스토큰과 리프레시 토큰을 쿼리파라미터로 받아 저장할 수 있도록 구성된 페이지를 목적으로 한다.
        //http://localhost/receive-token.html?access_token=XXX&refresh_token=YYY 형식으로 받도록 함.
        return UriComponentsBuilder
                .newInstance()
                .scheme("http")
                .host("se-sof.s3-website.ap-northeast-2.amazonaws.com") //"http://seveneleven-stackoverflow-s3.s3-website.ap-northeast-2.amazonaws.com"
                .path("/login")
                .queryParams(queryParams)
                .build()
                .toUri();

    }
}

프로젝트 중 트러블 슈팅

- 프론트에서 OAuth2.0 구글 로그인을 했을 때 'displayName(닉네임)'을 받고자 함.

  • response 객체에 유저 정보 중 displayName을 loginResponseDto에 그대로 담아 주려고 했지만, setHeader, getWriter().write() 등 다양한 방법으로 시도해보았으나, 프론트 쪽에서 그런 방식(응답 바디에 담아주거나 헤더로 보내주는 방식)으로는 받을 수 없다는 답변이 있어 다른 방법을 고심하게 되었다.

  • createURI 메서드에서 URI를 구성할 때 쿼리파라미터로 access_token이나 refresh_token을 쿼리파라미터로 넘기려던 것에 착안하여 displayName을 단독으로 넘기려고 하였으나 이 역시 불가능했다.
    클라이언트에서 이유를 알 수 없는 백색 화면이 출력되며 토큰과 displayName 저장이 정상적으로 이루어지지 않았다.

  • 해결책 : 액세스 토큰과 리프레시 토큰을 받게되면 SecurityContextHolder에 인증된 토큰(Claims)이 저장되게 된다. 이점에 착안하여 인증된 사용자 정보로부터 정보를 받는 또다른 api를 호출하도록 하였다.

package com.codestates.stackoverflowbe.global.auth.controller;

import com.codestates.stackoverflowbe.domain.account.entity.Account;
import com.codestates.stackoverflowbe.domain.account.service.AccountService;
import com.codestates.stackoverflowbe.global.auth.login.dto.LoginDto;
import com.codestates.stackoverflowbe.global.auth.login.dto.LoginResponseDto;
import com.codestates.stackoverflowbe.global.exception.BusinessLogicException;
import com.codestates.stackoverflowbe.global.exception.ExceptionCode;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.*;

import javax.validation.constraints.Positive;
import java.util.Map;
import java.util.Optional;

@RestController
@RequestMapping("/v1/auth")
@Tag(name = "Auth", description = "인증 기능")
public class AuthController {

    private final AccountService accountService;

    public AuthController(AccountService accountService) {
        this.accountService = accountService;
    }

    @GetMapping("/oauth")
    public ResponseEntity<LoginResponseDto> getOAuth2UserDisplayName() {

        // 시큐리티 컨텍스트 홀더에 저장되어 있는 인증 정보 가져오기
        Authentication authentication =
        Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()).orElseThrow(()
                -> new BusinessLogicException(ExceptionCode.NOT_AUTHENTICATED));

//        Map<String, Object> claims = (Map<String, Object>) authentication.getPrincipal();

        Account account = accountService.findByEmail((String) authentication.getPrincipal());
        LoginResponseDto loginResponseDto = new LoginResponseDto(account.getDisplayName());
        return ResponseEntity.ok(loginResponseDto);
    }
}
  • 아쉬운 점 : 사실 OAuth2를 호출하고 또 다른 api를 추가로 호출할 필요 없이 인증 성공 과정에서 응답 객체에 담아 보내주는 것이 더 좋을 것 같은데, 실제로 그렇게 할 수 없는 것인지 의문점이 남는다. 불필요한 api 호출 사용을 최대한 줄일 수 있다면 더 좋을 것이다.

    또한 어쩔 수 없이 기존 인증 정보(Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal())를 QuestionController, AnswerController 등에서 사용하고 있었기 때문에 principal의 내용을 이메일(String) -> 객체(Map<String, Object> claims)로 리팩토링 하지 못한 것이 아쉬웠다.
    결국 처음 설계를 현명하게 하지 못했기 때문에 해당 API에서 RDS 호출 비용을 발생시키게 되었다.

  • 좋았던 점 : RDS 호출 비용을 줄이기 위해 getPrincipal()의 정보를 최대한 활용하고자 하는 방향은 좋았다. SpringSecurity에 대한 기본적인 이해를 바탕으로 인증 흐름의 순서를 짚어가며 AccountDetails를 구성할 때 생성자로 account.getDisplayName()를 추가로 넘겨주었고, 인가를 담당하는 JwtVerificationFilter 필터의 setAuthenticationToContext메서드에서, Authentication authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities);으로 인증된 토큰을 만들어 줄때 principal이 객체(Map<String, Object> claims)가 되도록 설계 할 수 있었고,
    실제 로컬 테스트에서 정상적으로 해당 정보를 동작하게 할 수 있었다.

URI로 한글을 그냥 보내면 안된다!

    private Object createURI(HttpServletRequest request, String accessToken, String refreshToken) {
        // HTTP 요청의 쿼리 파라미터나 헤더를 구성하기 위한 Map
        MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
        queryParams.add("access_token", accessToken);
        queryParams.add("refresh_token", refreshToken);
        queryParams.add("displayName", displayName)


        //http://localhost/receive-token.html?access_token=XXX&refresh_token=YYY 형식으로 받도록 함.
        return UriComponentsBuilder
                .newInstance()
                .scheme("http")
                .host("se-sof.s3-website.ap-northeast-2.amazonaws.com") 
                .path("/login")
                .queryParams(queryParams)
                .build()
                .encode() //⭐ 이것을 해주면 한글도 URI로 보낼 수 있다.
                .toUri();
    }
  • encode() : UTF-8을 기본으로 인코딩 하는 메서드,
    한글 displayName을 쿼리 파람에 실어 보낼 수 있다.

레퍼런스

profile
9에서 0으로, 백엔드 개발블로그

0개의 댓글