Spring Security OAuth2 활용한 소셜 회원가입/로그인 구현

Kim Hyen Su·2024년 8월 10일

대장장이

목록 보기
2/6
post-thumbnail

개요

이번 프로젝트에서 회원가입/로그인 기능을 Spring Security OAuth2를 활용하여 리소스 서버에서 개인정보를 조회하여 처리할 예정입니다.

이를 위해 핵심이 되는 기술은 다음과 같습니다.

  • Spring Security : Filter Chain을 통해 인증/인가를 수행.
  • Spring Security OAuth2 : Spring Security와 연동하여 OAuth2 서버에서 리소스를 가져오기 위한 기능을 수행.
  • JWT : 서버 부하 감소 및 확장성을 고려하여 토큰 기반 인증 방식 도입

구현 예정인 소셜 로그인 종류로는 Google, Kakao, Naver 가 있으며, 이번 포스팅에서는 Google 관련 소셜 로그인 기능을 구현하겠습니다.

Google OAuth 2.0 설정(OpenID Connect)

OpenID Connect  |  Authentication  |  Google for Developers

애플리케이션(서비스)에서 사용자 로그인을 위해 Google의 OAuth2.0 인증 시스템을 사용하려면 Google API Console에서 프로젝트를 설정하여 OAuth2.0 사용자 인증 정보를 가져오도록 하고, 리다이렉션 URI를 설정하고 선택적으로 사용자 동의 화면에 표시되는 브랜드 정보를 맞춤 설정해줘야 합니다.

  1. 프로젝트 생성하기

    Google Cloud > API 및 서비스 > 프로젝트 생성

  2. OAuth 동의 화면 생성

    OAuth 동의 화면

    범위 추가 또는 삭제 선택

    범위 선택하기

    테스트 사용자 등록하기

    요약하기

  3. 사용자 인증 정보 - 클라이언트 ID 생성

    OAuth API 호출을 위한 클라이언트 키 발급.

JWT 관련 기능 구현

구글 설정을 마쳤으면, 회원 정보를 담을 JWT 토큰을 생성하고 처리하는 로직을 구현하겠습니다.

기본 세팅

build.gradle (jwt 버전 - 공식 깃헙 참고)

	// JWT
	implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
	implementation 'io.jsonwebtoken:jjwt-impl:0.12.6'
	implementation 'io.jsonwebtoken:jjwt-jackson:0.12.6'

TokenProvider

JWT 생성

@Component
@Slf4j
public class TokenProvider {

  private final SecretKey ACCESS_SECRET;
  private final SecretKey REFRESH_SECRET;
  private final Long ACCESS_EXP;
  private final Long REFRESH_EXP;
  private final AuthProvider authProvider;

  private static final String Bearer = "Bearer ";
  private static final String ROLE_CLAIM = "role";

  public TokenProvider(
      @Value("${jwt.access.secret}") String accessSecret,
      @Value("${jwt.access.expired}") Long accessExp,
      @Value("${jwt.refresh.secret}") String refreshSecret,
      @Value("${jwt.refresh.expired}") Long refreshExp,
      AuthProvider authProvider
  ) {
    this.authProvider = authProvider;
    byte[] accessKeyBytes = Decoders.BASE64.decode(accessSecret);
    byte[] refreshKeyBytes = Decoders.BASE64.decode(refreshSecret);
    this.ACCESS_SECRET = Keys.hmacShaKeyFor(accessKeyBytes);
    this.REFRESH_SECRET = Keys.hmacShaKeyFor(refreshKeyBytes);
    this.ACCESS_EXP = accessExp;
    this.REFRESH_EXP = refreshExp;
  }

  public String createAccessToken(Authentication authentication) {
    return createToken(authentication, ACCESS_SECRET, ACCESS_EXP);
  }

  public String createAccessToken(String refreshToken) {
    Claims claims = getClaimsFromRefreshToken(refreshToken);
    Authentication authentication = authProvider.getAuthentication(claims);
    return createAccessToken(authentication);
  }

