From c911d552807733fef816806795a502d8b5d347e4 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 30 Jun 2026 11:07:54 +0900 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20ADR=200005=20SSO=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EA=B5=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ... \353\252\250\353\215\270 \353\217\204\354\236\205.md" | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git "a/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" "b/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" index 12bb535..e9af8b1 100644 --- "a/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" +++ "b/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" @@ -18,16 +18,16 @@ - 신규 사용자는 Google 또는 GitHub OAuth2 로그인으로만 가입한다. - 내부 사용자 모델은 `users`로 표현하고, 외부 로그인 연결은 `oauth_accounts`로 분리한다. -- `users`는 서비스 내부의 사람 계정이며, 대시보드와 API 클라이언트 발급의 소유 주체가 된다. +- `users`는 서비스 내부의 사람 계정이며, 대시보드 로그인과 사용자 소유 리소스의 소유 주체가 된다. - `oauth_accounts`는 provider, provider user id, provider email, email verified 상태처럼 외부 로그인 식별 정보를 저장한다. - 한 사용자가 Google과 GitHub를 모두 연결할 수 있도록 `users`와 `oauth_accounts`는 1:N 관계로 둔다. - 기존 `clients.email`은 로그인 계정으로 승격하지 않고, 기존 키 claim 및 연락용 legacy email로 취급한다. -- 기존 방식의 신규 API Key 발급은 중단한다. 단, 이미 발급된 기존 키는 호환성 유지를 위해 별도 유예 기간 동안 계속 허용한다. +- 외부 공개 API용 OAuth2 Client Credentials 토큰 발급과 신규 API Key 정책은 이 ADR에서 결정하지 않고 별도 ADR에서 다룬다. ## Consequences -- 사람 인증과 API 호출 인증의 책임이 분리된다. +- 사람 인증과 외부 공개 API 호출 인증의 책임이 분리된다. SSO 로그인 후 발급하는 대시보드용 stateless 인증 토큰과 대시보드 API 호출은 이 ADR의 적용 범위에 포함된다. - 로컬 비밀번호 저장과 비밀번호 재설정 기능을 만들지 않아도 된다. -- 대시보드에서 “내 API 클라이언트”, “기존 키 연결”, “신규 인증 정보 발급” 같은 사용자 중심 기능을 제공할 수 있다. +- 대시보드에서 “내 계정”, “외부 로그인 연결”, “기존 키 연결” 같은 사용자 중심 기능을 제공할 수 있다. - GitHub는 이메일 비공개 또는 미검증 케이스가 있으므로 provider email만으로 기존 키 소유권을 자동 확정하면 안 된다. - 기존 사용자는 SSO 로그인 후 기존 API Key와 이메일 인증을 통해 기존 키를 claim하는 별도 마이그레이션 흐름이 필요하다. - 운영자는 기존 사용자에게 전환 안내 메일을 보내고, 미전환 사용자를 추적할 수 있어야 한다. From c3bfbf95a1ae6de8aea9552314e25655926e086c Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 30 Jun 2026 12:37:05 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=B0=8F=20OAuth=20=EA=B3=84=EC=A0=95=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/db/migration/V2__oauth_2.sql | 33 +++++++++++++++++++ .../MySqlTestContainerSmokeTest.java | 2 ++ 2 files changed, 35 insertions(+) create mode 100644 profanity-storage/rdb/src/main/resources/db/migration/V2__oauth_2.sql diff --git a/profanity-storage/rdb/src/main/resources/db/migration/V2__oauth_2.sql b/profanity-storage/rdb/src/main/resources/db/migration/V2__oauth_2.sql new file mode 100644 index 0000000..9c2d94f --- /dev/null +++ b/profanity-storage/rdb/src/main/resources/db/migration/V2__oauth_2.sql @@ -0,0 +1,33 @@ +CREATE TABLE users +( + id binary (16) NOT NULL COMMENT '사용자 고유 식별자', + display_name varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '표시 이름', + primary_email varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '대표 이메일', + avatar_url varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '프로필 이미지 URL', + status varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '사용자 상태', + created_at datetime(6) NOT NULL COMMENT '생성 시각', + updated_at datetime(6) NOT NULL COMMENT '수정 시각', + PRIMARY KEY (id), + KEY idx_users_primary_email(primary_email), + KEY idx_users_status(status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 계정'; + +CREATE TABLE oauth_accounts +( + id binary (16) NOT NULL COMMENT 'OAuth 계정 연결 식별자', + user_id binary (16) NOT NULL COMMENT '사용자 ID', + provider varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'OAuth 제공자', + provider_user_id varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '제공자 사용자 ID', + provider_email varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '제공자 이메일', + email_verified tinyint NOT NULL DEFAULT 0 COMMENT '제공자 이메일 검증 여부', + provider_username varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '제공자 사용자명', + display_name varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '제공자 표시 이름', + avatar_url varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '제공자 프로필 이미지 URL', + linked_at datetime(6) NOT NULL COMMENT '연결 시각', + PRIMARY KEY (id), + UNIQUE KEY uk_oauth_accounts_provider_user (provider, provider_user_id), + UNIQUE KEY uk_oauth_accounts_user_provider (user_id, provider), + KEY idx_oauth_accounts_provider_email(provider_email), + CONSTRAINT fk_oauth_accounts_user_id + FOREIGN KEY (user_id) REFERENCES users (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OAuth 계정 연결'; diff --git a/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java b/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java index a5fc410..ec66662 100644 --- a/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java +++ b/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java @@ -28,6 +28,8 @@ void mysqlContainer_whenMigrateAndSeed_succeeds() throws SQLException { assertThat(count(dataSource, "clients")).isEqualTo(2); assertThat(count(dataSource, "profanity_word")).isEqualTo(3); + assertThat(count(dataSource, "users")).isZero(); + assertThat(count(dataSource, "oauth_accounts")).isZero(); execute( dataSource, From ccdb2bb5e3c384f6aa3068bfa82726f14cb1e7c5 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Thu, 2 Jul 2026 23:50:09 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20GitHub=20OAuth=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=ED=9D=90=EB=A6=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + profanity-api/build.gradle | 1 + .../java/app/security/SecurityConfig.java | 18 ++ .../java/app/security/filter/ExcludePath.java | 3 + ...eOAuth2AuthorizationRequestRepository.java | 252 ++++++++++++++++++ .../oauth2/OAuth2LoginFailureHandler.java | 49 ++++ .../oauth2/OAuth2LoginSuccessHandler.java | 69 +++++ .../security/oauth2/SsoCookieProperties.java | 6 + .../src/main/resources/application-sso.yaml | 17 ++ .../src/main/resources/application.yml | 1 + .../src/main/resources/openapi/error-model.md | 1 + .../oauth2/OAuth2LoginFailureHandlerTest.java | 33 +++ .../oauth2/OAuth2LoginSuccessHandlerTest.java | 67 +++++ .../data/response/constant/StatusCode.java | 1 + 14 files changed, 520 insertions(+) create mode 100644 profanity-api/src/main/java/app/security/oauth2/CookieOAuth2AuthorizationRequestRepository.java create mode 100644 profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java create mode 100644 profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java create mode 100644 profanity-api/src/main/java/app/security/oauth2/SsoCookieProperties.java create mode 100644 profanity-api/src/main/resources/application-sso.yaml create mode 100644 profanity-api/src/test/java/app/security/oauth2/OAuth2LoginFailureHandlerTest.java create mode 100644 profanity-api/src/test/java/app/security/oauth2/OAuth2LoginSuccessHandlerTest.java diff --git a/.gitignore b/.gitignore index c79df48..ec05b0c 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,8 @@ application-prod.yml /.env.redis /docs/index.html /data/** +/sso/sso.env +/profanity-api/src/main/resources/application-sso.local.yaml .secrets /http/http-client.env.json diff --git a/profanity-api/build.gradle b/profanity-api/build.gradle index 6d23188..c4126a1 100644 --- a/profanity-api/build.gradle +++ b/profanity-api/build.gradle @@ -16,6 +16,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-aop' diff --git a/profanity-api/src/main/java/app/security/SecurityConfig.java b/profanity-api/src/main/java/app/security/SecurityConfig.java index ca7c950..6fb404c 100644 --- a/profanity-api/src/main/java/app/security/SecurityConfig.java +++ b/profanity-api/src/main/java/app/security/SecurityConfig.java @@ -3,6 +3,9 @@ import app.security.authentication.AuthenticationService; import app.security.filter.CustomAuthenticationEntryPoint; import app.security.filter.CustomAuthenticationFilter; +import app.security.oauth2.CookieOAuth2AuthorizationRequestRepository; +import app.security.oauth2.OAuth2LoginFailureHandler; +import app.security.oauth2.OAuth2LoginSuccessHandler; import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; @@ -30,6 +33,10 @@ public class SecurityConfig { private final AuthenticationService authenticationService; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CookieOAuth2AuthorizationRequestRepository + cookieOAuth2AuthorizationRequestRepository; + private final OAuth2LoginSuccessHandler oauth2LoginSuccessHandler; + private final OAuth2LoginFailureHandler oauth2LoginFailureHandler; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -43,6 +50,15 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .headers( headersConfigurer -> headersConfigurer.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) + .oauth2Login( + oauth2 -> + oauth2 + .authorizationEndpoint( + authorization -> + authorization.authorizationRequestRepository( + cookieOAuth2AuthorizationRequestRepository)) + .successHandler(oauth2LoginSuccessHandler) + .failureHandler(oauth2LoginFailureHandler)) .authorizeHttpRequests( authorizationManagerRequestMatcherRegistry -> authorizationManagerRequestMatcherRegistry @@ -50,6 +66,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers(HttpMethod.GET, "/", "/index.html") .permitAll() + .requestMatchers("/sso/**", "/oauth2/**", "/login/oauth2/code/**") + .permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/clients/**") .permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/health") diff --git a/profanity-api/src/main/java/app/security/filter/ExcludePath.java b/profanity-api/src/main/java/app/security/filter/ExcludePath.java index a914756..a5e7567 100644 --- a/profanity-api/src/main/java/app/security/filter/ExcludePath.java +++ b/profanity-api/src/main/java/app/security/filter/ExcludePath.java @@ -14,6 +14,9 @@ public enum ExcludePath { OPENAPI("openapi.json", "OpenAPI JSON 스펙", List.of(HttpMethod.GET)), OVERVIEW("overview.md", "API Overview Markdown 문서", List.of(HttpMethod.GET)), LLMS(List.of("llms.txt", "llm.txt"), "LLM 문서 색인", List.of(HttpMethod.GET)), + SSO("sso", "SSO 정적 페이지", List.of(HttpMethod.GET)), + OAUTH2("oauth2", "OAuth2 인증 시작", List.of(HttpMethod.GET)), + OAUTH2_CALLBACK("login/oauth2/code", "OAuth2 callback", List.of(HttpMethod.GET)), ; private final List paths; diff --git a/profanity-api/src/main/java/app/security/oauth2/CookieOAuth2AuthorizationRequestRepository.java b/profanity-api/src/main/java/app/security/oauth2/CookieOAuth2AuthorizationRequestRepository.java new file mode 100644 index 0000000..d3ff1a5 --- /dev/null +++ b/profanity-api/src/main/java/app/security/oauth2/CookieOAuth2AuthorizationRequestRepository.java @@ -0,0 +1,252 @@ +package app.security.oauth2; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serial; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.lang.Nullable; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CookieOAuth2AuthorizationRequestRepository + implements AuthorizationRequestRepository { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final String COOKIE_SAME_SITE = "Lax"; + private static final String COOKIE_PATH = "/"; + private static final String VALUE_SEPARATOR = "."; + + private final SsoCookieProperties properties; + + /** + * callback 요청에서 OAuth2 authorization request 쿠키를 읽는다. + * + *

쿠키 서명과 TTL 검증을 통과한 경우에만 Spring Security가 state 비교에 사용할 요청 객체를 반환한다. + */ + @Override + @Nullable + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + return findCookie(request, properties.name()) + .map(Cookie::getValue) + .map(this::decode) + .orElse(null); + } + + /** + * 로그인 시작 시점의 OAuth2 authorization request를 signed cookie로 저장한다. + * + *

요청 객체와 생성 시각을 직렬화한 뒤 HMAC 서명을 붙여 브라우저 쿠키로 내려보낸다. + */ + @Override + public void saveAuthorizationRequest( + @Nullable OAuth2AuthorizationRequest authorizationRequest, + HttpServletRequest request, + HttpServletResponse response) { + if (authorizationRequest == null) { + expireCookie(request, response); + return; + } + + StoredAuthorizationRequest storedAuthorizationRequest = + new StoredAuthorizationRequest(Instant.now().getEpochSecond(), authorizationRequest); + String payload = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(serialize(storedAuthorizationRequest)); + String signature = sign(payload); + addCookie(request, response, payload + VALUE_SEPARATOR + signature, properties.ttlSeconds()); + } + + /** + * callback 처리 후 저장된 OAuth2 authorization request를 읽고 쿠키를 만료시킨다. + * + *

state 검증에 한 번 사용한 임시 쿠키가 다음 로그인 흐름에 재사용되지 않도록 즉시 삭제한다. + */ + @Override + @Nullable + public OAuth2AuthorizationRequest removeAuthorizationRequest( + HttpServletRequest request, HttpServletResponse response) { + OAuth2AuthorizationRequest authorizationRequest = loadAuthorizationRequest(request); + expireCookie(request, response); + return authorizationRequest; + } + + /** + * 요청 쿠키 목록에서 설정된 이름과 일치하는 쿠키를 찾는다. + * + *

브라우저가 쿠키를 보내지 않았거나 이름이 맞는 쿠키가 없으면 빈 Optional을 반환한다. + */ + private java.util.Optional findCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return java.util.Optional.empty(); + } + + for (Cookie cookie : cookies) { + if (name.equals(cookie.getName())) { + return java.util.Optional.of(cookie); + } + } + return java.util.Optional.empty(); + } + + /** + * 쿠키 값을 검증하고 OAuth2 authorization request로 복원한다. + * + *

payload와 signature를 분리해 서명을 확인하고, 만료 시간이 지난 쿠키는 인증 흐름에서 제외한다. + */ + @Nullable + private OAuth2AuthorizationRequest decode(String cookieValue) { + String[] parts = cookieValue.split("\\" + VALUE_SEPARATOR, 2); + if (parts.length != 2 || !isValidSignature(parts[0], parts[1])) { + log.warn("Invalid OAuth2 authorization request cookie signature"); + return null; + } + + try { + byte[] decoded = Base64.getUrlDecoder().decode(parts[0]); + Object deserialized = deserialize(decoded); + if (!(deserialized + instanceof + StoredAuthorizationRequest( + long createdAtEpochSecond, + OAuth2AuthorizationRequest authorizationRequest))) { + return null; + } + if (isExpired(createdAtEpochSecond)) { + log.warn("Expired OAuth2 authorization request cookie"); + return null; + } + return authorizationRequest; + } catch (IllegalArgumentException exception) { + log.warn("Failed to decode OAuth2 authorization request cookie", exception); + return null; + } + } + + /** + * 쿠키에 담을 저장 객체를 Java 직렬화 바이트 배열로 변환한다. + * + *

Spring Security의 OAuth2AuthorizationRequest 전체를 callback 시점에 복원하기 위한 내부 표현이다. + */ + private byte[] serialize(StoredAuthorizationRequest storedAuthorizationRequest) { + try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream)) { + objectOutputStream.writeObject(storedAuthorizationRequest); + return byteArrayOutputStream.toByteArray(); + } catch (IOException exception) { + throw new IllegalStateException( + "Failed to serialize OAuth2 authorization request", exception); + } + } + + /** + * 쿠키 payload의 직렬화 바이트 배열을 Java 객체로 되돌린다. + * + *

복원 실패는 쿠키 값이 깨졌거나 기대한 형식이 아니라는 의미이므로 잘못된 요청으로 처리한다. + */ + private Object deserialize(byte[] value) { + try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(value); + ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)) { + return objectInputStream.readObject(); + } catch (IOException | ClassNotFoundException exception) { + throw new IllegalArgumentException( + "Failed to deserialize OAuth2 authorization request", exception); + } + } + + /** + * 저장 시각 기준으로 쿠키 TTL을 초과했는지 확인한다. + * + *

OAuth2 state는 로그인 중간에만 필요한 값이므로 짧은 시간 안에 사용되지 않으면 폐기한다. + */ + private boolean isExpired(long createdAtEpochSecond) { + long elapsedSeconds = Instant.now().getEpochSecond() - createdAtEpochSecond; + return elapsedSeconds > properties.ttlSeconds(); + } + + /** + * payload와 함께 전달된 서명이 서버에서 다시 계산한 서명과 같은지 비교한다. + * + *

MessageDigest.isEqual을 사용해 단순 문자열 비교보다 timing attack에 덜 민감하게 검증한다. + */ + private boolean isValidSignature(String payload, String signature) { + byte[] expected = sign(payload).getBytes(StandardCharsets.UTF_8); + byte[] actual = signature.getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(expected, actual); + } + + /** + * 서버 signing key로 payload의 HMAC-SHA256 서명을 만든다. + * + *

브라우저는 signing key를 모르기 때문에 payload를 조작해도 유효한 서명을 새로 만들 수 없다. + */ + private String sign(String payload) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init( + new SecretKeySpec( + properties.signingKey().getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException( + "Failed to sign OAuth2 authorization request cookie", exception); + } + } + + /** + * OAuth2 authorization request 쿠키를 응답에 추가한다. + * + *

HttpOnly와 SameSite=Lax를 적용하고, 현재 요청이 HTTPS일 때만 Secure 속성을 붙인다. + */ + private void addCookie( + HttpServletRequest request, HttpServletResponse response, String value, long maxAgeSeconds) { + ResponseCookie cookie = + ResponseCookie.from(properties.name(), value) + .httpOnly(true) + .secure(request.isSecure()) + .sameSite(COOKIE_SAME_SITE) + .path(COOKIE_PATH) + .maxAge(Duration.ofSeconds(maxAgeSeconds)) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + /** + * 같은 이름의 쿠키를 maxAge 0으로 내려보내 브라우저에서 삭제한다. + * + *

성공, 실패, null 저장 상황에서 남은 authorization request 쿠키를 정리하는 데 사용한다. + */ + private void expireCookie(HttpServletRequest request, HttpServletResponse response) { + addCookie(request, response, "", 0); + } + + private record StoredAuthorizationRequest( + long createdAtEpochSecond, OAuth2AuthorizationRequest authorizationRequest) + implements Serializable { + @Serial private static final long serialVersionUID = 1L; + } +} diff --git a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java new file mode 100644 index 0000000..58dbd82 --- /dev/null +++ b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java @@ -0,0 +1,49 @@ +package app.security.oauth2; + +import app.core.data.response.Status; +import app.core.data.response.constant.StatusCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler { + + private static final String FRONTEND_REDIRECT_URI = + "http://localhost:63344/profanity-filter-api/sso/index.html"; + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) + throws IOException { + Status status = Status.of(StatusCode.OAUTH2_LOGIN_FAILED, exception.getMessage()); + + log.warn("OAuth2 login failed. message={}", exception.getMessage()); + + response.sendRedirect( + FRONTEND_REDIRECT_URI + + "#error=oauth2_login_failed" + + "&statusCode=" + + status.code() + + "&statusMessage=" + + encode(status.message()) + + "&statusDescription=" + + encode(status.description()) + + "&statusDetailDescription=" + + encode(status.DetailDescription())); + } + + private String encode(String value) { + if (value == null) { + return ""; + } + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } +} diff --git a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..9041f63 --- /dev/null +++ b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java @@ -0,0 +1,69 @@ +package app.security.oauth2; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { + + private static final String MOCK_TOKEN_PREFIX = "mock_dashboard_token_"; + private static final String FRONTEND_REDIRECT_URI = + "http://localhost:63344/profanity-filter-api/sso/index.html"; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException { + OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal(); + String mockToken = MOCK_TOKEN_PREFIX + UUID.randomUUID().toString().replace("-", ""); + String githubUserId = attributeAsString(oauth2User, "id"); + String githubLogin = attributeAsString(oauth2User, "login"); + + log.info( + "GitHub OAuth2 login succeeded. provider={}, githubUserId={}, githubLogin={}", + registrationId(authentication), + githubUserId, + githubLogin); + + response.sendRedirect( + FRONTEND_REDIRECT_URI + + "#provider=" + + encode(registrationId(authentication)) + + "&githubUserId=" + + encode(githubUserId) + + "&githubLogin=" + + encode(githubLogin) + + "&dashboardAccessToken=" + + encode(mockToken)); + } + + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private String attributeAsString(OAuth2User oauth2User, String attributeName) { + Object attribute = oauth2User.getAttribute(attributeName); + if (attribute == null) { + return ""; + } + return String.valueOf(attribute); + } + + private String registrationId(Authentication authentication) { + if (authentication instanceof OAuth2AuthenticationToken oauth2AuthenticationToken) { + return oauth2AuthenticationToken.getAuthorizedClientRegistrationId(); + } + return "unknown"; + } +} diff --git a/profanity-api/src/main/java/app/security/oauth2/SsoCookieProperties.java b/profanity-api/src/main/java/app/security/oauth2/SsoCookieProperties.java new file mode 100644 index 0000000..5d14070 --- /dev/null +++ b/profanity-api/src/main/java/app/security/oauth2/SsoCookieProperties.java @@ -0,0 +1,6 @@ +package app.security.oauth2; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "sso.cookie") +public record SsoCookieProperties(String name, long ttlSeconds, String signingKey) {} diff --git a/profanity-api/src/main/resources/application-sso.yaml b/profanity-api/src/main/resources/application-sso.yaml new file mode 100644 index 0000000..2618560 --- /dev/null +++ b/profanity-api/src/main/resources/application-sso.yaml @@ -0,0 +1,17 @@ +spring: + security: + oauth2: + client: + registration: + github: + client-id: ${GITHUB_OAUTH_CLIENT_ID:} + client-secret: ${GITHUB_OAUTH_CLIENT_SECRET:} + scope: + - read:user + - user:email + +sso: + cookie: + name: PF_OAUTH2_AUTHORIZATION_REQUEST + ttl-seconds: 300 + signing-key: ${SSO_COOKIE_SIGNING_KEY:} diff --git a/profanity-api/src/main/resources/application.yml b/profanity-api/src/main/resources/application.yml index 8ec9cf6..e32fba9 100644 --- a/profanity-api/src/main/resources/application.yml +++ b/profanity-api/src/main/resources/application.yml @@ -12,6 +12,7 @@ spring: - redis - domain - shared + - sso mail: host: smtp.gmail.com port: 587 diff --git a/profanity-api/src/main/resources/openapi/error-model.md b/profanity-api/src/main/resources/openapi/error-model.md index bf1ad3d..a288884 100644 --- a/profanity-api/src/main/resources/openapi/error-model.md +++ b/profanity-api/src/main/resources/openapi/error-model.md @@ -27,6 +27,7 @@ API Key 누락처럼 보안 필터에서 차단되는 요청은 HTTP `401`과 `s | `4002` | `Invalid_tracking_id` | 유효하지 않은 tracking ID | | `4003` | `Not_fount_tracking_id` | tracking ID를 찾을 수 없음 | | `4010` | `Unauthorized` | API Key 누락 | +| `4011` | `Oauth2_login_failed` | OAuth2 로그인 실패 | | `4030` | `Forbidden` | 권한 부족 또는 차단된 클라이언트 | | `4031` | `Not_found_client` | 클라이언트 정보 없음 | | `4032` | `Invalid_api_key` | 유효하지 않은 API Key | diff --git a/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginFailureHandlerTest.java b/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginFailureHandlerTest.java new file mode 100644 index 0000000..bf01f50 --- /dev/null +++ b/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginFailureHandlerTest.java @@ -0,0 +1,33 @@ +package app.security.oauth2; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.core.data.response.constant.StatusCode; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; + +class OAuth2LoginFailureHandlerTest { + + @Test + @DisplayName("OAuth2 로그인 실패 시 표준 status 코드와 상세 사유를 FE fragment로 redirect한다") + void onAuthenticationFailure_whenOAuth2LoginFailed_redirectsToFrontendFragment() + throws Exception { + OAuth2LoginFailureHandler failureHandler = new OAuth2LoginFailureHandler(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + failureHandler.onAuthenticationFailure( + new MockHttpServletRequest(), response, new BadCredentialsException("state mismatch")); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FOUND); + assertThat(response.getRedirectedUrl()) + .startsWith("http://localhost:63344/profanity-filter-api/sso/index.html#") + .contains("error=oauth2_login_failed") + .contains("statusCode=" + StatusCode.OAUTH2_LOGIN_FAILED.code()) + .contains("statusMessage=" + StatusCode.OAUTH2_LOGIN_FAILED.status()) + .contains("statusDetailDescription=state%20mismatch"); + } +} diff --git a/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginSuccessHandlerTest.java b/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginSuccessHandlerTest.java new file mode 100644 index 0000000..838b982 --- /dev/null +++ b/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginSuccessHandlerTest.java @@ -0,0 +1,67 @@ +package app.security.oauth2; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; + +class OAuth2LoginSuccessHandlerTest { + + @Test + @DisplayName("GitHub 로그인 성공 시 mock dashboard token과 사용자 정보를 FE fragment로 redirect한다") + void onAuthenticationSuccess_whenGithubLoginSucceeded_redirectsToFrontendFragment() + throws Exception { + OAuth2LoginSuccessHandler successHandler = new OAuth2LoginSuccessHandler(); + OAuth2User oauth2User = + new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_USER")), + Map.of( + "id", 12345, + "login", "hgkim", + "avatar_url", "https://avatars.githubusercontent.com/u/12345"), + "id"); + OAuth2AuthenticationToken authentication = + new OAuth2AuthenticationToken(oauth2User, oauth2User.getAuthorities(), "github"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + successHandler.onAuthenticationSuccess(new MockHttpServletRequest(), response, authentication); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FOUND); + assertThat(response.getRedirectedUrl()) + .startsWith("http://localhost:63344/profanity-filter-api/sso/index.html#") + .contains("provider=github") + .contains("githubUserId=12345") + .contains("githubLogin=hgkim") + .contains("dashboardAccessToken=mock_dashboard_token_"); + } + + @Test + @DisplayName("GitHub 사용자 속성이 비어 있어도 null 없이 FE fragment로 redirect한다") + void onAuthenticationSuccess_whenGithubAttributesAreMissing_redirectsWithEmptyStrings() + throws Exception { + OAuth2LoginSuccessHandler successHandler = new OAuth2LoginSuccessHandler(); + OAuth2User oauth2User = + new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_USER")), Map.of("id", 12345), "id"); + OAuth2AuthenticationToken authentication = + new OAuth2AuthenticationToken(oauth2User, oauth2User.getAuthorities(), "github"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + successHandler.onAuthenticationSuccess(new MockHttpServletRequest(), response, authentication); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FOUND); + assertThat(response.getRedirectedUrl()) + .contains("githubUserId=12345") + .contains("githubLogin=") + .contains("dashboardAccessToken=mock_dashboard_token_"); + } +} diff --git a/profanity-shared/src/main/java/app/core/data/response/constant/StatusCode.java b/profanity-shared/src/main/java/app/core/data/response/constant/StatusCode.java index 9150617..45708eb 100644 --- a/profanity-shared/src/main/java/app/core/data/response/constant/StatusCode.java +++ b/profanity-shared/src/main/java/app/core/data/response/constant/StatusCode.java @@ -11,6 +11,7 @@ public enum StatusCode { "처리에 실패하였습니다. 요청이 잘못 되었거나 필수 파라미터가 누락된 경우 발생 합니다. Description에서 보다 상세한 오류 메세지를 확인할 수 있습니다."), NOT_FOUNT_TRACKING_ID(4003, "유효하지 않은 Tracking ID 입니다. Tracking ID를 확인해 주세요."), UNAUTHORIZED(4010, "인증 키가 누락 되었습니다."), + OAUTH2_LOGIN_FAILED(4011, "OAuth2 로그인에 실패했습니다."), FORBIDDEN(4030, "인증 권한이 부적절합니다. 인증 키가 유효하지 않거나 권한이 없는 경우 발생합니다."), NOT_FOUND_CLIENT(4031, "클라이언트 정보를 찾을 수 없습니다. 인증 키가 유효하지 않거나 권한이 없는 경우 발생합니다."), INVALID_API_KEY(4032, "API 키가 유효하지 않습니다. 인증 키가 유효하지 않거나 권한이 없는 경우 발생합니다."), From c11c482272b81e063e61410aefec5f8e24832cfb Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Fri, 3 Jul 2026 00:14:44 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20OAuth=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=EC=84=A4=EC=A0=95=EC=9D=84=20fake=20pr?= =?UTF-8?q?ovider=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/app/security/SecurityConfig.java | 3 +++ ...eOAuth2AuthorizationRequestRepository.java | 2 -- .../oauth2/OAuth2LoginFailureHandler.java | 2 -- .../oauth2/OAuth2LoginSuccessHandler.java | 2 -- .../oauth2/OAuth2SecuritySupportConfig.java | 26 +++++++++++++++++++ .../src/main/resources/application-sso.yaml | 12 ++++++--- .../presentation/ProfanityControllerTest.java | 6 ++++- .../ResponseCustomizingIntegrationTest.java | 6 ++++- 8 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 profanity-api/src/main/java/app/security/oauth2/OAuth2SecuritySupportConfig.java diff --git a/profanity-api/src/main/java/app/security/SecurityConfig.java b/profanity-api/src/main/java/app/security/SecurityConfig.java index 6fb404c..12d3152 100644 --- a/profanity-api/src/main/java/app/security/SecurityConfig.java +++ b/profanity-api/src/main/java/app/security/SecurityConfig.java @@ -6,12 +6,14 @@ import app.security.oauth2.CookieOAuth2AuthorizationRequestRepository; import app.security.oauth2.OAuth2LoginFailureHandler; import app.security.oauth2.OAuth2LoginSuccessHandler; +import app.security.oauth2.OAuth2SecuritySupportConfig; import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -28,6 +30,7 @@ @Configuration @EnableWebSecurity +@Import(OAuth2SecuritySupportConfig.class) @RequiredArgsConstructor public class SecurityConfig { diff --git a/profanity-api/src/main/java/app/security/oauth2/CookieOAuth2AuthorizationRequestRepository.java b/profanity-api/src/main/java/app/security/oauth2/CookieOAuth2AuthorizationRequestRepository.java index d3ff1a5..54baf03 100644 --- a/profanity-api/src/main/java/app/security/oauth2/CookieOAuth2AuthorizationRequestRepository.java +++ b/profanity-api/src/main/java/app/security/oauth2/CookieOAuth2AuthorizationRequestRepository.java @@ -24,10 +24,8 @@ import org.springframework.lang.Nullable; import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; -import org.springframework.stereotype.Component; @Slf4j -@Component @RequiredArgsConstructor public class CookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository { diff --git a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java index 58dbd82..fe710e5 100644 --- a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java +++ b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java @@ -10,10 +10,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; -import org.springframework.stereotype.Component; @Slf4j -@Component public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler { private static final String FRONTEND_REDIRECT_URI = diff --git a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java index 9041f63..dbf31f2 100644 --- a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java +++ b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java @@ -11,10 +11,8 @@ import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; -import org.springframework.stereotype.Component; @Slf4j -@Component public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private static final String MOCK_TOKEN_PREFIX = "mock_dashboard_token_"; diff --git a/profanity-api/src/main/java/app/security/oauth2/OAuth2SecuritySupportConfig.java b/profanity-api/src/main/java/app/security/oauth2/OAuth2SecuritySupportConfig.java new file mode 100644 index 0000000..58dfb0f --- /dev/null +++ b/profanity-api/src/main/java/app/security/oauth2/OAuth2SecuritySupportConfig.java @@ -0,0 +1,26 @@ +package app.security.oauth2; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(SsoCookieProperties.class) +public class OAuth2SecuritySupportConfig { + + @Bean + CookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationRequestRepository( + SsoCookieProperties ssoCookieProperties) { + return new CookieOAuth2AuthorizationRequestRepository(ssoCookieProperties); + } + + @Bean + OAuth2LoginSuccessHandler oauth2LoginSuccessHandler() { + return new OAuth2LoginSuccessHandler(); + } + + @Bean + OAuth2LoginFailureHandler oauth2LoginFailureHandler() { + return new OAuth2LoginFailureHandler(); + } +} diff --git a/profanity-api/src/main/resources/application-sso.yaml b/profanity-api/src/main/resources/application-sso.yaml index 2618560..bfe379a 100644 --- a/profanity-api/src/main/resources/application-sso.yaml +++ b/profanity-api/src/main/resources/application-sso.yaml @@ -4,14 +4,20 @@ spring: client: registration: github: - client-id: ${GITHUB_OAUTH_CLIENT_ID:} - client-secret: ${GITHUB_OAUTH_CLIENT_SECRET:} + client-id: ${GITHUB_OAUTH_CLIENT_ID:stub-github-client} + client-secret: ${GITHUB_OAUTH_CLIENT_SECRET:stub-github-secret} scope: - read:user - user:email + provider: + github: + authorization-uri: ${GITHUB_OAUTH_AUTHORIZATION_URI:http://127.0.0.1:65535/oauth2/authorize} + token-uri: ${GITHUB_OAUTH_TOKEN_URI:http://127.0.0.1:65535/oauth2/token} + user-info-uri: ${GITHUB_OAUTH_USER_INFO_URI:http://127.0.0.1:65535/user} + user-name-attribute: id sso: cookie: name: PF_OAUTH2_AUTHORIZATION_REQUEST ttl-seconds: 300 - signing-key: ${SSO_COOKIE_SIGNING_KEY:} + signing-key: ${SSO_COOKIE_SIGNING_KEY:stub-sso-cookie-signing-key-for-local-and-ci} diff --git a/profanity-api/src/test/java/app/presentation/ProfanityControllerTest.java b/profanity-api/src/test/java/app/presentation/ProfanityControllerTest.java index 29117ca..ce4beff 100644 --- a/profanity-api/src/test/java/app/presentation/ProfanityControllerTest.java +++ b/profanity-api/src/test/java/app/presentation/ProfanityControllerTest.java @@ -17,6 +17,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; @@ -25,7 +26,10 @@ @WebMvcTest( controllers = ProfanityController.class, - excludeAutoConfiguration = SecurityAutoConfiguration.class) + excludeAutoConfiguration = { + SecurityAutoConfiguration.class, + OAuth2ClientAutoConfiguration.class + }) @Import(TestConfig.class) class ProfanityControllerTest { private static final String REQUEST_URL = "/api/v1/filter"; diff --git a/profanity-api/src/test/java/app/web/response/ResponseCustomizingIntegrationTest.java b/profanity-api/src/test/java/app/web/response/ResponseCustomizingIntegrationTest.java index 44a56d6..bb127d3 100644 --- a/profanity-api/src/test/java/app/web/response/ResponseCustomizingIntegrationTest.java +++ b/profanity-api/src/test/java/app/web/response/ResponseCustomizingIntegrationTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; @@ -21,7 +22,10 @@ /** 실제 MockMvc 직렬화 흐름(@RestControllerAdvice 등록 → supports → 직렬화)까지 검증하는 통합 테스트. */ @WebMvcTest( controllers = ResponseCustomizingIntegrationTest.MetaTestController.class, - excludeAutoConfiguration = SecurityAutoConfiguration.class) + excludeAutoConfiguration = { + SecurityAutoConfiguration.class, + OAuth2ClientAutoConfiguration.class + }) @Import({ResponseCustomizingAdvice.class, HostResponseCustomizer.class}) @TestPropertySource(properties = "app.response.proxied-host=api.kr-filter.com") class ResponseCustomizingIntegrationTest { From 0b45f97915c2d41075177279fa6eee576339229d Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Fri, 3 Jul 2026 17:11:48 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20OAuth=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EC=83=98=ED=94=8C=20=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0\353\215\270 \353\217\204\354\236\205.md" | 7 +- .../oauth2/OAuth2LoginFailureHandler.java | 7 +- .../oauth2/OAuth2LoginSuccessHandler.java | 70 +++++++-- .../oauth2/OAuth2SecuritySupportConfig.java | 10 +- .../oauth2/SsoFrontendProperties.java | 6 + .../src/main/resources/application-local.yaml | 6 + .../src/main/resources/application-sso.yaml | 15 ++ .../oauth2/OAuth2LoginFailureHandlerTest.java | 7 +- .../oauth2/OAuth2LoginSuccessHandlerTest.java | 45 +++++- ui/sample-app/src/App.tsx | 3 + ui/sample-app/src/components/Navigation.tsx | 10 ++ ui/sample-app/src/constants/pagePath.ts | 7 +- .../src/features/login/LoginPage.tsx | 109 ++++++++++++++ ui/sample-app/src/styles.css | 138 ++++++++++++++++++ 14 files changed, 413 insertions(+), 27 deletions(-) create mode 100644 profanity-api/src/main/java/app/security/oauth2/SsoFrontendProperties.java create mode 100644 profanity-api/src/main/resources/application-local.yaml create mode 100644 ui/sample-app/src/features/login/LoginPage.tsx diff --git "a/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" "b/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" index e9af8b1..ced0a42 100644 --- "a/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" +++ "b/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" @@ -24,8 +24,13 @@ - 기존 `clients.email`은 로그인 계정으로 승격하지 않고, 기존 키 claim 및 연락용 legacy email로 취급한다. - 외부 공개 API용 OAuth2 Client Credentials 토큰 발급과 신규 API Key 정책은 이 ADR에서 결정하지 않고 별도 ADR에서 다룬다. +## Implementation Scope +이번 0005 적용은 Google과 GitHub SSO 앱 등록, Spring OAuth2 client registration, authorization 진입점, callback URL, success/failure handler로 이어지는 콜백 파이프라인을 파악하고 검증하는 데 집중한다. + +대시보드용 서버 로그인 토큰 발급, claim 할당, 기존 API Key 연결, 사용자 생성 및 `oauth_accounts` upsert 처리 로직은 다음 구현 단계에서 다룬다. + ## Consequences -- 사람 인증과 외부 공개 API 호출 인증의 책임이 분리된다. SSO 로그인 후 발급하는 대시보드용 stateless 인증 토큰과 대시보드 API 호출은 이 ADR의 적용 범위에 포함된다. +- 사람 인증과 외부 공개 API 호출 인증의 책임이 분리된다. 대시보드용 stateless 인증 토큰과 대시보드 API 호출 인증은 SSO 이후 단계에서 별도로 구현한다. - 로컬 비밀번호 저장과 비밀번호 재설정 기능을 만들지 않아도 된다. - 대시보드에서 “내 계정”, “외부 로그인 연결”, “기존 키 연결” 같은 사용자 중심 기능을 제공할 수 있다. - GitHub는 이메일 비공개 또는 미검증 케이스가 있으므로 provider email만으로 기존 키 소유권을 자동 확정하면 안 된다. diff --git a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java index fe710e5..33d551d 100644 --- a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java +++ b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java @@ -7,15 +7,16 @@ import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; @Slf4j +@RequiredArgsConstructor public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler { - private static final String FRONTEND_REDIRECT_URI = - "http://localhost:63344/profanity-filter-api/sso/index.html"; + private final SsoFrontendProperties ssoFrontendProperties; @Override public void onAuthenticationFailure( @@ -26,7 +27,7 @@ public void onAuthenticationFailure( log.warn("OAuth2 login failed. message={}", exception.getMessage()); response.sendRedirect( - FRONTEND_REDIRECT_URI + ssoFrontendProperties.redirectUri() + "#error=oauth2_login_failed" + "&statusCode=" + status.code() diff --git a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java index dbf31f2..4cfac32 100644 --- a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java +++ b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java @@ -6,6 +6,7 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.UUID; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; @@ -13,39 +14,75 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @Slf4j +@RequiredArgsConstructor public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private static final String MOCK_TOKEN_PREFIX = "mock_dashboard_token_"; - private static final String FRONTEND_REDIRECT_URI = - "http://localhost:63344/profanity-filter-api/sso/index.html"; + + private final SsoFrontendProperties ssoFrontendProperties; @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal(); + String provider = registrationId(authentication); String mockToken = MOCK_TOKEN_PREFIX + UUID.randomUUID().toString().replace("-", ""); - String githubUserId = attributeAsString(oauth2User, "id"); - String githubLogin = attributeAsString(oauth2User, "login"); + ProviderProfile providerProfile = providerProfile(provider, oauth2User); log.info( - "GitHub OAuth2 login succeeded. provider={}, githubUserId={}, githubLogin={}", - registrationId(authentication), - githubUserId, - githubLogin); + "OAuth2 login succeeded. provider={}, providerUserId={}, providerLogin={}, providerEmail={}", + provider, + providerProfile.userId(), + providerProfile.login(), + providerProfile.email()); response.sendRedirect( - FRONTEND_REDIRECT_URI + ssoFrontendProperties.redirectUri() + "#provider=" - + encode(registrationId(authentication)) + + encode(provider) + + "&providerUserId=" + + encode(providerProfile.userId()) + + "&providerLogin=" + + encode(providerProfile.login()) + + "&providerEmail=" + + encode(providerProfile.email()) + "&githubUserId=" - + encode(githubUserId) + + encode(providerProfile.githubUserId()) + "&githubLogin=" - + encode(githubLogin) + + encode(providerProfile.githubLogin()) + + "&googleUserId=" + + encode(providerProfile.googleUserId()) + + "&googleEmail=" + + encode(providerProfile.googleEmail()) + "&dashboardAccessToken=" + encode(mockToken)); } + private ProviderProfile providerProfile(String provider, OAuth2User oauth2User) { + return switch (provider) { + case "github" -> + new ProviderProfile( + attributeAsString(oauth2User, "id"), + attributeAsString(oauth2User, "login"), + attributeAsString(oauth2User, "email"), + attributeAsString(oauth2User, "id"), + attributeAsString(oauth2User, "login"), + "", + ""); + case "google" -> + new ProviderProfile( + attributeAsString(oauth2User, "sub"), + attributeAsString(oauth2User, "name"), + attributeAsString(oauth2User, "email"), + "", + "", + attributeAsString(oauth2User, "sub"), + attributeAsString(oauth2User, "email")); + default -> new ProviderProfile("", "", "", "", "", "", ""); + }; + } + private String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); } @@ -64,4 +101,13 @@ private String registrationId(Authentication authentication) { } return "unknown"; } + + private record ProviderProfile( + String userId, + String login, + String email, + String githubUserId, + String githubLogin, + String googleUserId, + String googleEmail) {} } diff --git a/profanity-api/src/main/java/app/security/oauth2/OAuth2SecuritySupportConfig.java b/profanity-api/src/main/java/app/security/oauth2/OAuth2SecuritySupportConfig.java index 58dfb0f..3dd5131 100644 --- a/profanity-api/src/main/java/app/security/oauth2/OAuth2SecuritySupportConfig.java +++ b/profanity-api/src/main/java/app/security/oauth2/OAuth2SecuritySupportConfig.java @@ -5,7 +5,7 @@ import org.springframework.context.annotation.Configuration; @Configuration -@EnableConfigurationProperties(SsoCookieProperties.class) +@EnableConfigurationProperties({SsoCookieProperties.class, SsoFrontendProperties.class}) public class OAuth2SecuritySupportConfig { @Bean @@ -15,12 +15,12 @@ CookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationRequestRepos } @Bean - OAuth2LoginSuccessHandler oauth2LoginSuccessHandler() { - return new OAuth2LoginSuccessHandler(); + OAuth2LoginSuccessHandler oauth2LoginSuccessHandler(SsoFrontendProperties ssoFrontendProperties) { + return new OAuth2LoginSuccessHandler(ssoFrontendProperties); } @Bean - OAuth2LoginFailureHandler oauth2LoginFailureHandler() { - return new OAuth2LoginFailureHandler(); + OAuth2LoginFailureHandler oauth2LoginFailureHandler(SsoFrontendProperties ssoFrontendProperties) { + return new OAuth2LoginFailureHandler(ssoFrontendProperties); } } diff --git a/profanity-api/src/main/java/app/security/oauth2/SsoFrontendProperties.java b/profanity-api/src/main/java/app/security/oauth2/SsoFrontendProperties.java new file mode 100644 index 0000000..78b4422 --- /dev/null +++ b/profanity-api/src/main/java/app/security/oauth2/SsoFrontendProperties.java @@ -0,0 +1,6 @@ +package app.security.oauth2; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "sso.frontend") +public record SsoFrontendProperties(String redirectUri) {} diff --git a/profanity-api/src/main/resources/application-local.yaml b/profanity-api/src/main/resources/application-local.yaml new file mode 100644 index 0000000..05d384c --- /dev/null +++ b/profanity-api/src/main/resources/application-local.yaml @@ -0,0 +1,6 @@ +spring: + config: + import: + - optional:file:./sso/sso.env[.properties] + - optional:file:../sso/sso.env[.properties] + - optional:classpath:application-sso.local.yaml diff --git a/profanity-api/src/main/resources/application-sso.yaml b/profanity-api/src/main/resources/application-sso.yaml index bfe379a..68ccae0 100644 --- a/profanity-api/src/main/resources/application-sso.yaml +++ b/profanity-api/src/main/resources/application-sso.yaml @@ -9,14 +9,29 @@ spring: scope: - read:user - user:email + google: + client-id: ${GOOGLE_OAUTH_CLIENT_ID:stub-google-client} + client-secret: ${GOOGLE_OAUTH_CLIENT_SECRET:stub-google-secret} + scope: + - openid + - profile + - email provider: github: authorization-uri: ${GITHUB_OAUTH_AUTHORIZATION_URI:http://127.0.0.1:65535/oauth2/authorize} token-uri: ${GITHUB_OAUTH_TOKEN_URI:http://127.0.0.1:65535/oauth2/token} user-info-uri: ${GITHUB_OAUTH_USER_INFO_URI:http://127.0.0.1:65535/user} user-name-attribute: id + google: + authorization-uri: ${GOOGLE_OAUTH_AUTHORIZATION_URI:http://127.0.0.1:65535/oauth2/authorize} + token-uri: ${GOOGLE_OAUTH_TOKEN_URI:http://127.0.0.1:65535/oauth2/token} + user-info-uri: ${GOOGLE_OAUTH_USER_INFO_URI:http://127.0.0.1:65535/userinfo} + jwk-set-uri: ${GOOGLE_OAUTH_JWK_SET_URI:http://127.0.0.1:65535/oauth2/certs} + user-name-attribute: sub sso: + frontend: + redirect-uri: ${SSO_FRONTEND_REDIRECT_URI:http://localhost:5173/login} cookie: name: PF_OAUTH2_AUTHORIZATION_REQUEST ttl-seconds: 300 diff --git a/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginFailureHandlerTest.java b/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginFailureHandlerTest.java index bf01f50..d81ab69 100644 --- a/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginFailureHandlerTest.java +++ b/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginFailureHandlerTest.java @@ -12,11 +12,14 @@ class OAuth2LoginFailureHandlerTest { + private static final SsoFrontendProperties FRONTEND_PROPERTIES = + new SsoFrontendProperties("http://localhost:5173/login"); + @Test @DisplayName("OAuth2 로그인 실패 시 표준 status 코드와 상세 사유를 FE fragment로 redirect한다") void onAuthenticationFailure_whenOAuth2LoginFailed_redirectsToFrontendFragment() throws Exception { - OAuth2LoginFailureHandler failureHandler = new OAuth2LoginFailureHandler(); + OAuth2LoginFailureHandler failureHandler = new OAuth2LoginFailureHandler(FRONTEND_PROPERTIES); MockHttpServletResponse response = new MockHttpServletResponse(); failureHandler.onAuthenticationFailure( @@ -24,7 +27,7 @@ void onAuthenticationFailure_whenOAuth2LoginFailed_redirectsToFrontendFragment() assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FOUND); assertThat(response.getRedirectedUrl()) - .startsWith("http://localhost:63344/profanity-filter-api/sso/index.html#") + .startsWith("http://localhost:5173/login#") .contains("error=oauth2_login_failed") .contains("statusCode=" + StatusCode.OAUTH2_LOGIN_FAILED.code()) .contains("statusMessage=" + StatusCode.OAUTH2_LOGIN_FAILED.status()) diff --git a/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginSuccessHandlerTest.java b/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginSuccessHandlerTest.java index 838b982..0f15989 100644 --- a/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginSuccessHandlerTest.java +++ b/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginSuccessHandlerTest.java @@ -16,11 +16,14 @@ class OAuth2LoginSuccessHandlerTest { + private static final SsoFrontendProperties FRONTEND_PROPERTIES = + new SsoFrontendProperties("http://localhost:5173/login"); + @Test @DisplayName("GitHub 로그인 성공 시 mock dashboard token과 사용자 정보를 FE fragment로 redirect한다") void onAuthenticationSuccess_whenGithubLoginSucceeded_redirectsToFrontendFragment() throws Exception { - OAuth2LoginSuccessHandler successHandler = new OAuth2LoginSuccessHandler(); + OAuth2LoginSuccessHandler successHandler = new OAuth2LoginSuccessHandler(FRONTEND_PROPERTIES); OAuth2User oauth2User = new DefaultOAuth2User( List.of(new SimpleGrantedAuthority("ROLE_USER")), @@ -37,8 +40,10 @@ void onAuthenticationSuccess_whenGithubLoginSucceeded_redirectsToFrontendFragmen assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FOUND); assertThat(response.getRedirectedUrl()) - .startsWith("http://localhost:63344/profanity-filter-api/sso/index.html#") + .startsWith("http://localhost:5173/login#") .contains("provider=github") + .contains("providerUserId=12345") + .contains("providerLogin=hgkim") .contains("githubUserId=12345") .contains("githubLogin=hgkim") .contains("dashboardAccessToken=mock_dashboard_token_"); @@ -48,7 +53,7 @@ void onAuthenticationSuccess_whenGithubLoginSucceeded_redirectsToFrontendFragmen @DisplayName("GitHub 사용자 속성이 비어 있어도 null 없이 FE fragment로 redirect한다") void onAuthenticationSuccess_whenGithubAttributesAreMissing_redirectsWithEmptyStrings() throws Exception { - OAuth2LoginSuccessHandler successHandler = new OAuth2LoginSuccessHandler(); + OAuth2LoginSuccessHandler successHandler = new OAuth2LoginSuccessHandler(FRONTEND_PROPERTIES); OAuth2User oauth2User = new DefaultOAuth2User( List.of(new SimpleGrantedAuthority("ROLE_USER")), Map.of("id", 12345), "id"); @@ -60,8 +65,42 @@ void onAuthenticationSuccess_whenGithubAttributesAreMissing_redirectsWithEmptySt assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FOUND); assertThat(response.getRedirectedUrl()) + .contains("providerUserId=12345") + .contains("providerLogin=") .contains("githubUserId=12345") .contains("githubLogin=") .contains("dashboardAccessToken=mock_dashboard_token_"); } + + @Test + @DisplayName("Google 로그인 성공 시 mock dashboard token과 사용자 정보를 FE fragment로 redirect한다") + void onAuthenticationSuccess_whenGoogleLoginSucceeded_redirectsToFrontendFragment() + throws Exception { + OAuth2LoginSuccessHandler successHandler = new OAuth2LoginSuccessHandler(FRONTEND_PROPERTIES); + OAuth2User oauth2User = + new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_USER")), + Map.of( + "sub", "google-user-123", + "email", "hgkim@example.com", + "name", "HG Kim", + "picture", "https://example.com/profile.png"), + "sub"); + OAuth2AuthenticationToken authentication = + new OAuth2AuthenticationToken(oauth2User, oauth2User.getAuthorities(), "google"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + successHandler.onAuthenticationSuccess(new MockHttpServletRequest(), response, authentication); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FOUND); + assertThat(response.getRedirectedUrl()) + .startsWith("http://localhost:5173/login#") + .contains("provider=google") + .contains("providerUserId=google-user-123") + .contains("providerLogin=HG%20Kim") + .contains("providerEmail=hgkim%40example.com") + .contains("googleUserId=google-user-123") + .contains("googleEmail=hgkim%40example.com") + .contains("dashboardAccessToken=mock_dashboard_token_"); + } } diff --git a/ui/sample-app/src/App.tsx b/ui/sample-app/src/App.tsx index 15e9424..64c498f 100644 --- a/ui/sample-app/src/App.tsx +++ b/ui/sample-app/src/App.tsx @@ -4,6 +4,7 @@ import { getCurrentPagePath, type PagePath } from "./constants/pagePath"; import { OPENAPI_DOCUMENT_URL, OVERVIEW_MARKDOWN_PATH, FALLBACK_OVERVIEW_MARKDOWN } from "./docs/constants"; import { preloadDocsDocuments } from "./docs/hooks"; import { HomePage } from "./features/home/HomePage"; +import { LoginPage } from "./features/login/LoginPage"; import { RegisterPage } from "./features/register/RegisterPage"; import { Navigation } from "./components/Navigation"; @@ -57,6 +58,8 @@ export default function App() { ) : pagePath === "/register" ? ( + ) : pagePath === "/login" ? ( + ) : ( )} diff --git a/ui/sample-app/src/components/Navigation.tsx b/ui/sample-app/src/components/Navigation.tsx index 63be5d8..dc6326a 100644 --- a/ui/sample-app/src/components/Navigation.tsx +++ b/ui/sample-app/src/components/Navigation.tsx @@ -29,6 +29,16 @@ export function Navigation({ onNavigate, pagePath }: NavigationProps) { > 홈 + { + event.preventDefault(); + onNavigate("/login"); + }} + > + 로그인 + ; + +export function LoginPage() { + const [loginResult, setLoginResult] = useState(() => parseLoginResult()); + + useEffect(() => { + function syncLoginResult() { + setLoginResult(parseLoginResult()); + } + + window.addEventListener("hashchange", syncLoginResult); + return () => window.removeEventListener("hashchange", syncLoginResult); + }, []); + + const formattedResult = useMemo(() => { + if (!loginResult) { + return LOGIN_RESULT_EMPTY; + } + + return JSON.stringify(loginResult, null, 2); + }, [loginResult]); + + function startOAuthLogin(provider: LoginProvider) { + window.location.assign(`${API_BASE_URL}/oauth2/authorization/${provider}`); + } + + return ( +

+
+
+
+

OAuth 로그인

+

소셜 계정으로 로그인

+
+ +
+ + +
+
+ + +
+
+ ); +} + +function parseLoginResult(): OAuthLoginResult | null { + const hash = window.location.hash.replace(/^#/, ""); + + if (!hash) { + return null; + } + + const params = new URLSearchParams(hash); + const result = Object.fromEntries(params.entries()); + + return Object.keys(result).length > 0 ? result : null; +} + +function GitHubIcon() { + return ( + + ); +} + +function GoogleIcon() { + return ( + + ); +} diff --git a/ui/sample-app/src/styles.css b/ui/sample-app/src/styles.css index 48c1694..68b2d63 100644 --- a/ui/sample-app/src/styles.css +++ b/ui/sample-app/src/styles.css @@ -1441,6 +1441,135 @@ button { border-color: color-mix(in srgb, var(--ivory) 28%, transparent); } +.login-page { + min-height: 100dvh; + display: grid; + place-items: center; + padding: calc(var(--landing-nav-height) + 40px) var(--landing-letterbox) 64px; + background: + linear-gradient(90deg, color-mix(in srgb, var(--pine) 5%, transparent) 1px, transparent 1px), + linear-gradient(180deg, color-mix(in srgb, var(--pine) 5%, transparent) 1px, transparent 1px), + var(--ivory); + background-size: 42px 42px; + color: var(--pine); +} + +.login-panel { + width: min(920px, 100%); + display: grid; + grid-template-columns: minmax(260px, 0.82fr) minmax(0, 1.18fr); + border: 1px solid var(--pine); + border-radius: 8px; + background: var(--ivory); + box-shadow: 18px 18px 0 color-mix(in srgb, var(--sage) 46%, transparent); +} + +.login-actions, +.login-result-panel { + min-width: 0; + word-break: keep-all; + overflow-wrap: break-word; +} + +.login-actions { + display: grid; + align-content: space-between; + gap: 40px; + padding: clamp(22px, 3vw, 36px); +} + +.login-kicker { + margin: 0 0 10px; + color: color-mix(in srgb, var(--pine) 62%, var(--ivory)); + font-size: 13px; + font-weight: 900; +} + +.login-actions h1 { + margin: 0; + max-width: 9em; + font-size: clamp(30px, 3.6vw, 48px); + line-height: 1.08; + letter-spacing: 0; + text-wrap: balance; +} + +.login-provider-list { + display: grid; + gap: 10px; +} + +.login-provider-button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 14px; + min-height: 58px; + border: 1px solid var(--pine); + border-radius: 8px; + background: var(--ivory); + padding: 0 18px; + color: var(--pine); + font-size: 16px; + font-weight: 900; +} + +.login-provider-button:hover, +.login-provider-button:focus-visible { + background: var(--sage); + outline: 0; +} + +.login-provider-icon { + width: 24px; + height: 24px; + flex: 0 0 auto; +} + +.login-result-panel { + display: grid; + grid-template-rows: auto minmax(280px, 1fr); + gap: 18px; + border-left: 1px solid var(--pine); + background: var(--pine); + padding: clamp(22px, 3vw, 36px); + color: var(--ivory); +} + +.login-result-panel > span { + width: max-content; + border: 1px solid color-mix(in srgb, var(--ivory) 34%, transparent); + border-radius: 999px; + padding: 7px 10px; + color: color-mix(in srgb, var(--ivory) 78%, transparent); + font-size: 13px; + font-weight: 850; +} + +.login-result-panel pre { + min-width: 0; + max-width: 100%; + overflow: auto; + margin: 0; + border: 1px solid color-mix(in srgb, var(--ivory) 24%, transparent); + border-radius: 8px; + background: color-mix(in srgb, black 20%, var(--pine)); + padding: 18px; + color: var(--sage); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.login-result-panel pre[data-empty="true"] { + display: grid; + place-items: center; + color: color-mix(in srgb, var(--ivory) 62%, transparent); + text-align: center; +} + .footer-cta { display: grid; justify-items: center; @@ -1552,6 +1681,15 @@ button { grid-template-columns: 1fr; } + .login-panel { + grid-template-columns: 1fr; + } + + .login-result-panel { + border-top: 1px solid var(--pine); + border-left: 0; + } + .register-result { min-height: 240px; border-top: 1px solid var(--pine); From 4982c118dd1843ea9000cb6a7c3cef4b857ecb99 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Fri, 3 Jul 2026 17:20:16 +0900 Subject: [PATCH 6/7] feat: remove unused environment variables and test assertions eliminated redundant oauth-related variables from the .env file and removed obsolete test assertions for unused database tables. these changes streamline the configuration and improve test clarity. --- module.secrets | 2 +- .../app/test/support/container/MySqlTestContainerSmokeTest.java | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/module.secrets b/module.secrets index df88b33..5934a0a 160000 --- a/module.secrets +++ b/module.secrets @@ -1 +1 @@ -Subproject commit df88b333bd3f97a99f8d2d9579b1864fbdfd113e +Subproject commit 5934a0af1ee195dad8f64ff5aec3534fb499a6f2 diff --git a/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java b/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java index ec66662..a5fc410 100644 --- a/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java +++ b/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java @@ -28,8 +28,6 @@ void mysqlContainer_whenMigrateAndSeed_succeeds() throws SQLException { assertThat(count(dataSource, "clients")).isEqualTo(2); assertThat(count(dataSource, "profanity_word")).isEqualTo(3); - assertThat(count(dataSource, "users")).isZero(); - assertThat(count(dataSource, "oauth_accounts")).isZero(); execute( dataSource, From fedacf94de5b0d2cdd7c49b7581278fc57ad4aa6 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Fri, 3 Jul 2026 17:52:00 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=ED=99=98=EA=B2=BD=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EA=B4=80=EB=A6=AC=20skill=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/skills/manage-env-secrets/SKILL.md | 112 ++++++++++++++++++ .../manage-env-secrets/agents/openai.yaml | 4 + 2 files changed, 116 insertions(+) create mode 100644 .codex/skills/manage-env-secrets/SKILL.md create mode 100644 .codex/skills/manage-env-secrets/agents/openai.yaml diff --git a/.codex/skills/manage-env-secrets/SKILL.md b/.codex/skills/manage-env-secrets/SKILL.md new file mode 100644 index 0000000..67616dc --- /dev/null +++ b/.codex/skills/manage-env-secrets/SKILL.md @@ -0,0 +1,112 @@ +--- +name: manage-env-secrets +description: Manage this profanity-filter-api project's environment variables and secret stores. Use when adding, removing, auditing, syncing, or troubleshooting env vars in local development .env files, the module.secrets submodule, 1Password items, ExternalSecret-backed k3s deployment, OAuth/SSO credentials, DB/Redis credentials, mail credentials, server keys, or any task involving secret-source/database-source/op:// references. +--- + +# Manage Env Secrets + +## Operating Rule + +Treat every env value as secret unless proven otherwise. Do not print, quote, summarize, paste, log, commit, or include raw values in final answers. Report only key names, counts, file paths, item names, and verification status. + +If a command may print values, either do not run it or redirect stdout away from the conversation and verify with a separate key-name-only command. This matters because `op item edit` default output can reveal non-concealed field values. + +## Source Map + +Local development source: + +- `module.secrets/project/profanity-filter/backend/.env` +- Use this when the user asks to prepare or adjust local/submodule env values. +- This is a secrets submodule, but values are still not safe to display. + +k3s deployment source: + +- Vault: `profanity-filter` +- 1Password item: `secret-source` +- 1Password item: `database-source` +- `deploy/overlays/production/external-secret.yaml` extracts both items into the `profanity-secrets` Kubernetes Secret. + +Current item ownership: + +- `database-source`: `DB_DRIVER`, `DB_PASSWORD`, `DB_URL`, `DB_USERNAME`, `REDIS_MAIN_HOST`, `REDIS_MAIN_PASSWORD`, `REDIS_MAIN_PORT` +- `secret-source`: application/general secrets such as `MAIL_USERNAME`, `MAIL_PASSWORD`, `SERVER_PORT`, `SERVER_KEYCODE`, `SERVER_KEYCODE_ALGORITHM`, `ACTUATOR_PATH`, OAuth/SSO fields + +Do not duplicate DB/Redis fields into `secret-source` while `database-source` owns them. + +## Safe Workflow + +1. Inspect state without values. + +Use key-name-only commands: + +```bash +sed -n 's/^\([A-Za-z0-9_][A-Za-z0-9_]*\)=.*/\1/p' module.secrets/project/profanity-filter/backend/.env | sort +op item get secret-source --vault profanity-filter --format json | ruby -rjson -e 'item=JSON.parse(STDIN.read); (item["fields"] || []).map { |f| f["label"] }.compact.sort.each { |label| puts label }' +op item get database-source --vault profanity-filter --format json | ruby -rjson -e 'item=JSON.parse(STDIN.read); (item["fields"] || []).map { |f| f["label"] }.compact.sort.each { |label| puts label }' +``` + +2. Classify each requested key. + +- DB/Redis -> `database-source` +- OAuth/SSO -> `secret-source` +- Mail/server/app control -> `secret-source` +- Unknown key -> inspect deployment/config references before choosing an item + +3. Apply only the needed delta. + +- Add missing keys. +- Update changed keys. +- Remove stale keys only when the user asked or the owning manifest/config no longer references them. +- Keep database and app items separated. + +4. Verify without values. + +After modification, query field labels only and report: + +- target item +- added/updated/removed key names +- missing keys, if any +- no raw values + +## 1Password Update Rules + +Prefer JSON template or piped input over assignment statements. `op item edit secret-source KEY=value` is unsafe because process arguments can expose values. + +When using `op item edit`, do not allow default output into the chat. Redirect stdout to `/dev/null`, then verify labels separately: + +```bash +op item get secret-source --vault profanity-filter --format json \ + | ruby -rjson -e '...build updated JSON without printing values...' \ + | op item edit secret-source --vault profanity-filter >/dev/null +``` + +Then verify: + +```bash +op item get secret-source --vault profanity-filter --format json \ + | ruby -rjson -e 'item=JSON.parse(STDIN.read); (item["fields"] || []).map { |f| f["label"] }.compact.sort.each { |label| puts label }' +``` + +Use `CONCEALED` type for keys containing `PASSWORD`, `SECRET`, `TOKEN`, `KEY`, or `SIGNING_KEY` unless an existing field type is intentionally different. + +Never use `--reveal` unless the user explicitly asks and the task cannot be done otherwise. Even then, avoid printing the value. + +## Local .env Rules + +When editing `module.secrets/project/profanity-filter/backend/.env`: + +- Preserve existing unrelated lines and ordering where reasonable. +- Add new keys as `KEY=value`. +- Do not use root project `.env` or `sso/sso.env` as durable sources. +- Do not stage or commit ignored local scratch files. +- If committing the submodule, commit inside `module.secrets` first, then update the parent repo submodule pointer only if the user asks. + +## Reporting + +Keep reports short and factual: + +- "Added 13 OAuth/SSO fields to `secret-source`; missing=none." +- "Skipped DB/Redis fields because `database-source` already owns them." +- "Did not print or reveal secret values." + +Do not include command output that contains values. diff --git a/.codex/skills/manage-env-secrets/agents/openai.yaml b/.codex/skills/manage-env-secrets/agents/openai.yaml new file mode 100644 index 0000000..3d2b144 --- /dev/null +++ b/.codex/skills/manage-env-secrets/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Env Secret Manager" + short_description: "프로젝트 환경 변수와 1Password secret field를 안전하게 관리합니다." + default_prompt: "이 프로젝트의 로컬 .env, module.secrets, 1Password secret-source/database-source 환경 변수를 노출 없이 추가, 제거, 수정해줘."