Skip to content

feat(app): GCP OAuth 동의 화면 검증을 위한 랜딩 페이지 및 사이트 인증 추가#783

Open
jk-kim0 wants to merge 4 commits intodevelopfrom
jk/feat-landing-page
Open

feat(app): GCP OAuth 동의 화면 검증을 위한 랜딩 페이지 및 사이트 인증 추가#783
jk-kim0 wants to merge 4 commits intodevelopfrom
jk/feat-landing-page

Conversation

@jk-kim0
Copy link
Copy Markdown
Collaborator

@jk-kim0 jk-kim0 commented Apr 3, 2026

Description

  • GCP OAuth 동의 화면 검증 3가지 요건을 충족하기 위한 변경입니다.
    1. 홈페이지 200 OK: / 경로에 공개 랜딩 페이지 추가 (기존 /login 리다이렉트 → 랜딩 페이지)
    2. 개인정보 처리방침 링크: 랜딩 페이지 하단에 Privacy Policy 링크 포함
    3. 앱 이름 일치: 랜딩 페이지·About 페이지 h1에 시스템 설정의 브랜드 이름 표시

프론트엔드

  • LandingPage 컴포넌트: SaaS 스타일 랜딩 (Sign In/Sign Up 버튼, 브랜드명 h1, 서비스 소개, 법적 링크)
  • 인증된 사용자 → /dashboard 자동 리다이렉트
  • AboutPage h1: 하드코딩 i18n → usePublicAuthConfig().brandName (동적)
  • i18n 키 추가 (en/ko/ja)

백엔드

  • OAuthProviderConfig.GoogleWebsiteVerification 데이터 클래스 추가 (meta/HTML 검증 토큰)
  • GoogleSiteVerificationController: GET /{filename:google[a-z0-9]+\.html} 공개 엔드포인트 (BrandingController에서 분리)
  • OAuthProviderQuerygetGoogleSiteVerification() 메서드 추가
  • 검증 데이터는 oauth_providers.config JSON에 저장 (별도 컬럼/migration 없음)

참고

  • 프로덕션 환경에서 /google*.html 패턴만 백엔드로 프록시하도록 nginx 설정 필요
  • GCP 콘솔에서 홈페이지 URL을 실제 앱 URL로 변경 필요 (수동)

Added/updated tests?

  • Yes
    • app.test.tsx 업데이트 (AppShell 테스트 경로를 /dashboard로 변경)
    • GoogleSiteVerificationControllerTest 신규 추가

Additional notes

  • 시스템 설정 웹콘솔 UI에 Google Site Verification 토큰 입력 필드는 후속 PR로 추가 예정
  • metaVerificationToken 필드도 구조에 포함하여 후속 meta tag verification 지원 가능

- / 경로에 공개 랜딩 페이지 추가 (Sign In/Sign Up 버튼, 브랜드명 h1, 개인정보 처리방침 링크)
- /about 페이지 h1을 동적 브랜드 이름으로 변경 (usePublicAuthConfig)
- Google Site Verification HTML 파일 확인 방식 지원 (BrandingController)
- system_settings에 google_site_verification 컬럼 추가 (Flyway)
- 시스템 설정 일반 수정 API에 googleSiteVerification 필드 추가
- i18n: 랜딩 페이지 키 추가 (en/ko/ja)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gemini-code-assist
Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@jk-kim0 jk-kim0 requested a review from keIIy-kim as a code owner April 3, 2026 15:15
@jk-kim0 jk-kim0 self-assigned this Apr 3, 2026
@keIIy-kim
Copy link
Copy Markdown
Collaborator

keIIy-kim commented Apr 3, 2026

좋은 방향으로 보입니다. 다만 코드 레벨에서는 몇 가지를 같이 보면 더 좋을 것 같습니다.