  public String createRefreshToken(Authentication authentication) {
    return createToken(authentication, REFRESH_SECRET, REFRESH_EXP);
  }

  public String createRefreshToken(String accessToken) {
    Claims claims = getClaimsFromAccessToken(accessToken);
    Authentication authentication = authProvider.getAuthentication(claims);
    return createRefreshToken(authentication);
  }

  private String createToken(Authentication authentication, SecretKey secret, Long exp) {
    Date now = new Date();
    Date expiredDate = new Date(now.getTime() + exp);

    String authorities = authentication.getAuthorities()
        .stream()
        .map(GrantedAuthority::getAuthority)
        .collect(Collectors.joining());

    return Jwts.builder()
        .subject(authentication.getName())
        .claim(ROLE_CLAIM, authorities)
        .issuedAt(now)
        .expiration(expiredDate)
        .signWith(secret)
        .compact();
  }
  ...
}

  
  • AccessToken secretKey와 RefreshToken SecretKey 구분.
    • 보안상 이점
      • accessToken secretKey 탈취된 경우, 해커가 refreshToken도 함께 조작할 가능성 존재합니다.
      • 1개의 key가 노출되더라도, 다른 토큰의 key는 별도로 관리되므로 서로 영향이 사라집니다.
  • AuthProvider 의존 주입
    • Authentication 생성 관련 로직을 AuthProvider로 분리해줌으로써, SRP(단일 책임 원칙)를 따릅니다.

JWT 파싱

@Component
@Slf4j
public class TokenProvider {
	...
	public Claims getClaimsFromAccessToken(String accessToken) {
	    return getClaimsFromToken(accessToken, ACCESS_SECRET);
	  }
	
	  public Claims getClaimsFromRefreshToken(String refreshToken) {
	    return getClaimsFromToken(refreshToken, REFRESH_SECRET);
	  }
	
	  // JWT 페이로드에 담긴 claims 조회
	  private Claims getClaimsFromToken(String token, SecretKey secretKey) {
	    try {
	      return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
	    } catch (ExpiredJwtException e) {
	      throw new ExpiredTokenException();
	    } catch (SignatureException e) {
	      throw new InvalidJwtSignatureException();
	    } catch (JwtException e) {
	      throw new InvalidTokenException(e.getMessage());
	    } catch (Exception e) {
	      throw new UnAuthenticatedException(e.getMessage());
	    }
	  }
	  ...
	}
  • 두 토큰의 secretKey가 다르므로, 파싱 메서드도 구분하여 정의합니다.

JWT 검증

  // token validation
  public void validateAccessToken(String accessToken) {
    validateToken(accessToken, ACCESS_SECRET);
  }

  public void validateRefreshToken(String refreshToken) {
    validateToken(refreshToken, REFRESH_SECRET);
  }

  private void validateToken(String token, SecretKey secretKey) {
    this.getClaimsFromToken(token, secretKey);
  }
   // JWT 페이로드에 담긴 claims 조회
	  private Claims getClaimsFromToken(String token, SecretKey secretKey) {
	    try {
	      return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
	    } catch (ExpiredJwtException e) {
	      throw new ExpiredTokenException();
	    } catch (SignatureException e) {
	      throw new InvalidJwtSignatureException();
	    } catch (JwtException e) {
	      throw new InvalidTokenException(e.getMessage());
	    } catch (Exception e) {
	      throw new UnAuthenticatedException(e.getMe	ssage());
	    }
	  }
  • Jwts.parser() 를 통해 verifyWith()를 통해서 검증을 수행하며, 메서드 호출부를 try~catch문으로 감싸주어 예외를 처리해줍니다.

Spring Security OAuth 연동 및 소셜 회원가입 / 로그인 구현

참고 블로그

전체 Flow

# 회원가입 Flow
<Front>
- 구글 로그인 버튼 클릭
- 구글 로그인 페이지로 redirect
- 구글 로그인
    - 이메일
    - 비밀번호
