diff --git a/src/main/java/com/chaineeproject/chainee/ChaineeApplication.java b/src/main/java/com/chaineeproject/chainee/ChaineeApplication.java index 2e9b452..b5bfbde 100644 --- a/src/main/java/com/chaineeproject/chainee/ChaineeApplication.java +++ b/src/main/java/com/chaineeproject/chainee/ChaineeApplication.java @@ -1,14 +1,15 @@ package com.chaineeproject.chainee; +import com.chaineeproject.chainee.config.AppProperties; import com.chaineeproject.chainee.jwt.JwtProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; @SpringBootApplication -@EnableConfigurationProperties(JwtProperties.class) +@EnableConfigurationProperties({JwtProperties.class, AppProperties.class}) public class ChaineeApplication { public static void main(String[] args) { SpringApplication.run(ChaineeApplication.class, args); } -} \ No newline at end of file +} diff --git a/src/main/java/com/chaineeproject/chainee/config/AppProperties.java b/src/main/java/com/chaineeproject/chainee/config/AppProperties.java new file mode 100644 index 0000000..1b9cea6 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/config/AppProperties.java @@ -0,0 +1,8 @@ +package com.chaineeproject.chainee.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app") +public record AppProperties(String frontendBaseUrl, Oauth2Props oauth2) { + public record Oauth2Props(String callbackPath) {} +} diff --git a/src/main/java/com/chaineeproject/chainee/security/handler/CustomOAuth2SuccessHandler.java b/src/main/java/com/chaineeproject/chainee/security/handler/CustomOAuth2SuccessHandler.java index a61853d..5dcb04b 100644 --- a/src/main/java/com/chaineeproject/chainee/security/handler/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/chaineeproject/chainee/security/handler/CustomOAuth2SuccessHandler.java @@ -1,30 +1,33 @@ +// src/main/java/com/chaineeproject/chainee/security/handler/CustomOAuth2SuccessHandler.java package com.chaineeproject.chainee.security.handler; import com.chaineeproject.chainee.auth.AuthService; import com.chaineeproject.chainee.auth.AuthTokens; +import com.chaineeproject.chainee.config.AppProperties; import com.chaineeproject.chainee.jwt.JwtProperties; import com.chaineeproject.chainee.security.CustomOauth2UserDetails; import com.chaineeproject.chainee.security.oauth.CookieUtil; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import java.io.IOException; -import java.util.Map; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; @RequiredArgsConstructor @Component @Slf4j public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler { - private final AuthService authService; // ✅ TokenService 대신 AuthService - private final JwtProperties jwtProps; // ✅ TTL/쿠키 정책은 yml에서 - private final ObjectMapper om = new ObjectMapper(); + private final AuthService authService; // Access/Refresh 발급 + private final JwtProperties jwtProps; // TTL/쿠키 정책 + private final AppProperties appProps; // 프론트 콜백 URL 구성 @Override public void onAuthenticationSuccess( @@ -34,15 +37,15 @@ public void onAuthenticationSuccess( CustomOauth2UserDetails u = (CustomOauth2UserDetails) authentication.getPrincipal(); - // 1) Access/Refresh 한 번에 발급 + Refresh 해시 저장(DB) + // 1) 토큰 한 번에 발급 AuthTokens t = authService.issueFor(u.getUser()); - // 2) 단계 안내 + // 2) 다음 단계 결정 String nextStep = !u.isKycVerified() ? "KYC" : (!u.isDidVerified() ? "DID" : "HOME"); - // 3) Refresh 쿠키 설정 (도메인 분기: 로컬= null, 운영= chainee.store) + // 3) Refresh 토큰은 HttpOnly 쿠키로 내려서 JS에서 읽히지 않게 int maxAge = (int) jwtProps.refreshTtl().toSeconds(); - boolean secure = Boolean.TRUE.equals(jwtProps.cookie().secure()); + boolean secure = Boolean.TRUE.equals(jwtProps.cookie().secure()); String sameSite = jwtProps.cookie().sameSite(); String cookieDomain = cookieDomainFor(request); CookieUtil.addCookie( @@ -50,35 +53,55 @@ public void onAuthenticationSuccess( jwtProps.refreshCookieName(), t.refreshToken(), cookieDomain, - true, // HttpOnly - secure, // Secure - sameSite, // Lax/None 등 + true, // HttpOnly + secure, // Secure + sameSite, // e.g., Lax / None maxAge ); - // 4) 응답 바디 (SPA가 accessToken 저장/사용) - var body = Map.of( - "authenticated", true, - "registered", !u.isNewUser(), - "email", u.getUsername(), - "status", Map.of("kyc", u.isKycVerified(), "did", u.isDidVerified()), - "nextStep", nextStep, + // 4) Access 토큰/상태는 URL fragment(#)로 전달 → 서버/프록시 로그에 남지 않음 + // 예) https://app.chainee.store/auth/callback#accessToken=...&accessExp=...&nextStep=KYC®istered=true&email=... + String base = normalizeBase(appProps.frontendBaseUrl()) + appProps.oauth2().callbackPath(); + String redirectUrl = buildFragmentUrl( + base, "accessToken", t.accessToken(), - "accessExp", t.accessExpEpochSec(), - "refreshExp", t.refreshExpEpochSec() + "accessExp", String.valueOf(t.accessExpEpochSec()), + "refreshExp", String.valueOf(t.refreshExpEpochSec()), + "nextStep", nextStep, + "registered", String.valueOf(!u.isNewUser()), + "email", u.getUsername() ); - response.setContentType("application/json"); - response.setStatus(HttpServletResponse.SC_OK); - response.getWriter().write(om.writeValueAsString(body)); + response.setStatus(HttpStatus.FOUND.value()); // 302 + response.setHeader("Location", redirectUrl); + // 바디 없음 } - /** 운영 도메인에서만 Domain=chainee.store 지정, 로컬/개발은 null로 둬야 쿠키가 저장됨 */ private String cookieDomainFor(HttpServletRequest req) { String host = req.getServerName(); if (host != null && (host.equals("chainee.store") || host.endsWith(".chainee.store"))) { - return "chainee.store"; + return "chainee.store"; // 운영에서만 Domain 지정 + } + return null; // dev/local (localhost/127.0.0.1 등) 에서는 Domain 미지정 + } + + private String normalizeBase(String base) { + if (base == null || base.isBlank()) return "/"; + return base.endsWith("/") ? base.substring(0, base.length() - 1) : base; + } + + private String enc(String s) { + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } + + /** fragment에 key=value&key=value 형태로 안전하게 넣는다 */ + private String buildFragmentUrl(String base, String... kv) { + StringBuilder sb = new StringBuilder(base); + sb.append("#"); + for (int i = 0; i < kv.length; i += 2) { + if (i > 0) sb.append("&"); + sb.append(enc(kv[i])).append("=").append(enc(kv[i + 1])); } - return null; // localhost/127.0.0.1 등 개발환경 + return sb.toString(); } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 3ed7ff3..aa648a6 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -42,4 +42,8 @@ app: refresh-cookie-name: refresh_token cookie: secure: false # 로컬은 http라 false - same-site: Lax \ No newline at end of file + same-site: Lax + + frontend-baseurl: https://app.chainee.store + oauth2: + callback-path: /auth/callback \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 38c6539..c5a215a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -33,4 +33,8 @@ app: refresh-cookie-name: refresh_token cookie: secure: true - same-site: Lax \ No newline at end of file + same-site: Lax + + frontend-baseurl: https://app.chainee.store + oauth2: + callback-path: /auth/callback \ No newline at end of file