큰 흐름 자체는 이해되고, 특히 Google verification file을 public host에서 응답하게 만드는 방향은 타당해 보입니다. 다만 지금 구현은 google_site_verification의 책임이 조금 섞여 보여서, 장기적으로는 아래처럼 나눠볼 여지도 있어 보입니다.

핵심은 이 정도로 이해했습니다.

  • HTML file verification은 controller에서 처리
  • meta tag verificationindex.html 주입으로 처리
  • 설정의 source of truth는 가능하면 SystemSetting보다 OAuthProviderConfig.Google 쪽에 두는 방향도 검토 가능
  • BrandingController에는 branding concern만 남기는 쪽도 고려 가능

예를 들면 저장 위치는 OAuthProviderConfig.Google을 확장하는 식으로 볼 수도 있을 것 같습니다.

sealed class OAuthProviderConfig {
    data class Google(
        val websiteVerification: WebsiteVerification? = null,
    ) : OAuthProviderConfig() {
        data class WebsiteVerification(
            val metaVerificationToken: String? = null,
            val htmlVerificationFileName: String? = null,
        )
    }
}

이렇게 두면:

  • htmlVerificationFileContent는 별도 저장 없이
  • HTML file body는 서버가 계산해서 응답
  • 설정 의미도 Google OAuth 쪽에 더 가깝게 모일 수 있어 보입니다.

HTML file verification 응답도 현재처럼 root path 공개 응답이 필요한 점은 맞고, 다만 BrandingController보다는 별도 controller로 분리하는 쪽도 장기적으로는 더 읽기 좋을 수 있어 보입니다. 예를 들면:

@RestController
@PreAuthorize("permitAll()")
class GoogleSiteVerificationController(
    private val oauthProviderQuery: OAuthProviderQuery,
) {
    @GetMapping("/{filename:google[a-z0-9]+\\.html}")
    fun verify(
        @PathVariable filename: String,
    ): ResponseEntity<String> {
        val snapshot = oauthProviderQuery.getGoogleSiteVerification()
            ?: return ResponseEntity.notFound().build()

        val expected = snapshot.htmlVerificationFileName
            ?: return ResponseEntity.notFound().build()

        if (filename != expected) {
            return ResponseEntity.notFound().build()
        }

        return ResponseEntity
            .ok()
            .contentType(MediaType.TEXT_PLAIN)
            .body("google-site-verification: $expected")
    }
}

이 예시에서 특히 좋다고 느낀 부분은:

  • body를 DB에 그대로 들고 가지 않고 서버가 계산하는 점
  • 설정된 filename과 정확히 일치할 때만 응답하는 점
  • text/plainno-store 같은 운영 디테일을 명확히 가져갈 수 있는 점입니다.

meta tag verification이 필요해지면, 그때는 controller가 아니라 index.html 응답에 넣는 방식이 자연스러워 보여서, 예를 들면:

  • SpaStaticResourceConfig
  • IndexHtmlHeadTransformer
  • GoogleSiteVerificationHeadTagContributor
    같은 쪽으로 확장할 수도 있어 보입니다.

즉, 이번 PR에서는 현재 구조로 가더라도 괜찮아 보이지만,

  • verification body는 서버 계산
  • filename exact match
  • text/plain
  • no-store
    같은 보강은 있으면 더 좋겠고,
    장기적으로는 verification 설정을 Google OAuth 설정 쪽과 더 가깝게 두는 방향도 검토해볼 수 있을 것 같습니다.