- 개인정보 제공 동의

<Back>
- OAuth2 API로부터 Auth Code 전달 받음.
- Auth Code를 리소스 서버로 전달.
- Auth Code를 가지고 Google API로 Access Token 요청
- Google Access Token을 가지고 회원 정보 요청
- GGB DB 조회로 회원가입 여부 확인. 비회원이면 400 + USER_NOT_FOUND + Google Access Token 반환

<Front>
- 400 + USER_NOT_FOUND + Google Access Token 응답을 받음
- 회원가입 페이지로 이동 및 정보 입력
- Back-end로 회원가입 정보와 Google 토큰 전달.

<Back>
- 회원가입 처리
- 로그인 처리
    - Access/Refresh Token 발급
    - Refresh Token 저장
    - 200 + OK + Access/Refresh Toekn 리턴
    
# 로그인 Flow
<Front>
- 구글 로그인 버튼 클릭
- 구글 로그인
    - 이메일
    - 비밀번호
- 개인정보 제공 동의

<Back>
- OAuth2 API로부터 Auth Code 전달 받음.
- Auth Code를 리소스 서버로 전달.
- Auth Code를 가지고 Google API로 Access Token 요청
- Google Access Token을 가지고 회원 정보 요청
- GGB DB 조회로 회원가입 여부 확인.
- 회원 확인 후, Access/Refresh Token 발급
- Refresh Token 저장
- 200 + OK + Access/Refresh Toekn 리턴

# 토큰 재발급 flow
<Front>
- 소셜 로그인 요청

<Back>
- 200 OK + AccessToken + RefreshToken

<Front>
- 쿠키, 세션스토리지, 로컬스토리지 등에 Token 저장
- API 요청 시 Header Authorization에 Access Token 추가하여 요청

<Back>
- Header에서 Token 추출하여 검증
- 검증 오류 시 401 + UnAuthorized

<Front>
- Refresh Token으로 토큰 재발급 요청

<Back>
- Access Token + Refresh Token 재발급
- 검증 성공 시 정상 요청 수행

의존성 추가

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// OAuth2
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

Security 설정

application.yml

  security:
    oauth2:
      client:
        registration:
            google:
              client-id: id
              client-secret: password
              scope: profile, email

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

  private final CustomOAuth2UserService oAuth2UserService;
  private final TokenService tokenService;
  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http
        .csrf(AbstractHttpConfigurer::disable) // csrf 비활성화
        .cors(AbstractHttpConfigurer::disable) // cors 비활성화
        .httpBasic(AbstractHttpConfigurer::disable) // 기본 인증 로그인 비활성화
        .formLogin(AbstractHttpConfigurer::disable) // 기본 login form 비활성화
        .logout(AbstractHttpConfigurer::disable) // 기본 logout 비활성화
        .headers(c -> c
            .frameOptions(
                HeadersConfigurer.FrameOptionsConfig::sameOrigin)) // X-Frame-Options sameOrigin 제한
        .sessionManagement(c -> c
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 비활성화

        // 요청에 따른 리소스 접근 설정
        .authorizeHttpRequests(
            request -> request
                .requestMatchers(
                    new AntPathRequestMatcher("/api/auth/**")
                ).authenticated()
                .anyRequest().permitAll()
        )

        // OAuth2 설정
        .oauth2Login(oauth -> oauth
            .authorizationEndpoint(c -> c.baseUri("/oauth2/authorize"))
            .userInfoEndpoint(c -> c.userService(oAuth2UserService))
            .successHandler(new CustomOAuth2SuccessHandler(tokenService)) // 로그인 성공 시 처리
            .failureHandler(new CustomOauth2FailHandler()) // 로그인 실패 시 처리
        )

        // 예외 핸들링
        .exceptionHandling(ex -> ex
            .authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 인증이 되지 않은 사용자가 인증이 필요한 URL에 접근 시 발생하는 예외 처리
            .accessDeniedHandler(new CustomAccessDeniedHandler()) // 인가 처리 되지 않은 사용자가 어떤 권한이 필요한 URL에 접근 시 발생하는 예외 처리
        )

        // JWT 필터, 오류 핸들링 / 로깅 필터 추가
        .addFilterBefore(new TokenAuthenticationFilter(tokenService),
            UsernamePasswordAuthenticationFilter.class) // JWT 인증 필터
        .addFilterBefore(new TokenExceptionHandlingFilter(),
            TokenAuthenticationFilter.class) // 오류 핸들링
        .addFilterBefore(new LogFilter(), TokenExceptionHandlingFilter.class) // 로깅 필터
    ;

    return http.build();
  }
}

CSRF

Cross-Site Request Forgery 로써, 인증된 사용자를 사칭하여 웹 서버에 원하지 않는 명령을 보내는 공격을 말합니다.

예를 들면, URL에 악성 매개변수를 포함하는 식으로 공격을 수행합니다.

이를 방지하기 위한 방법은 여러가지가 존재합니다.

  • Referer 체크 방식 : HTTP 헤더에 있는 정보로 해당 요청이 요청 페이지의 정보를 가지고 있습니다. 이는 프로그램으로 조작이 가능하므로 권장되지 않는 방식입니다.
  • Restful 한 API : GET/POST를 구분하여 img 태그 등을 사용 시 GET 요청으로 들어오게 되므로, 이를 POST로 처리하도록 구현해놓으면, 요청이 서버 내에서 처리되지 않습니다.

JWT 사용 시, JWT 존재 여부로 CSRF 여부 확인이 가능하므로 Disabled() 설정해줍니다.

CORS

Cross-Origin Resource Sharing 로써, 브라우저가 자신의 출처가 아닌 다른 어떤 출처로 부터 자원을 로딩하는 것을 허용하도록 서버가 허가 해주는 HTTP 헤더 기반 메커니즘을 의미합니다.

CORS 는 교차 출처 리소스를 호스팅하는 서버가 실제 요청을 허가할 것인지 확인하기 위해 브라우저가 보내는 사전 요청 메커니즘에 의존합니다.

이 사전 요청에서 브라우저는 실제 요청에서 사용할 HTTP 메서드와 헤더들에 대한 정보가 표시된 헤더에 담아 보냅니다.

보안 상의 이유로 브라우저는 스크립트에서 시작한 교차 출처 HTTP 요청을 제한합니다. 이러한 경우, 웹 애플리케이션은 로드된 동일 출처 에서만 리소스 요청이 가능하며, 다른 출처의 응답에 올바른 CORS 헤더가 포함되어 있지 않는 한 그렇지 못하다는 것을 의미합니다.

즉, CORS 메커니즘은 브라우저와 서버 간의 안전한 교차 출처 요청 및 데이터 전송을 지원합니다.

CORS 실패는 오류가 발생되지만, 보안 상의 이유로 오류에 대한 세부 사항은 JavaScript로 제공되지 않습니다. 구체적인 내용은 브라우저의 콘솔에서 세부 사항을 확인해야 합니다.

아직, 프론트 서버를 거쳐 요청을 처리하는 상황이 없기 때문에, CORS 설정을 disabled 처리했습니다.

.authorizationEndpoint(c → c.baseUri(”/oauth2/authorize”))

Spring Security OAuth2 내에서 리소스 서버에 인가 코드 요청을 위한 URI로써, 클라이언트로부터 해당 URI로 요청이 들어오면, 인가코드 요청을 보낸 뒤, 클라이언트를 OAuth2 인증 페이지로 리다이렉션 시켜줍니다.

AuthenticationFailureHandler vs. AuthenticationEntryPoint

AuthenticationFailureHandler : 로그인에 실패 시 발생하는 예외 처리.

  • Google 로그인 중 오류 발생
  • Google 로그인 후 사용자 정보 제공 동의 화면 cancel

