HJW's IT Blog

Spring Security 기반 OAuth 2.0 & JWT 구현하기 본문

SPRING

Spring Security 기반 OAuth 2.0 & JWT 구현하기

kiki1875 2025. 3. 25. 14:33

이번 사이드 프로젝트를 진행하며, OAuth 2.0 에 더불어 JWT 인증 시스템을 채택하여 진행하였다.

OAuth 2.0 Flow

  1. Client 의 로그인 요청 : GET /api/oauth2/authorize/{provider} 로 요청을 보낸다
  2. 서버는 돌아올 콜백 URI 를 포함한, 제공자에 맞는 URL 로 리다이렉션을 한다
  3. 클라이언트가 제공자의 페이지에서 승인을 한다
  4. 제공자는 콜백 URI 을 사용해 서버의 엔드포인트에 인증 코드를 포함해 보낸다
  5. 서버는 해당 인증 코드를 가지고 다시 제공자에 Token요청을 한다
  6. Token 이 유효하다면, 제공자는 미리 명시된 (ex. email, providerId) 데이터를 서버로 보내준다.

Spring Security 를 사용하게되면, 2 ~ 5번의 과정을 자동으로 진행해 준다. 이때, spring security 가 자동으로 생성하는 콜백 URL 은 다음과 같다 {baseUrl}/login/oauth2/code/google.

이떄 개발자가 해주어야 할 것은, registration 정보와 provider 정보 입력이다.

다음은 yml 파일의 예시이다.

spring:
    security:
        oauth2:
            client:
                registration:
                    google:
                        client-id: # Google API Console 에서 발급받은 Client ID
                        client-secret : # Google API Console 에서 발급받은 Secret
                        scope : profile, email # 사용자에게 요청할 권한 범위
                        redirect-uri : "{baseUrl}/login/oauth2/code/google" # 로그인 완료후 Google 이 리다이렉션할 콜백 URI
                provider:
                    google:
                        authorization-uri :  # 사용자를 Google 로그인 화면으로 리다이렉션 하는 URI
                        token-uri : # 로그인 후 발급받은 code 로 access_token 을 요청하는 URI
                        user-info-uri : # Access Token 으로 사용자 정보를 가져오는 URI

Google OAuth2.0 설정 가이드

  1. Google Cloud Console 에서 새 프로젝트 생성
  • 접속: 구글 콘솔
  • 좌측 상단 메뉴 > 프로젝트 선택 > 새 프로젝트 만들기
  • 프로젝트 이름은 자유롭게 입력 (my-project, side-project 등)
  • 프로젝트 생성 후 해당 프로젝트로 전환되어야 이후 작업 가능
  1. OAuth 동의 화면 구성
  • API 및 서비스 -> OAuth 동의 화면
  • 사용자 유형 선택 : 개발용이면 외부 사용자 선택
  • 필수 정보 입력
  • 저장 후 계속
  1. OAuth 클라이언트 ID 생성
  • 사용자 인증 정보 만들기 -> OAuth 클라이언트 ID
  • 어플리케이션 유형 선택
  • 필수 입력 항목
    • 이때, 승인된 리디렉션 URI 는 Spring Security 가 자동으로 사용하는 콜백 URL을 사용
    • 혹은 직접 정의해도 되지만, 일치해야 한다.
  1. Client ID, Secret 발급

지금은 로컬에서 진행하는 프로젝트기 때문에, 승인된 리디렉션 URI 에 Spring Security 가 기본적으로 제공하는 redirect-uri 인 http://localhost:8080/login/oauth2/code/google 를 설정해 두었다.

Spring Security가 자동 생성하는 redirect-uri는 반드시 Google OAuth 설정의 "승인된 리디렉션 URI"와 일치해야 한다.

로그인 처리

여기까지 했다면, 이제 OAuth 인증 성공시, 실패시, Unauthorized api 를 정의해야 한다. 이번 프로젝트엔 /api/oauth/** 와 /api/public/** 의 요청은 인증이 필요 없도록, 그 외의 api 는 인증이 필요하도록 만들 것이다.

JSESSIONID 제거

Spring Security 는 기본적으로 Session 기반 인증을 사용하려 하기 때문에, JSESSIONID 가 발급된다. 이를 클라이언트의 쿠키에 저장하고, 이를 통해 인증 상태를 유지한다. 하지만 이번 프로젝트에선, JWT 를 활용한 인증 방식을 사용할 것이기 때문에 이 JSESSIONID 기반의 인증을 비활성화 해주어야 한다. (이후 알게 되었는데 아직 쿠키 제거가 되진 않지만 인증은 막은 상태이다..)

SessionCreationPolicy.STATELESS 로 설정해 두겠다.

@Bean  
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{  
  http  
      .csrf(csrf -> csrf.disable())  
      .sessionManagement(session -> session  
          .sessionCreationPolicy(SessionCreationPolicy.STATELESS)  
      )  
      .authorizeHttpRequests(auth -> auth  
          .requestMatchers("/api/oauth2/**", "/api/public/**").permitAll()  
          .requestMatchers("/api/private/**").authenticated()  
          .anyRequest().denyAll()  
      )
      .oauth2Login(oauth2 -> oauth2  
    .successHandler(/* Success Logic */)
    .failureHandler(/* Fail Logic */))
    .addFilterBefore(/*filter Logic*/);

    return http.build();
)

JWT 구현

JWT 를 구현하기 위해 다음 의존성을 추가해 주었다

// jwt  
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'  
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'  
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

1. JwtUtil

JwtUtil 클레스는 다음과 같은 책임을 가질 것이다.

  • Access Token 생성
  • Refresh Token 생성
  • Token 인코딩, 디코딩

우선 static 메소드로 기능들을 구현하기 위해, setter 를 통해 SECRET 을 설정 파일로 부터 주입 받아야 한다

@Value("${secret}")  
public void setSecret(String value) {  
  JwtUtil.SECRET = value;  
}

서명 생성

private static Key getSigningKey(){  
  return  Keys.hmacShaKeyFor(SECRET.getBytes());  
}

Access Token 생성

public static String generateAccessToken(Long userId, String role){  
  return Jwts.builder()  
      .setSubject(String.valueOf(userId))  
      .claim("role", role)  
      .setIssuedAt(new Date())  
      .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION))  
      .signWith(getSigningKey())  
      .compact();  
}
  • userId 를 subject로, role 을 claim 에 설정하여 Access Token 을 생성해 준다.
  • 유효기간은 정의된 ACCESS_TOKEN_EXPIRATION 이며, getSigningKey() 를 통해 서명 한다.

Refresh Token 생성

public static String generateRefreshToken(Long userId){  
  return Jwts.builder()  
      .setSubject(String.valueOf(userId))  
      .setIssuedAt(new Date())  
      .setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION))  
      .signWith(getSigningKey())  
      .compact();  
}
  • Refresh Token 의 경우, Access Token 보다 간단하다.
  • UserId만 포함된 Refresh Token 을 서명하여 반환하면 끝이다.

parseToken()

public static Claims parseToken(String token){  
  return Jwts.parserBuilder()  
      .setSigningKey(getSigningKey())  
      .build()  
      .parseClaimsJws(token)  
      .getBody();  
}
  • JWT Token 을 파싱하고 내부 Claim 값을 가져온다

2. JwtFilter

이제 Spring Filter 에 등록할 JwtAuthenticationFilter 를 구현해야 한다. 해당 클래스는 모든 요청에 대해 token 을 추출하고, 파싱하여 SecurityContextHolder 에 인증/인가 정보를 저장하는 역할을 할 것이다.

@Component  
@RequiredArgsConstructor  
public class JwtAuthenticationFilter extends OncePerRequestFilter {  

  private final MemberRepository memberRepository;  
  @Override  
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,  
      FilterChain filterChain) throws ServletException, IOException {  

    String token = request.getHeader("Authorization");  
    if(token != null && token.startsWith("Bearer")){  
      token = token.substring(7);  
      try{  
        Claims claims = JwtUtil.parseToken(token);  
        Long userId = Long.parseLong(claims.getSubject());  

        Optional<Member> optionalMember = memberRepository.findById(userId);  
        if(optionalMember.isPresent()){  
          Member member = optionalMember.get();  
          UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(member, null,  
              Collections.emptyList());  

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

      }catch (Exception e){  
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);  
        return;  
      }  
    }  
    filterChain.doFilter(request, response);  
  }  
}