jk-kim0 and others added 2 commits April 4, 2026 00:37
- isu_001 (backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt:45): Google 파일 검증은 반환된 토큰 문자열 전체를 파일명으로 그대로 써야 하는데, 여기서는 저장값 앞뒤에 google 과 .html 을 다시 붙여 기대 파일명을 만듭니다. 그래서 운영자가 Google이 준 값(google12cfc68677988bb4.html 형태)을 그대로 저장하면 실제 요청 URL은 항상 404가 나고 사이트 검증이 실패합니다.
- isu_002 (frontend/app/src/pages/landing/landing.page.tsx:55): 랜딩의 회원가입 버튼이 /invite 로 연결되는데, 이 페이지는 token 쿼리가 없으면 즉시 Invalid invitation link. 오류 화면으로 떨어집니다. 신규 사용자를 위한 CTA가 실제 가입 흐름이 아니라 깨진 초대 수락 페이지로 연결되어 첫 진입 경험이 막힙니다.
- isu_003 (backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt:118): update() 메서드에서 googleSiteVerification 파라미터의 기본값이 null이고, null이 전달되면 기존 값을 무조건 덮어씁니다. 현재 프론트엔드의 일반 설정 수정 폼에는 googleSiteVerification 필드가 없으므로(후속 PR 예정), 관리자가 브랜드명이나 연락처 이메일만 수정해도 요청 본문에 googleSiteVerification이 포함되지 않아 Jackson이 null로 역직렬화하고, 저장된 Google Site Verification 토큰이 삭제됩니다.
- SystemSettingEntity에서 googleSiteVerification 필드를 제거합니다
- OAuthProviderConfig.Google에 WebsiteVerification 데이터 클래스를 추가합니다
- BrandingController에서 분리하여 GoogleSiteVerificationController를 생성합니다
- OAuthProviderQuery에 getGoogleSiteVerification() 메서드를 추가합니다
- system_settings 테이블의 컬럼 migration을 삭제합니다 (oauth_providers.config JSON 활용)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jk-kim0 jk-kim0 changed the title feat: GCP OAuth 동의 화면 검증을 위한 랜딩 페이지 및 사이트 인증 추가 feat(app): GCP OAuth 동의 화면 검증을 위한 랜딩 페이지 및 사이트 인증 추가 Apr 3, 2026
- isu_005 (backend/iam/src/main/kotlin/io/deck/iam/controller/OAuthProviderDtos.kt:61): Google 업데이트 요청을 항상 OAuthProviderConfig.Google()로 변환하면, 인증 설정 저장 시 기존 websiteVerification 값이 매번 빈 객체로 덮어써집니다. 프론트의 설정 화면은 enabled/clientId/clientSecret/domain만 PUT하므로 auth 설정을 한 번만 저장해도 저장돼 있던 htmlVerificationFileName이 삭제되고, /google*.html 검증 엔드포인트가 404로 바뀝니다.
@jk-kim0
Copy link
Copy Markdown
Collaborator Author

jk-kim0 commented Apr 3, 2026

[debate-review][sha:ee990de93c82697ebf4669b835ee0fa0b58c5f6a] Consensus reached after 7 rounds.

Debate Summary

  • isu_001 [withdrawn] BrandingController의 site verification 로직 리팩토링으로 해결
  • isu_002 [withdrawn] 랜딩 페이지 Sign Up 링크 수정
  • isu_003 [withdrawn] SystemSettingEntity에서 필드 제거로 데이터 손실 위험 해소
  • isu_004 [withdrawn] CI 실패는 인프라 이슈
  • isu_005 [accepted] OAuthProviderDtos.toConfig()에서 Google config 업데이트 시 기존 websiteVerification 데이터 보존
  • isu_006 [withdrawn] 여기서는 /auth/me가 성공하면 항상 /dashboard로 보내는데, 같은 세션 체크를 사용하는 기존 checkAuth()와 로그인 bootstrap은 passwordMustChange를 별도로 처리해 /auth/password-change로 리다이렉트합니다. /auth/me는 비밀번호 변경이 필요한 사용자에게도 200을 돌려주므로, 이제 루트(/)로 들어온 해당 사용자는 강제 비밀번호 변경 화면 대신 대시보드 진입 흐름으로 잘못 보내집니다.

