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 현실 설정
'Java & Spring' 카테고리의 다른 글
| CORS·XSS·헤더 보안: SPA/REST 현실 설정 (0) | 2025.10.14 |
|---|---|
| 권한 모델링: ROLE vs 권한 문자열, @PreAuthorize, 도메인 권한 (0) | 2025.10.14 |
| JWT 심화: 만료/클레임/키 회전 + 4편과의 연결 전략 (0) | 2025.10.13 |
| 세션 기반 로그인 vs Stateless: CSRF/Remember-me/세션관리 (0) | 2025.10.12 |
| AuthenticationProvider & UserDetailsService 커스터마이징 (0) | 2025.10.12 |