- 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:
@@ -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 를 재수화한다.
|
||||
|
||||
Reference in New Issue
Block a user