Applied Fixes

  • backend/iam/src/main/kotlin/io/deck/iam/controller/OAuthProviderDtos.kt:61 - (reported by codex, applied by codex) Google 업데이트 요청을 항상 OAuthProviderConfig.Google()로 변환하면, 인증 설정 저장 시 기존 websiteVerification 값이 매번 빈 객체로 덮어써집니다. 프론트의 설정 화면은 enabled/clientId/clientSecret/domain만 PUT하므로 auth 설정을 한 번만 저장해도 저장돼 있던 htmlVerificationFileName이 삭제되고, /google*.html 검증 엔드포인트가 404로 바뀝니다.

Withdrawn Findings

  • backend/iam/src/main/kotlin/io/deck/iam/controller/BrandingController.kt:45 - Google 파일 검증은 반환된 토큰 문자열 전체를 파일명으로 그대로 써야 하는데, 여기서는 저장값 앞뒤에 google 과 .html 을 다시 붙여 기대 파일명을 만듭니다. 그래서 운영자가 Google이 준 값(google12cfc68677988bb4.html 형태)을 그대로 저장하면 실제 요청 URL은 항상 404가 나고 사이트 검증이 실패합니다.
    Reason: BrandingController에서 siteVerification 엔드포인트가 제거되고 GoogleSiteVerificationController로 대체. prefix/suffix 이중 부착 문제 해소.
  • frontend/app/src/pages/landing/landing.page.tsx:55 - 랜딩의 회원가입 버튼이 /invite 로 연결되는데, 이 페이지는 token 쿼리가 없으면 즉시 Invalid invitation link. 오류 화면으로 떨어집니다. 신규 사용자를 위한 CTA가 실제 가입 흐름이 아니라 깨진 초대 수락 페이지로 연결되어 첫 진입 경험이 막힙니다.
    Reason: Sign Up 버튼 href가 /invite에서 /login으로 변경 완료.
  • backend/iam/src/main/kotlin/io/deck/iam/domain/SystemSettingEntity.kt:118 - update() 메서드에서 googleSiteVerification 파라미터의 기본값이 null이고, null이 전달되면 기존 값을 무조건 덮어씁니다. 현재 프론트엔드의 일반 설정 수정 폼에는 googleSiteVerification 필드가 없으므로(후속 PR 예정), 관리자가 브랜드명이나 연락처 이메일만 수정해도 요청 본문에 googleSiteVerification이 포함되지 않아 Jackson이 null로 역직렬화하고, 저장된 Google Site Verification 토큰이 삭제됩니다.
    Reason: googleSiteVerification 필드가 SystemSettingEntity에서 제거되고 OAuthProviderConfig.Google.WebsiteVerification으로 이동. null 덮어쓰기 문제 해소.
  • frontend/app/src/app/App.tsx:37 - CI 파이프라인에서 frontend (deskpie) 빌드가 Set up job 단계에서 실패하여 ci-gate, e2e-ready도 실패 상태입니다. 로그 분석 결과 인프라(runner) 문제로 PR 코드와 직접 관련은 없으나, 머지 전 CI 재실행으로 전체 통과 확인이 필요합니다.
    Reason: CI는 현재 pending 상태이며, 이전 실패는 인프라 문제로 PR 코드와 무관.
  • frontend/app/src/pages/landing/landing.page.tsx:27 - 여기서는 /auth/me가 성공하면 항상 /dashboard로 보내는데, 같은 세션 체크를 사용하는 기존 checkAuth()와 로그인 bootstrap은 passwordMustChange를 별도로 처리해 /auth/password-change로 리다이렉트합니다. /auth/me는 비밀번호 변경이 필요한 사용자에게도 200을 돌려주므로, 이제 루트(/)로 들어온 해당 사용자는 강제 비밀번호 변경 화면 대신 대시보드 진입 흐름으로 잘못 보내집니다.
    Reason: CC의 반박 수락 — /dashboard 진입 시 AppShell의 checkAuth()가 passwordMustChange를 처리하므로 기능적 버그 아님

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants