diff --git a/src/main/java/com/sb/web/auth/controller/AuthController.java b/src/main/java/com/sb/web/auth/controller/AuthController.java index 048369b..c0898fc 100644 --- a/src/main/java/com/sb/web/auth/controller/AuthController.java +++ b/src/main/java/com/sb/web/auth/controller/AuthController.java @@ -56,6 +56,18 @@ public class AuthController { return authService.login(req, clientIp(request)); } + /** 구글 OAuth 클라이언트 ID (비보호) — 프론트가 구글 버튼 노출/초기화에 사용. 미설정 시 빈 문자열 */ + @GetMapping("/google-client-id") + public java.util.Map 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 logout(HttpServletRequest request) { authService.logout(AuthInterceptor.resolveToken(request)); diff --git a/src/main/java/com/sb/web/auth/dto/GoogleLoginRequest.java b/src/main/java/com/sb/web/auth/dto/GoogleLoginRequest.java new file mode 100644 index 0000000..84742ba --- /dev/null +++ b/src/main/java/com/sb/web/auth/dto/GoogleLoginRequest.java @@ -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; +} diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java index 7b9eefd..cf3ef51 100644 --- a/src/main/java/com/sb/web/auth/service/AuthService.java +++ b/src/main/java/com/sb/web/auth/service/AuthService.java @@ -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 를 재수화한다. diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c2cef91..4cca2ad 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: