본문 바로가기
Java & Spring

OAuth2 로그인(Google/GitHub) + JWT 브릿지

by yamoojin83 2025. 10. 13.

OAuth2 로그인(Google/GitHub) + JWT 브릿지

소셜 로그인을 도입하면 가입 장벽을 낮출 수 있습니다. oauth2Login으로 Google/GitHub 인증을 처리하고, 로그인 성공 시 우리 서비스의 JWT를 발급해 SPA/모바일과 자연스럽게 연동하는 “브릿지” 패턴을 구현해봅니다.

1) 의존성 & 클라이언트 등록


// build.gradle.kts
dependencies {
  implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
  implementation("org.springframework.boot:spring-boot-starter-security")
  implementation("org.springframework.boot:spring-boot-starter-web")
  implementation("org.springframework.security:spring-security-oauth2-jose") // JWT 발급용
}

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: [openid, email, profile]
          github:
            client-id: ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope: [read:user, user:email]
        provider:
          github:
            authorization-uri: https://github.com/login/oauth/authorize
            token-uri: https://github.com/login/oauth/access_token
            user-info-uri: https://api.github.com/user
            user-name-attribute: id

2) 사용자 매핑(OAuth2UserService)

소셜 프로필을 우리의 사용자 모델로 매핑합니다. 첫 로그인 시 가입, 이후에는 업데이트.


@Service
@RequiredArgsConstructor
public class SocialUserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
  private final UserRepository users;

  @Override
  public OAuth2User loadUser(OAuth2UserRequest req) throws OAuth2AuthenticationException {
    DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
    OAuth2User user = delegate.loadUser(req);

    String reg = req.getClientRegistration().getRegistrationId(); // google/github
    String email = switch (reg) {
      case "google" -> (String) user.getAttributes().get("email");
      case "github" -> fetchGithubEmail(user); // user:email 스코프 필요
      default -> throw new OAuth2AuthenticationException("Unsupported provider");
    };

    User saved = users.findByEmail(email).orElseGet(() -> users.save(User.social(email, "ROLE_USER")));
    Map<String, Object> attrs = new HashMap<>(user.getAttributes());
    attrs.put("email", email); // 표준화
    return new DefaultOAuth2User(
        saved.roleAuthorities(), attrs, "email"); // nameAttributeKey = email
  }

  private String fetchGithubEmail(OAuth2User user) {
    // 간단 예시: 메인 이메일이 attributes에 없을 수 있어 user:email API 호출 필요(생략 가능)
    Object login = user.getAttributes().get("login");
    return login + "@users.noreply.github.com";
  }
}

3) 성공 핸들러: JWT 발급 & 전달

로그인 성공 시 우리 서비스의 Access/Refresh JWT를 발급, 쿠키 또는 리다이렉트 쿼리로 전달합니다.


@Component
@RequiredArgsConstructor
public class JwtLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
  private final TokenService tokens; // 5편의 TokenService와 동일 컨셉

  @Override
  public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse res,
                                      Authentication auth) throws IOException, ServletException {
    String sub = ((OAuth2User) auth.getPrincipal()).getAttribute("email");
    String access = tokens.access(sub, List.of("ROLE_USER"), Duration.ofMinutes(15));
    Map<String,String> rt = tokens.rotateRefresh(sub, null);

    // 1) 쿠키로 전달 (SPA가 같은 도메인일 때)
    ResponseCookie a = ResponseCookie.from("ACCESS", access).httpOnly(true).secure(true)
        .sameSite("Lax").path("/").maxAge(Duration.ofMinutes(15)).build();
    ResponseCookie r = ResponseCookie.from("REFRESH", rt.get("refreshToken")).httpOnly(true).secure(true)
        .sameSite("Lax").path("/").maxAge(Duration.ofDays(14)).build();
    res.addHeader("Set-Cookie", a.toString());
    res.addHeader("Set-Cookie", r.toString());

    // 2) 또는 리다이렉트로 전달(보안/도메인 정책에 맞춰 택1)
    setDefaultTargetUrl("/"); // 프런트 라우트
    super.onAuthenticationSuccess(req, res, auth);
  }
}

4) SecurityFilterChain(oauth2Login + 리소스 서버)


@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

  private final OAuth2UserService<OAuth2UserRequest, OAuth2User> socialUserService;
  private final AuthenticationSuccessHandler successHandler;

  @Bean
  SecurityFilterChain http(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests(auth -> auth
          .requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
          .anyRequest().authenticated())
      .oauth2Login(o -> o
          .userInfoEndpoint(u -> u.userService(socialUserService))
          .successHandler(successHandler))
      .oauth2ResourceServer(oauth -> oauth.jwt()); // API 요청은 Bearer로 보호
    return http.build();
  }
}

5) CORS/쿠키 정책

  • 같은 최상위 도메인: SameSite=Lax 쿠키로 충분한 경우가 많습니다.
  • 서브도메인/다른 도메인: SameSite=None + Secure와 CORS(Allowed-Origin/Credentials) 필요.

6) 운영 팁

  • 프로바이더별 이메일 검증 상태를 확인하고, 미검증이면 추가 확인 절차 안내.
  • 회원 탈퇴 시 소셜 연결 해제 안내(프로바이더 계정 관리 페이지 링크 제공).
  • 감사 로깅: 로그인 성공/실패, 프로바이더, IP/UA 기록.

 

 

👉 1편: SecurityFilterChain 완전정복: 요청 매칭/인가 룰 제대로 이해하기

👉 2편: PasswordEncoder와 회원가입: BCrypt·Pepper·비밀번호 정책

👉 3편: AuthenticationProvider & UserDetailsService 커스터마이징

👉 4편: 세션 기반 로그인 vs Stateless: CSRF/Remember-me/세션관리

👉 5편: JWT 심화: 만료/클레임/키 회전 + 4편과의 연결 전략

👉 6편: OAuth2 로그인(Google/GitHub) + JWT 브릿지

👉 7편: 권한 모델링: ROLE vs 권한 문자열, @PreAuthorize, 도메인 권한

👉 8편: CORS·XSS·헤더 보안: SPA/REST 현실 설정

👉 9편: 리소스 서버 분리 & API Gateway 연동(Spring Cloud Gateway)

👉 10편: 감사 로깅/Audit: 로그인 실패·권한거부 탐지 규격