feat: 구글 로그인/가입 — ID 토큰 검증 후 세션 발급
CI / build (push) Failing after 14m2s

- AuthService.googleLogin: 구글 tokeninfo 로 ID 토큰 검증(aud==클라이언트 ID)
  후 provider=GOOGLE 회원 조회/자동가입 → 세션 발급(login 과 동일 경로)
- GoogleLoginRequest DTO, /api/auth/google·/google-client-id 엔드포인트
- application.yml: app.google-client-id=${GOOGLE_CLIENT_ID:}

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-27 18:02:19 +09:00
parent a1697a7a22
commit 09efd65fa9
4 changed files with 99 additions and 4 deletions
@@ -56,6 +56,18 @@ public class AuthController {
return authService.login(req, clientIp(request));
}
/** 구글 OAuth 클라이언트 ID (비보호) — 프론트가 구글 버튼 노출/초기화에 사용. 미설정 시 빈 문자열 */
@GetMapping("/google-client-id")
public java.util.Map<String, String> googleClientId() {
return java.util.Map.of("clientId", authService.googleClientId());
}
/** 구글 로그인/가입 (비보호) — ID 토큰 검증 후 세션 발급 */
@PostMapping("/google")
public LoginResponse googleLogin(@Valid @RequestBody com.sb.web.auth.dto.GoogleLoginRequest req) {
return authService.googleLogin(req.getIdToken(), req.isRememberMe());
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest request) {
authService.logout(AuthInterceptor.resolveToken(request));
@@ -0,0 +1,16 @@
package com.sb.web.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 구글 로그인 요청 — 프론트(Google Identity Services)에서 받은 ID 토큰.
*/
@Data
public class GoogleLoginRequest {
@NotBlank(message = "토큰이 없습니다.")
private String idToken;
private boolean rememberMe;
}
@@ -19,6 +19,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
/**
@@ -38,6 +39,10 @@ public class AuthService {
private final com.sb.web.admin.service.AppSettingService appSettingService;
private final com.sb.web.auth.mapper.AuthSessionMapper authSessionMapper;
/** 구글 OAuth 클라이언트 ID (ID 토큰 aud 검증용). 미설정 시 구글 로그인 비활성. */
@org.springframework.beans.factory.annotation.Value("${app.google-client-id:}")
private String googleClientId;
private static final String SESSION_PREFIX = "session:";
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
private static final Duration REMEMBER_TTL = Duration.ofDays(30); // 자동 로그인(로그인 상태 유지)
@@ -126,9 +131,14 @@ public class AuthService {
try { redisTemplate.delete("login:rl:" + clientIp); } catch (Exception ignore) { /* 무시 */ }
}
Duration ttl = req.isRememberMe() ? REMEMBER_TTL : SESSION_TTL;
return issueSession(member, req.isRememberMe());
}
/** 세션 토큰 발급 → Redis + DB 백업. login/소셜로그인 공용. */
private LoginResponse issueSession(Member member, boolean rememberMe) {
Duration ttl = rememberMe ? REMEMBER_TTL : SESSION_TTL;
SessionUser session = SessionUser.from(member);
session.setRememberMe(req.isRememberMe());
session.setRememberMe(rememberMe);
String token = UUID.randomUUID().toString().replace("-", "");
java.time.LocalDateTime expiresAt = java.time.LocalDateTime.now().plus(ttl);
@@ -137,9 +147,9 @@ public class AuthService {
} catch (Exception e) {
log.warn("[login] Redis 세션 저장 실패(무시, DB 백업 사용): {}", e.toString());
}
// 영속 백업 — Redis 재시작/유실에도 로그인 유지
authSessionMapper.insert(com.sb.web.auth.domain.AuthSession.of(token, session, expiresAt));
log.info("[login] {} (token issued, rememberMe={})", member.getLoginId(), req.isRememberMe());
log.info("[login] member={} provider={} (token issued, rememberMe={})",
member.getId(), member.getProvider(), rememberMe);
return LoginResponse.builder()
.token(token)
@@ -148,6 +158,61 @@ public class AuthService {
.build();
}
/** 구글 로그인/가입 — ID 토큰 검증 후 provider=GOOGLE 회원 조회/생성하고 세션 발급. */
@Transactional
public LoginResponse googleLogin(String idToken, boolean rememberMe) {
if (googleClientId == null || googleClientId.isBlank()) {
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "구글 로그인이 설정되지 않았습니다.");
}
if (idToken == null || idToken.isBlank()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "토큰이 없습니다.");
}
// 구글 ID 토큰 검증(서명·만료는 구글이 검증) → 클레임 반환
Map<?, ?> info;
try {
info = org.springframework.web.client.RestClient.create()
.get()
.uri("https://oauth2.googleapis.com/tokeninfo?id_token={t}", idToken)
.retrieve()
.body(Map.class);
} catch (Exception e) {
throw new ApiException(HttpStatus.UNAUTHORIZED, "구글 인증에 실패했습니다.");
}
if (info == null || !googleClientId.equals(String.valueOf(info.get("aud")))) {
throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 구글 토큰입니다.");
}
String sub = String.valueOf(info.get("sub"));
String email = info.get("email") == null ? null : String.valueOf(info.get("email"));
String name = info.get("name") != null ? String.valueOf(info.get("name"))
: (email != null ? email.split("@")[0] : "사용자");
Member member = memberMapper.findByProvider("GOOGLE", sub);
if (member == null) {
// 최초 로그인 = 가입. 가입 제한 시 차단.
if (!appSettingService.isSignupEnabled()) {
throw new ApiException(HttpStatus.FORBIDDEN, "현재 회원가입이 제한되어 있습니다.");
}
member = Member.builder()
.name(name)
.email(email)
.provider("GOOGLE")
.providerId(sub)
.role("USER")
.status("ACTIVE")
.build();
memberMapper.insert(member);
log.info("[google] new member id={} email={}", member.getId(), email);
}
if (!"ACTIVE".equals(member.getStatus())) {
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
}
return issueSession(member, rememberMe);
}
public String googleClientId() {
return googleClientId == null ? "" : googleClientId;
}
/**
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
+2
View File
@@ -76,6 +76,8 @@ app:
account-key: ${ACCOUNT_CRYPTO_KEY:}
# 업로드 이미지 등 외부에서 접근할 절대 URL 베이스. 앱(Capacitor)은 출처가 localhost 라 상대경로가 안 통함.
public-url: ${PUBLIC_URL:https://app.sblog.kr}
# 구글 OAuth 웹 클라이언트 ID (Google Cloud Console 발급). 미설정 시 구글 로그인 비활성.
google-client-id: ${GOOGLE_CLIENT_ID:}
# ===== Logging =====
logging: