- 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));
|
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")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<Void> logout(HttpServletRequest request) {
|
public ResponseEntity<Void> logout(HttpServletRequest request) {
|
||||||
authService.logout(AuthInterceptor.resolveToken(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 org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
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.admin.service.AppSettingService appSettingService;
|
||||||
private final com.sb.web.auth.mapper.AuthSessionMapper authSessionMapper;
|
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 String SESSION_PREFIX = "session:";
|
||||||
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
|
private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션
|
||||||
private static final Duration REMEMBER_TTL = Duration.ofDays(30); // 자동 로그인(로그인 상태 유지)
|
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) { /* 무시 */ }
|
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);
|
SessionUser session = SessionUser.from(member);
|
||||||
session.setRememberMe(req.isRememberMe());
|
session.setRememberMe(rememberMe);
|
||||||
|
|
||||||
String token = UUID.randomUUID().toString().replace("-", "");
|
String token = UUID.randomUUID().toString().replace("-", "");
|
||||||
java.time.LocalDateTime expiresAt = java.time.LocalDateTime.now().plus(ttl);
|
java.time.LocalDateTime expiresAt = java.time.LocalDateTime.now().plus(ttl);
|
||||||
@@ -137,9 +147,9 @@ public class AuthService {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[login] Redis 세션 저장 실패(무시, DB 백업 사용): {}", e.toString());
|
log.warn("[login] Redis 세션 저장 실패(무시, DB 백업 사용): {}", e.toString());
|
||||||
}
|
}
|
||||||
// 영속 백업 — Redis 재시작/유실에도 로그인 유지
|
|
||||||
authSessionMapper.insert(com.sb.web.auth.domain.AuthSession.of(token, session, expiresAt));
|
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()
|
return LoginResponse.builder()
|
||||||
.token(token)
|
.token(token)
|
||||||
@@ -148,6 +158,61 @@ public class AuthService {
|
|||||||
.build();
|
.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 을 갱신(슬라이딩 만료)한다.
|
* 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다.
|
||||||
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
|
* Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다.
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ app:
|
|||||||
account-key: ${ACCOUNT_CRYPTO_KEY:}
|
account-key: ${ACCOUNT_CRYPTO_KEY:}
|
||||||
# 업로드 이미지 등 외부에서 접근할 절대 URL 베이스. 앱(Capacitor)은 출처가 localhost 라 상대경로가 안 통함.
|
# 업로드 이미지 등 외부에서 접근할 절대 URL 베이스. 앱(Capacitor)은 출처가 localhost 라 상대경로가 안 통함.
|
||||||
public-url: ${PUBLIC_URL:https://app.sblog.kr}
|
public-url: ${PUBLIC_URL:https://app.sblog.kr}
|
||||||
|
# 구글 OAuth 웹 클라이언트 ID (Google Cloud Console 발급). 미설정 시 구글 로그인 비활성.
|
||||||
|
google-client-id: ${GOOGLE_CLIENT_ID:}
|
||||||
|
|
||||||
# ===== Logging =====
|
# ===== Logging =====
|
||||||
logging:
|
logging:
|
||||||
|
|||||||
Reference in New Issue
Block a user