feat: 구글 로그인/가입 — ID 토큰 검증 후 세션 발급
- 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:
co-authored by
Claude Opus 4.8
parent
a1697a7a22
commit
09efd65fa9
@@ -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