AuthenticationEntryPoint : 미인증 사용자가 인증이 필요한 리소스에 접근할 때 예외처리.

  • authorizeHttpRequestsFilter 에 authenticated() 로 설정된 리소스에 미인증 회원이 접근.

Filter

TokenAuthenticationFilter

@Slf4j
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {

  private final TokenService tokenService;

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain)
      throws ServletException, IOException {
    // 1. 토큰 추출
    String accessToken = tokenService.getToken(request);

    // 2. 토큰 검증
    if (StringUtils.hasText(accessToken) && !request.getRequestURI().contains("reissue")) {
      tokenService.validateToken(accessToken);
      Authentication auth = tokenService.getAuthentication(accessToken);
      SecurityContextHolder.getContext().setAuthentication(auth);
    }

    filterChain.doFilter(request, response);
  }
}
  • JWT 토큰의 이상 유무를 검증하는 OncePerRequestFilter입니다.
  • HttpServletRequest 요청 헤더에서 AuthorizationHeader에 AccessToken을 추출합니다.
  • 해당 토큰이 있는지 유무를 확인 후 토큰을 검증합니다.

OncePerRequestFilter vs. GenericFilterBean

OncePerRequestFilter는 Spring에서 제공하는 특별한 필터로, HTTP 요청 당 한번만 실행되도록 보장해줍니다. 즉, 포워딩으로 인해 서블릿 요청이 바뀌어 전달되는 경우에도 한번의 필터링만 거치게 됩니다.

GenericFilterBean은 서블릿 요청이 올 때마다 필터링을 거치게 되므로, 불필요한 필터링이 발생하게 됩니다.

TokenExceptionHandlingFilter

@Slf4j
public class TokenExceptionHandlingFilter extends OncePerRequestFilter {

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain)
      throws ServletException, IOException {
    try {
      filterChain.doFilter(request, response);
    } catch (UnAuthenticatedException e) {
      writeResponse(response, HttpStatus.UNAUTHORIZED.value(),
          ApiResponse.error(e.getCode(), e.getMessage()));
    } catch (ForbiddenException e) {
      writeResponse(response, HttpStatus.FORBIDDEN.value(),
          ApiResponse.error(e.getCode(), e.getMessage()));
    }
  }
}

Filter에서 발생한 오류 처리

  • 기존의 Controller 내에서 발생한 오류의 경우, @RestControllerAdvice 클래스 내 @ExceptionHandler를 통해 정의한 메서드에서 처리되도록 구현이 되어 있습니다.
  • 하지만, Filter는 Spring Context 외부에 DispatcherServlet 이전에 존재하여 해당 Advice로 처리가 불가합니다.
  • 따라서, 별도의 예외처리용 필터를 정의하여 이후 필터들에서 발생한 에외를 핸들링해줘야 합니다.

OAuth2Login

  • .authorizationEndpoint(c -> c.baseUri("/oauth2/authorize"))
    • 위 설정을 통해서, OAuth2Login 처리 로직에 매핑되기 위한 엔드 포인트를 설정해줍니다.
    • 클라이언트 측에서 해당 URI로 요청이 오게 되면, OAuth2Login 로직을 수행합니다.

CustomOAuth2UserService

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

  private final MemberService memberService;

  @Override
  public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

    Map<String, Object> attr = super.loadUser(userRequest).getAttributes();

    String email = attr.get("email").toString();

    Member member = memberService.findByEmail(email).orElseThrow(() -> {
      String accessToken = userRequest.getAccessToken().getTokenValue();
      return new NotJoinedUserException(accessToken);
    });

    String userNameAttributeName = userRequest.getClientRegistration()
        .getProviderDetails()
        .getUserInfoEndpoint()
        .getUserNameAttributeName();

    return new PrincipalDetails(member, attr, userNameAttributeName);
  }
}
  • super.loadUser(userRequest) 를 통해서 사용자 정보를 로드합니다.
  • 받아온 회원 정보를 통해서 서비스 가입 여부를 확인합니다. 가입되지 않은 회원인 경우, NotJoinedUserException 발생 및 리소스 서버에서 제공한 엑세스토큰을 함께 전달합니다.
    • 위에서 예외 발생 시, CustomOauth2FailHandler에서 예외를 핸들링해줍니다.
  • 가입된 회원인 경우, OAuth2User를 반환합니다.
  • 그 다음에는 OAuth2User 객체를 활용하여 OAuth2AuthenticationToken 객체를 생성하며, 해당 객체에는 인증된 사용자 정보를 포함합니다.
  • 그 다음 단계에서는 생성된 OAuth2AuthenticationToken을 SecurityContext에 저장해줍니다. SecurityContext에는 현재 인증된 사용자 정보를 유지하기 위한 역할을 합니다.
  • 마지막으로, 인증이 성공한 뒤 AuthenticationSuccessHandler 에 의해서 인증 성공 후 로직을 수행합니다.
    • 이는, 현재 정의된 설정으로 CustomOAuth2SuccessHandler에서 처리해줍니다.

CustomOAuth2SuccessHandler

@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler {

  private final TokenService tokenService;

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication authentication) throws IOException {
    Token token = tokenService.getToken(authentication);
    tokenService.save(token);

    writeResponse(
        response,
        HttpStatus.UNAUTHORIZED.value(),
        ApiResponse.ok(
            token
        )
    );

    log.info("Successfully authenticated oauth2 token");
  }
}

CustomOAuth2FailHandler

@Slf4j
public class CustomOauth2FailHandler implements AuthenticationFailureHandler {

  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException exception) throws IOException {
    if (exception instanceof NotJoinedUserException e) {
      writeResponse(
          response,
          HttpStatus.UNAUTHORIZED.value(),
          ApiResponse.error(
              e.getCode(),
              e.getMessage(),
              e.getAccessToken()
          )
      );
    }
    log.error("Custom Oauth2 Authentication Failed", exception);
  }
}

소셜 회원가입

  • 서버로부터 401 + OAuth2 Server AccessToken이 함께 응답온 경우, 소셜 회원가입 로직이 수행됩니다.
  • 프론트 서버에서 클라이언트 페이지를 회원가입 페이지로 포워딩해주며, 입력된 회원가입 정보를 AccessToken과 함께 서버로 전달해줍니다.

MemberController

@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Slf4j
public class MemberController {

  private final MemberService memberService;
  private final TokenService tokenService;

  @PostMapping("/signup")
  public ApiResponse<Token> signup(@RequestBody MemberJoinRequest memberJoinRequest) {
    Token token = memberService.join(memberJoinRequest);
    return ApiResponse.ok(token);
  }
  ...
}

MemberService

@Service
@RequiredArgsConstructor
@Slf4j
public class MemberService {

  private final MemberRepository memberRepository;
  private final TokenService tokenService;
  private final AuthProvider authProvider;

  public Token join(MemberJoinRequest request) {
    // NOTE : 확장성 고려, OpenFeign 또는 추상화 도입 고민
    String userInfoEndpointUri = "https://www.googleapis.com/oauth2/v3/userinfo";

    HttpHeaders headers = new HttpHeaders();
    headers.add("Authorization", "Bearer " + request.accessToken());
    HttpEntity<String> entity = new HttpEntity<>(headers);

    ResponseEntity<String> response = new RestTemplate().exchange(userInfoEndpointUri,
        HttpMethod.GET, entity,
        String.class);

    log.info("Google Response >> {}", response.getBody());

    Member googleMember = new Gson().fromJson(response.getBody(), Member.class);

    Member member = Member.builder()
        .email(googleMember.getEmail())
        .profile(googleMember.getProfile())
        .nickname(request.nickname())
        .address(request.address())
        .phone(request.phone())
        .gender(request.gender())
        .build();
    memberRepository.save(member);

    return saveToken(member);
  }
  
  private Token saveToken(Member member) {
	  Token token = tokenService.getToken(authProvider.getAuthentication(member));
	  tokenService.save(token);
	  return token;
  }
}
  • 우선, Google OAuth2 로그인만 구현한 터라 Google API로 RestTemplate을 활용하여 REST CLIENT를 수행한 로직만 있습니다.
    • 이는, 추후 OpenFeign 도입 예정이며, 이를 통해서 여러 OAuth 사용이 가능하도록 확장성을 고려할 수 있도록 리팩토링할 예정입니다.
  • 위 요청을 통해 Google에서 제공하는 AccessToken을 통해 사용자 정보를 가져오고, 요청 받은 정보와 함께 처리하여 회원가입 처리를 수행합니다.
  • 회원가입 처리 후 AccessToken과 RefreshToken을 생성 및 저장하여 로그인 처리해줍니다.

TokenService

@Service
@RequiredArgsConstructor
public class TokenService {

  private final TokenProvider tokenProvider;
  private final TokenRepository tokenRepository;

  // AccessToken 생성 & RefreshToken 생성 및 저장
  public TokenDto getToken(Authentication authentication) {
    String accessToken = tokenProvider.createAccessToken(authentication);
    String refreshToken = tokenProvider.createRefreshToken(authentication);
    return TokenDto.builder()
        .accessToken(accessToken)
        .refreshToken(refreshToken)
        .build();
  }
  ...
}

토큰 재발급

  • 토큰 기반 인증 구현하면서 느꼈던 치명적인 단점은 탈취의 위험으로 인해 보안상으로 위험하다는 점입니다.
  • 토큰의 구현 방법은 다양하지만, 기본적으로 무상태성의 특징을 가지므로 서버에서는 발급 후 토큰에 대한 어떠한 상태도 서버에서는 관리되지 않습니다. 이로 인해서 탈취되었다는 것을 인지하더라도 서버에서 조치할 수 있는점이 없게 됩니다.
  • 이처럼 토큰의 탈취로 인한 보안상의 위험을 낮추기 위한 방법은 어떤 것들이 있을까요?
    • 토큰의 상태를 서버에서 관리하는 방법도 있지만, 이는 세션 인증 방식과 차이가 없습니다.
    • 토큰의 만료기한을 짧게 적용하는 방법이 있습니다. 이는 특정 토큰의 유통기한을 짧게 갖도록 해줌으로써, 탈취될 위험과 탈취로 인한 피해를 최소화하기 위한 방법입니다.
    • 단지, 문제가 되는 점은 토큰의 만료 기한을 짧게 가져가면, 그만큼 UX 적으로 불편함이 커지게 됩니다. 즉, 자주 로그인을 해줘야 하는 문제가 발생합니다.
  • 위와 같은 과정에서 나오게 된 개념이 RefreshToken입니다. 이는 언어 그대로 재처리를 위한 토큰입니다. 즉, 사용자가 기존에 사용하던 토큰을 만료하는 대신에, RefreshToken이 존재한다면, 기존의 AccessToken을 재발급해주는 로직을 사용하면, UX 적으로는 세션을 사용하던 것과 다른 차이를 느끼지 못하게 됩니다.
  • 이처럼 보안적인 이슈와 UX 를 높이기 위해서는 AccessToken과 RefreshToken 을 구현하는 것이 필요합니다.

MemberController

  @PostMapping("/reissue")
  public ApiResponse<Token> reissue(@RequestBody TokenReissueRequest tokenReissueRequest) {
    Token token = tokenService.reissueAccessToken(tokenReissueRequest);
    return ApiResponse.ok(token);
  }

TokenService

   // accessToken 재발급
  // NOTE : AccessToken create 시, 함께 생성된 RefreshToken 인지 여부를 확인함으로써, 보안 강화.
  public Token reissueAccessToken(TokenReissueRequest tokenReissueRequest) {

    String accessToken = tokenReissueRequest.accessToken();
    String refreshToken = tokenReissueRequest.refreshToken();

    if (!StringUtils.hasText(accessToken) || !StringUtils.hasText(refreshToken)) {
      throw new NotExistsTokenException();
    }

    Token token = tokenRepository.findByAccessTokenAndRefreshToken(accessToken, refreshToken);
    if (token == null) {
      throw new NotFoundTokenException();
    }

    String reissuedAccessToken = tokenProvider.createAccessToken(refreshToken);
    String reissuedRefreshToken = tokenProvider.createRefreshToken(reissuedAccessToken);
    token.updateAccessToken(reissuedAccessToken, reissuedRefreshToken);
    tokenRepository.update(token);
    return token;
  }
  • AccessToken과 RefreshToken으로 함께 DB 내 값을 조회하며, Token 객체가 null이 아닌 경우, AccessToken과 RefreshToken을 재발급합니다.
    • 이는 RefreshToken을 1회성으로 사용해줌으로써 RefreshToken의 탈취로 인한 피해를 줄이기 위함입니다.

Trouble-Shooting

LogFilter 적용되지 않는 오류 발생

개요

  • LogFilter를 FilterBean으로 등록 시, Order를 1로 설정하여 우선순위를 높도록 하였습니다. 하지만, 실제 Spring Security 내 인증이 실패한 경우, 관련 로그가 콘솔에 찍히지 않는 오류가 발생했습니다.

원인

  • 기존의 로그 필터가 security filter chain보다 우선순위가 낮기 때문에 인증/인가 로직이 선행되어 로그가 찍히지 않는다고 생각했습니다.

조치

  • FilterRegistrationBean에서 Order를 MIN값으로 설정하여 처리할수도 있지만, 이는 추후 다른 필터가 추가되는 확장성을 고려했을 때, 순서를 제어하기 어려워질 수 있습니다.
    • 새로운 Custom Filter가 생겼을 때 MIN_VALUE + 1 해야 하는 거 아닌가?? 그렇다면, 관리가 어렵다.
  • LogFilter를 Security Filter chain 내 UsernamePasswordAuthenticationFilter 앞에 등록하여 Logging이 우선시 되도록 설정해줬습니다. 그 결과, 인증 실패 시 결과가 콘솔 로그에 찍혔습니다.

추가

  • UsernamePasswordAuthenticationFilter 앞에 등록 해놨기 때문에 인증 처리 결과에 대한 로깅은 가능하지만, 이전의 Filter Chain에서 예외가 발생할 경우, 해당 부분에 대한 로그는 찍히지 않게 됩니다.
  • 결국, Security Filter Chain 내부 구성을 확인 후 가장 가장자리에 있는 필터 전에 LogFilter를 위치시키도록 수정해줘야 합니다. 해당 부분은 추후 Spring Security를 추가로 학습하게 될 때, 함께 리팩토링 하도록 하겠습니다.

H2-Console 접속되지 않는 현상 발생

개요 : h2-console에 접속이 되지 않는 현상 발생.

원인 : security와 충돌 예상

  • 이유 : H2-Console에 접속하기 위해서는 기본적으로 host:port/h2-console 경로로 접속이 가능하며, 이를 통해 다양한 리소스에 접근이 가능하다.
  • 하지만, Security에 의해 resource가 제한되어 오류 발생.

조치 :

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring()
                .requestMatchers("/h2-console/*");
    } 

마무리

이렇게 Spring Security + Spring Security OAuth2 + JWT 를 활용한 회원가입 및 로그인 기능 구현이 끝났습니다. 리소스 서버에 요청을 보내고 회원정보를 가져오고 이를 서버 내에서 처리하는 전 과정에 대해서 학습하고 직접 구현해보니, Spring Security OAuth2 내 인증/인가가 어떻게 수행되는지에 대해서 알 수 있었습니다.

다음 포스팅에서는 직접 구현한 코드를 리팩토링하며 관련 내용들에 대해 포스팅하겠습니다.

profile
백엔드 서버 엔지니어

0개의 댓글