Flow 설명

  • Request Header 에서 Authorization 값을 가져온 후, Bearer 를 제거하여 순수 Token 만 추출한다.
  • 이전 JwtUtil 에 정의한 parseToken() 을 이용하여 토큰을 파싱하고, claim 을 추출한다.
  • 위에서 추출한 claim 을 통해 userId 를 추출한다
  • userId 를 사용하여 DB에 저장된 member 를 조회한다.
    • 조회결과가 비어있다면 UNAUTHORIZED 를 반환한다.
  • member 조회에 성공하면, 인증 객체를 생성하고, SecurityContextHolder 에 등록한다.

3. Refresh Token 발급 및 구현

본 프로젝트의 Refresh Token 발급 구조는 다음과 같다.

Client 가 Authorization 헤더에 Access Token 을 전송 -> 만료 상태코드 반환 -> 클라이언트가 토큰 재발급 api 로 refresh token 을 담아서 전송 -> refresh token 이 유효하다면 재발급 -> 유효하지 않다면 Unauthorized -> Client 는 Unauthorized 라면 OAuth2.0 을 이용한 재 로그인

3.1 RefreshToken Entity

우선 RefreshToken 엔티티를 생성해 주겠다. 추후 Redis 등을 이용하여 고도화 할 여지가 있지만 우선은 단순 DB 저장 방식을 사용하겠다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {
    @Id
    private Long memberId;

    @Column(nullable = false)
    private String token;

    public RefreshToken(Long memberId, String token){
        this.memberId = memberId;
        this.token = token;
    }

    public void updateToken(String token){
        this.token = token;
    }
}

3.2 Controller 정의

Refresh Token 을 이용하여 Access Token 을 재발급받는 엔드포인트 또한 필요하다.

@Controller  
@RequiredArgsConstructor  
public class RefreshTokenController {  

  private final RefreshTokenRepository refreshTokenRepository;  

  @PostMapping("/api/token/refresh")  
  public ResponseEntity<?> refreshToken(@RequestBody RefreshTokenDTO request){  
    try{  
      String refreshToken = request.refreshToken();  
      Claims claims = JwtUtil.parseToken(refreshToken);  
      Long userId = Long.parseLong(claims.getSubject());  

      RefreshToken savedToken = refreshTokenRepository.findById(userId).orElseThrow(  
          // TODO : 예외 처리  
      );  

      String newAccessToken = JwtUtil.generateAccessToken(userId, "ROLE_USER");  
      String newRefreshToken = JwtUtil.generateRefreshToken(userId);  
      savedToken.updateToken(newRefreshToken);  
      refreshTokenRepository.save(savedToken);  

      RefreshTokenDTO refreshTokenDTO = new RefreshTokenDTO(newRefreshToken);  

      return ResponseEntity.ok()  
          .header("Authorization", "Bearer " + newAccessToken)  
          .body(refreshTokenDTO);  
    }catch (Exception e){  
      return ResponseEntity.status(HttpStatus.UNAUTHORIZED)  
          .body("유효하지 않은 Refresh Token 입니다.");  
    }  
  }  
}
  • 요청 Request 에서 Refresh Token 을 추출한다
  • Refresh Token 을 파싱하여 userId를 추출하고, userId로 refreshTokenRepository를 조회한다.
  • 유효하다면, 새로운 Access Token 과 Refresh Token 을 발급한다.
    • 여기서 Refresh Token 재발급은 개발자의 자유이다.
  • 새로 발급받은 토큰을 DB 에 저장하고 사용자에게 새로운 Access Token 과 Refresh Token 을 반환한다.

Security Config & AuthenticationSuccessHandler

OAuth 로그인, JWT 구현까지 완료하였다. 이제, OAuth 인증 성공시 실제 토큰 발급 로직을 작성해야 하는데, AuthenticationSuccessHandler 인터페이스를 구현하여 만들 것이다.

기초 키워드 정리

AuthenticationSuccessHander 란?

  • Spring Security에서 인증이 성공했을 때 실행되는 콜백 인터페이스이다.

OAuth2User 란?

public interface OAuth2User extends OAuth2AuthenticatedPrincipal {  
}

public interface OAuth2AuthenticatedPrincipal extends AuthenticatedPrincipal {  
  @Nullable  
  default <A> A getAttribute(String name) {  
    return this.getAttributes().get(name);  
  }  

  Map<String, Object> getAttributes();  

  Collection<? extends GrantedAuthority> getAuthorities();  
}

위처럼 구현되어 있는 OAuth2 인증 후 사용자 정보를 표현하기 위한 인터페이스 이다.

즉, 인증된 사용자 정보를 Map 형태로 들고 있으며, Authentication.getPrincipal() 로 접근이 가능하다. (Spring 에선 @AuthenticationPrincipal)

AuthenticationSuccessHandler 구현채

@RequiredArgsConstructor  
public class CustomOAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {  

  private final MemberRepository memberRepository;  
  private final RefreshTokenRepository refreshTokenRepository;  
  @Override  
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,  
      Authentication authentication) throws IOException, ServletException {  

    OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();  
    String email = oAuth2User.getAttribute("email");  
    String providerId = oAuth2User.getAttribute("sub");  

    Member member = memberRepository.findByEmail(email).orElseGet(() ->{  
      Member newMember = new Member(  
          email,  
          null,  
          email.split("@")[0],  
          Provider.GOOGLE,  
          providerId,  
          MemberStatus.ACTIVE  
      );  
      return memberRepository.save(newMember);  
    });  

    String accessToken = JwtUtil.generateAccessToken(member.getId(), "ROLE_USER");  
    String refreshToken = JwtUtil.generateRefreshToken(member.getId());  

    refreshTokenRepository.save(new RefreshToken(member.getId(), refreshToken));  

    response.setHeader("Authorization", "Bearer " + accessToken);  
    response.setHeader("Refresh-Token", refreshToken);  
  }  
}

해당 클래스는, OAuth 인증 후, 후처리 하는 클래스로, Authentication 객체에서 정보를 추출하여 OAuth2User 로 캐스팅 한다.

캐스팅 된 OAuth2User 객체에서 비즈니스 로직상 사용자 생성에 필요한 정보들을 추출한다. 이번 프로젝트에선, email, 그리고 providerId 를 추출하기로 하였다.

추출한 email 을 사용하여 DB 를 조회하고, 없다면 새로운 사용자를 등록한다.

  • 이후 다른 OAuth 제공자를 사용하게 된다면 지금처럼 Provider 를 하드코딩 해선 안된다.

사용자를 불러왔거나 생성하였다면, 해당 사용자에 대한 Access Token 및 Refresh Token 을 발급한다.

추가로 알게된 사실

Spring Security 는 OAuth2 로그인 후, 자동으로 redirection 302 를 반환한다. 그렇기 때문에 헤더에 설정한 AuthorizationRefresh-Token 을 읽을 수 없다.

302 Redirection 은 그저 이 URL 로 이동하라는 신호일 뿐이기 때문이다.

하지만, Redirect 시 URL 에 쿼리스트링으로 토큰과 정보를 붙일 경우, 이를 브라우저가 읽어낼 수 있다.

그래서, CustomOAuth2AuthenticationSuccessHandler 에서 response 에 header 가 아닌 redirect url 을 다음과 같이 설정해 주어야 한다.

String encodedName = URLEncoder.encode(member.getName(), StandardCharsets.UTF_8);  
String encodedEmail = URLEncoder.encode(member.getEmail(), StandardCharsets.UTF_8);  
String encodedRole = URLEncoder.encode("USER", StandardCharsets.UTF_8);  

String redirectUri = "http://localhost:3000/oauth2/redirect"  
    + "?token=" + accessToken  
    + "&refreshToken=" + refreshToken  
    + "&id=" + member.getId()  
    + "&name=" + encodedName  
    + "&email=" + encodedEmail  
    + "&role=" + encodedRole;  

response.sendRedirect(redirectUri);

확인 해보기

다음과 같은 엔드포인트를 생성하여 한번 잘 로그인이 되는지 테스트 해보겠다.

@RestController  
public class TestController {  
  @GetMapping("/api/private/check-auth")  
  public ResponseEntity<String> checkAuth(Authentication authentication) {  
    if (authentication == null || !authentication.isAuthenticated()) {  
      return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("인증되지 않은 사용자입니다.");  
    }  

    Member principal = (Member) authentication.getPrincipal();  
    return ResponseEntity.ok("인증된 사용자입니다. principal = " + principal.toString());  
  }  
}
  • http://localhost:8080/api/oauth2/authorize/google 요청

위와 같이 Authorization Header 와 Refresh-Token 이 잘 설정되어 있는것을 볼 수 있다.

이제 Postman 으로 `/api/private/check-auth` 로 테스트 해보겠다.

헤더 없이

헤더 포함