diff --git a/.env.example b/.env.example index f105000..a3b2fce 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,14 @@ KAKAO_REST_API_KEY= # 미발급. 없으면 네이버 지역검색을 GEMINI_API_KEY= TOUR_API_KEY= # 디코딩된 키(인코딩 키는 이중 인코딩된다) +# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다). +# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션) +# "승인된 JavaScript 원본" 에 화면 주소를 등록해야 브라우저에서 토큰이 나온다(리디렉션 URI 는 필요 없다). +# ★ 백엔드(aud 대조)와 프론트(버튼)가 **같은 값**을 써야 한다 — compose 가 이 하나를 +# VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다. +# ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site +GOOGLE_CLIENT_ID= + # CORS 허용 오리진. 쉼표로 여럿. # 서버에 올리면 반드시 적는다. 안 적으면 화면은 뜨고 API 만 막힌다. # CLIENT_URL=http://172.30.1.36:30031,http://localhost:3002 diff --git a/AGENTS.md b/AGENTS.md index b9b2a81..87b3819 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,10 @@ `site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부 이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더 재실행만으로는 안 되고 **백엔드에서 재발행**해 payload 를 다시 만들어야 한다. +- **`GOOGLE_CLIENT_ID` 도 두 곳에 있고 같아야 한다.** 백엔드(`GOOGLE_CLIENT_ID`) ↔ 프론트 + (`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의 + 수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.** + 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다). - **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데 블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는 CDN 을 앞에 세워야 한다. 아니면 비워라. diff --git a/admin/frontend/src/app/router.tsx b/admin/frontend/src/app/router.tsx index 9185007..123ccec 100644 --- a/admin/frontend/src/app/router.tsx +++ b/admin/frontend/src/app/router.tsx @@ -23,7 +23,8 @@ const ADMIN_NAV: NavItem[] = [ * 그 예외가 기본값이 된다. 사장님 앱과 앱을 가른 이유가 이 규칙을 지키기 위해서다. */ export const router = createBrowserRouter([ - {path: '/login', element: }, + // selfServe=false: 내부 운영 계정은 우리가 만들어 준다 — 가입 링크도 구글 로그인도 두지 않는다. + {path: '/login', element: }, {path: '/', element: }, diff --git a/docker-compose.yml b/docker-compose.yml index 55824d6..212dea1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,9 @@ x-common-env: &common-env DB_NAME: ${DB_NAME:-web4ai_db} JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-} + # ★ 프론트(VITE_GOOGLE_CLIENT_ID)와 같은 값이어야 한다 — 백엔드는 이 값으로 구글 토큰의 + # 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} # ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다. SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} SITE_PAYLOAD_DIR: /app/out/payloads @@ -190,6 +193,8 @@ services: VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000} VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-} VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-} + # 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처). + VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} volumes: - ./package.json:/app/package.json - ./package-lock.json:/app/package-lock.json @@ -259,6 +264,8 @@ services: # ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다. VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-} VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-} + # 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다. + VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} image: o2o-web4ai-solution-site container_name: o2o-web4ai-solution-site volumes: diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 9e57729..cd6d4a5 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -65,6 +65,17 @@ | 코드 격리 | `site` 에 발행 상태를 두고, 해지 처리는 **삭제가 아니라 상태 전이**로만 구현한다. 물리 삭제 경로를 만들지 않는다 | | 반영됨 (2026-08-26) | `SiteStatus.SUSPENDED`(해지 유예 — 페이지 살아 있음) / `UNPUBLISHED`(내림)를 분리했다. `PublishAction.SUSPEND`·`RESUME` 으로 `publish_logs` 에 남는다. 유예 기간 길이만 정하면 된다 | +### 1-5. 계정 연결 — 같은 사람의 id/pw 계정과 구글 계정을 이을 것인가 + +| 항목 | 내용 | +|---|---| +| 상태 | **미결** (2026-09-02 구글 로그인 붙이면서 생김) | +| 필요한 결론 | 이미 id/pw 로 가입한 사람이 같은 이메일의 구글로 로그인했을 때, 같은 계정으로 이을 것인가. 이으려면 **먼저 가입한 쪽의 소유 증명**(비밀번호 재입력 또는 이메일 인증)을 어디에 둘 것인가 | +| 왜 지금 안 푸나 | 이메일만 보고 자동으로 이으면 **계정 선점**이 된다 — 공격자가 남의 이메일로 id/pw 계정을 먼저 만들어 두면, 그 사람이 구글로 로그인하는 순간 공격자가 비밀번호를 아는 계정 안으로 들어간다. 소유 증명 절차 없이 열 수 있는 문이 아니다 | +| 코드 격리 | `company.users.provider`(AuthProvider) 로 계정마다 수단을 하나만 둔다. 이메일이 이미 쓰이고 있으면 **잇지도 만들지도 않고** `ACCOUNT_PROVIDER_CONFLICT` 로 거절하고, 화면은 "처음 가입할 때 쓴 방법으로 로그인" 을 안내한다. 반대 방향(구글 계정에 비밀번호 설정)도 `update_me` 에서 같은 코드로 막는다 | +| 결론이 "잇는다" 일 때 | `provider`·`provider_uid` 를 users 에서 별도 테이블(`user_identities`)로 빼고, 계정 하나에 수단 여러 개를 매단다. 지금 구조가 그 이행을 막지 않는다 | +| 확정 사항 | **구글 ID 토큰의 `aud`(우리 client_id)와 `email_verified` 검증은 결론과 무관하게 필수다.** `tests/test_google_identity.py` 가 이 둘을 고정한다 | + --- ## 2. 이식하면서 내린 결정 (2026-08-26) diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 880f6da..f9466e0 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -104,6 +104,19 @@ docker compose exec solution-worker python scripts/republish_all.py 프리렌더만 다시 돌리면 옛 주소가 그대로 나온다. 반드시 **백엔드에서 재발행**해 payload 를 다시 만들어야 한다. +### 0단계-b — 구글 로그인도 주소가 정해져야 켜진다 + +`GOOGLE_CLIENT_ID` 하나를 루트 `.env` 에 적으면 compose 가 백엔드와 프론트 +(`VITE_GOOGLE_CLIENT_ID`) 양쪽에 흘려보낸다. **두 곳에 따로 적지 않는다.** + +- Google Cloud Console > 사용자 인증 정보 > **OAuth 2.0 클라이언트 ID(웹 애플리케이션)** +- **승인된 JavaScript 원본**에 화면을 여는 주소를 그대로 넣는다(포트까지). 리디렉션 URI 는 + 쓰지 않는다 — 브라우저가 ID 토큰을 바로 받는 방식(GIS)이다. +- 주소가 바뀌면 원본 목록도 같이 고친다. 안 고치면 **버튼은 뜨는데 눌러도 아무 일이 없다.** +- 비워 두면 구글 로그인만 꺼진다(버튼 자체가 안 뜬다). id/pw 로그인·가입은 그대로 된다. + +★ `VITE_*` 라서 **번들에 구워진다** — 값을 넣거나 바꾸면 `./deploy.sh solution-site` 로 다시 굽는다. + ### 1단계 — 서버에서 "굽기"만 재현 (Azure 끔) 배포 대상 서버(접속·경로·이미 물린 포트)는 [SERVERS.md](SERVERS.md) 가 단일 출처다. ```bash diff --git a/nginx/Dockerfile b/nginx/Dockerfile index bb0b19a..f370b72 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -31,11 +31,15 @@ ARG VITE_SITE_PREVIEW_URL # 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession). ARG VITE_AUTO_LOGIN_ID ARG VITE_AUTO_LOGIN_PW +# 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와 +# 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다). +ARG VITE_GOOGLE_CLIENT_ID ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \ VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \ VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \ VITE_AUTO_LOGIN_ID=$VITE_AUTO_LOGIN_ID \ - VITE_AUTO_LOGIN_PW=$VITE_AUTO_LOGIN_PW + VITE_AUTO_LOGIN_PW=$VITE_AUTO_LOGIN_PW \ + VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID RUN npm run build -w @o2o/frontend FROM nginx:alpine diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 4d08ef1..6545250 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -68,14 +68,16 @@ CREATE TABLE IF NOT EXISTS company.companies ( CREATE TABLE IF NOT EXISTS company.users ( user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK) company_id uuid NOT NULL, -- 소속 회사(company.companies.company_id) - id VARCHAR(20) NOT NULL, -- 로그인 ID - password VARCHAR(255) NOT NULL, -- 해시된 비밀번호이어야 함 + id VARCHAR(64) NOT NULL, -- 로그인 ID (구글 계정은 google_) + password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL name VARCHAR(50) NULL, -- 이름 email VARCHAR(255) NULL, -- 이메일 contact_number VARCHAR(20) NULL, -- 연락처 last_accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 마지막 접속 시각 status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성) role SMALLINT NOT NULL DEFAULT 1, -- 권한(UserRole): 1=user(일반), 2=owner(최고관리자), 3=developer(내부 운영) + provider SMALLINT NOT NULL DEFAULT 1, -- 로그인 수단(AuthProvider): 1=local(id/pw), 2=google + provider_uid VARCHAR(255) NULL, -- 구글 sub — 이메일이 바뀌어도 유지되는 유일 키 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -356,6 +358,8 @@ CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id); -- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다. CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE; +-- 같은 구글 계정으로 두 번 가입되지 않게. provider 를 키에 넣어 수단이 늘어도 이 인덱스가 그대로 쓰인다. +CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid) WHERE deleted = FALSE AND provider_uid IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE; @@ -430,3 +434,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_dedupe_active ON job.jobs (dedupe_key) ALTER TABLE place.places ADD COLUMN IF NOT EXISTS content_updated_at TIMESTAMPTZ NULL, -- 2026-08-27 노출값 변경 시각(개별 재빌드 판별) ADD COLUMN IF NOT EXISTS external_source SMALLINT NULL; -- 2026-08-27 검증 소스(kakao/naver) + +-- 2026-09-02 구글 로그인. 소셜 계정은 비밀번호가 없고(NULL), 로그인 아이디가 google_ 라 20자를 넘는다. +ALTER TABLE company.users + ADD COLUMN IF NOT EXISTS provider SMALLINT NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS provider_uid VARCHAR(255) NULL; +ALTER TABLE company.users ALTER COLUMN id TYPE VARCHAR(64); +ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL; diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index fdb2c8c..7663ef7 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -6,6 +6,7 @@ from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.sql import text from common.enums import ( + AuthProvider, DBType, UserStatus, UserRole, @@ -68,14 +69,22 @@ class users(MainTableMixin, MAIN_BASE): user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) company_id = Column(UUID(as_uuid=True), nullable=False, index=True) - id = Column(String(20), nullable=False, unique=True, index=True) # 로그인 아이디 - password = Column(String(255), nullable=False) # bcrypt 해시 (ERD VARCHAR(30)→255 확장) + # 20자였다. 구글 계정의 로그인 아이디를 `google_`(최대 28자)로 만들면서 넓혔다 — + # sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다. + id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디 + # 소셜 계정은 비밀번호가 없다(NULL). 더미 해시를 넣으면 "비번이 있는 계정" 처럼 보여 + # id/pw 로그인 경로가 그 계정을 상대로 계속 시도된다. + password = Column(String(255), nullable=True) # bcrypt 해시 (ERD VARCHAR(30)→255 확장) name = Column(String(50), nullable=True) email = Column(String(255), nullable=True) contact_number = Column(String(20), nullable=True) last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value) role = Column(SmallInteger, nullable=False, default=UserRole.USER.value) + # server_default 를 함께 준다 — ORM default 는 raw INSERT(테스트 시드·수동 SQL)에 안 먹어서 + # 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값). + provider = Column(SmallInteger, nullable=False, server_default=text("1"), default=AuthProvider.LOCAL.value) + provider_uid = Column(String(255), nullable=True) # 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키 # ============================================================ diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index 6da569e..08ae772 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -52,6 +52,9 @@ class ErrorType(Enum): ACCOUNT_BLOCKED_USER = auto() ACCOUNT_NOT_FOUND = auto() ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도 + ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절) + OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다 + OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패 # 사업장(places) 관련 에러 PLACE_NOT_FOUND = 1200 @@ -153,6 +156,17 @@ class UserRole(CodeEnum): DEVELOPER = 3 # 개발자(내부 운영): 최고관리자 권한 전부 + 고객사에 보이지 않음 +class AuthProvider(CodeEnum): + """users.provider 코드값. 이 계정이 무엇으로 신원을 증명하는가. + + 한 계정은 수단 하나다 — 같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다. + 이으려면 "먼저 가입한 쪽의 소유"를 증명받아야 하는데, 그 증명 없이 이메일만 보고 이으면 + 남이 먼저 만들어 둔 계정에 내 구글 로그인이 들어간다(계정 선점). 보류 사유는 DECISIONS.md 1절.""" + + LOCAL = 1 # id/pw + GOOGLE = 2 # 구글 ID 토큰 + + class CompanyStatus(CodeEnum): """companies.status 코드값.""" diff --git a/solution/backend/config/config_models.py b/solution/backend/config/config_models.py index c6cdf6e..1b18d15 100644 --- a/solution/backend/config/config_models.py +++ b/solution/backend/config/config_models.py @@ -99,6 +99,19 @@ class JwtToken(BaseSettings): refresh_expire_day: int = Field(7, validation_alias="JWT_REFRESH_EXPIRE_DAY") +class GoogleOAuthConfig(BaseSettings): + """구글 로그인. client_id 가 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다. + + ★ client_id 는 비밀이 아니다(프론트 번들에 그대로 들어간다). 서버가 이 값을 갖는 이유는 + 숨기려는 게 아니라 **수신자(aud) 대조** 때문이다 — 남의 앱에 발급된 구글 토큰을 그대로 + 들고 와도 우리 계정이 되지 않게 막는 유일한 검사다. + ★ client_secret 은 쓰지 않는다. 프론트가 ID 토큰을 받아 오는 방식(GIS)이라 코드 교환이 없다.""" + + model_config = _BASE + + client_id: str = Field("", validation_alias="GOOGLE_CLIENT_ID") + + class ExternalApiConfig(BaseSettings): """키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다.""" @@ -144,3 +157,8 @@ def get_jwt_token_config() -> JwtToken: @lru_cache def get_external_api_config() -> ExternalApiConfig: return ExternalApiConfig() + + +@lru_cache +def get_google_oauth_config() -> GoogleOAuthConfig: + return GoogleOAuthConfig() diff --git a/solution/backend/config/server_configs.py b/solution/backend/config/server_configs.py index e1b4510..44c7f32 100644 --- a/solution/backend/config/server_configs.py +++ b/solution/backend/config/server_configs.py @@ -1,12 +1,15 @@ """설정 싱글턴 — 호출부 21개 파일이 이 이름들을 가져다 쓴다.""" from config.config_models import ( + APP_ENV, # 재export — 테스트가 "지금 어느 환경으로 도는가" 를 여기서 읽는다(tests/test_config.py) ExternalApiConfig, + GoogleOAuthConfig, JwtToken, LogConfig, MainDBConfig, WebServerConfig, get_external_api_config, + get_google_oauth_config, get_jwt_token_config, get_log_config, get_main_db_config, @@ -18,6 +21,7 @@ log_config: LogConfig = get_log_config() main_db_config: MainDBConfig = get_main_db_config() jwt_token_config: JwtToken = get_jwt_token_config() external_api_config: ExternalApiConfig = get_external_api_config() +google_oauth_config: GoogleOAuthConfig = get_google_oauth_config() # 부팅은 막지 않는다 — 토큰이 필요 없는 로컬 작업까지 못 하게 되면 곤란하다. if not jwt_token_config.access_key or not jwt_token_config.refresh_key: diff --git a/solution/backend/crud/user_crud.py b/solution/backend/crud/user_crud.py index fb9e08e..30b22ee 100644 --- a/solution/backend/crud/user_crud.py +++ b/solution/backend/crud/user_crud.py @@ -19,6 +19,14 @@ class IUserCRUD(ABC): async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]: pass + @abstractmethod + async def get_user_by_provider_uid(self, cdb: AsyncSession, provider: int, provider_uid: str) -> Tuple[ErrorType, users]: + pass + + @abstractmethod + async def get_user_by_email(self, cdb: AsyncSession, email: str) -> Tuple[ErrorType, users]: + pass + @abstractmethod async def is_user(self, cdb: AsyncSession, login_id: str) -> ErrorType: pass @@ -31,6 +39,10 @@ class IUserCRUD(ABC): async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType: pass + @abstractmethod + async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType: + pass + @abstractmethod async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]: pass @@ -66,6 +78,45 @@ class UserCRUD(IUserCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None + async def get_user_by_provider_uid(self, cdb: AsyncSession, provider: int, provider_uid: str) -> Tuple[ErrorType, users]: + """소셜 계정 조회 키는 provider_uid(구글 sub) 다 — 이메일이 아니다. + 구글은 이메일 변경을 허용하고, 이메일로 찾으면 그때 같은 사람에게 계정이 하나 더 생긴다.""" + try: + query = ( + select(users) + .where(users.provider == provider, users.provider_uid == provider_uid, users.deleted == False) # noqa: E712 + .limit(1) + ) + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, "get_user_by_provider_uid failed.") + if err_type != ErrorType.SUCCESS: + return err_type, None + if len(row_list) != 1: + return ErrorType.DB_INVALID_KEY, None + return ErrorType.SUCCESS, row_list[0] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def get_user_by_email(self, cdb: AsyncSession, email: str) -> Tuple[ErrorType, users]: + """이메일로 1건. "이미 다른 수단으로 가입돼 있다" 판정에만 쓴다. + 이메일에는 유니크 제약이 없다(옛 데이터) — 여러 건이면 가장 먼저 만들어진 것을 본다.""" + try: + query = ( + select(users) + .where(func.lower(users.email) == email.strip().lower(), users.deleted == False) # noqa: E712 + .order_by(users.created_at.asc()) + .limit(1) + ) + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, "get_user_by_email failed.") + if err_type != ErrorType.SUCCESS: + return err_type, None + if len(row_list) != 1: + return ErrorType.DB_INVALID_KEY, None + return ErrorType.SUCCESS, row_list[0] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + async def is_user(self, cdb: AsyncSession, login_id: str) -> ErrorType: try: query = select(users).where(users.id == login_id, users.deleted == False).limit(1) # noqa: E712 @@ -94,6 +145,13 @@ class UserCRUD(IUserCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType: + try: + return await DB_SESSION_MNG.insert(cdb, company) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]: try: query = select(companies).where(companies.company_id == company_id, companies.deleted == False).limit(1) # noqa: E712 diff --git a/solution/backend/router/v1/auth/account.py b/solution/backend/router/v1/auth/account.py index 8eb38ca..bc8108a 100644 --- a/solution/backend/router/v1/auth/account.py +++ b/solution/backend/router/v1/auth/account.py @@ -4,7 +4,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from common.models.gmodel import UserInfo from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse from services.auth_service import AuthService -from .protocol import Req_Login, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken +from .protocol import Req_GoogleLogin, Req_Login, Req_Signup, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken security = HTTPBearer() @@ -17,6 +17,26 @@ async def login(request: Request, req: Req_Login, service: AuthService = Depends return RemoveNoneResponse(await service.attempt_login(req.id, req.password, request.client.host)) +@router.post( + path="/signup", + response_model=Res_Login, + summary="회원가입", + description="id/pw 로 가입한다. 회사(테넌트) 1개가 함께 생기고, 성공하면 곧바로 로그인 토큰을 발급한다.", +) +async def signup(req: Req_Signup, service: AuthService = Depends()): + return RemoveNoneResponse(await service.signup(req)) + + +@router.post( + path="/google", + response_model=Res_Login, + summary="구글 로그인", + description="구글 ID 토큰(GIS credential)을 검증하고 JWT 토큰을 발급한다. 처음 온 계정은 그 자리에서 만든다.", +) +async def google_login(req: Req_GoogleLogin, service: AuthService = Depends()): + return RemoveNoneResponse(await service.google_login(req)) + + @router.post( path="/refresh_token", dependencies=[Depends(IsValidRefreshToken)], diff --git a/solution/backend/router/v1/auth/protocol.py b/solution/backend/router/v1/auth/protocol.py index 1134e1e..99b699c 100644 --- a/solution/backend/router/v1/auth/protocol.py +++ b/solution/backend/router/v1/auth/protocol.py @@ -2,7 +2,7 @@ from typing import Optional from pydantic import Field -from common.enums import UserRole +from common.enums import AuthProvider, UserRole from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol @@ -16,6 +16,29 @@ class Req_Login(AuthProtocol): password: str = "" +class Req_Signup(AuthProtocol): + """id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다. + + ★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면 + 한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라 + **중복 판정용** 이다.""" + + id: str = "" + password: str = "" + name: Optional[str] = None + email: str = "" + company_name: Optional[str] = None # 상호. 비우면 이름 → 아이디 순으로 채운다 + + +class Req_GoogleLogin(AuthProtocol): + """구글 로그인. 프론트(GIS)가 받은 ID 토큰을 그대로 넘긴다. + + 필드 이름이 `credential` 인 이유는 GIS 콜백이 주는 이름 그대로이기 때문이다 — + `access_token`/`id_token` 으로 바꿔 부르면 우리 토큰과 헷갈린다.""" + + credential: str = "" + + class Res_Login(Res_WebPacketProtocol): access_token: str = "" refresh_token: str = "" @@ -47,4 +70,7 @@ class Res_Me(Res_WebPacketProtocol): email: Optional[str] = None contact_number: Optional[str] = None role: UserRole = UserRole.USER + # 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다) + # 내 정보 화면이 붙을 때 이 값으로 갈라야 한다. + provider: AuthProvider = AuthProvider.LOCAL company: Optional[CompanyData] = Field(default=None) diff --git a/solution/backend/services/auth_service.py b/solution/backend/services/auth_service.py index cfa320c..63a59fb 100644 --- a/solution/backend/services/auth_service.py +++ b/solution/backend/services/auth_service.py @@ -1,14 +1,23 @@ +import re import uuid from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import users -from common.enums import DBWRType, ErrorType, UserRole, UserStatus +from common.database.model.models import companies, users +from common.enums import AuthProvider, CompanyStatus, DBWRType, ErrorType, UserRole, UserStatus from common.logger import LOG from common.models.gmodel import UserInfo from crud.user_crud import IUserCRUD, UserCRUD -from router.v1.auth.protocol import CompanyData, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken +from router.v1.auth.protocol import ( + CompanyData, + Req_GoogleLogin, + Req_Signup, + Req_UpdateMe, + Res_Login, + Res_Me, + Res_RefreshToken, +) from router.v1.validator.dependencies import ( CreateAccessToken, CreateRefreshToken, @@ -16,6 +25,35 @@ from router.v1.validator.dependencies import ( GetHashedPW, VerifyPW, ) +from services.external.google_identity import ( + GoogleAccount, + GoogleNotConfigured, + GoogleTokenInvalid, + verify_id_token, +) + +# 로그인 아이디: 영문으로 시작하는 4~20자. 대소문자를 구분하지 않고 저장은 입력 그대로 한다. +_LOGIN_ID_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9._-]{3,19}$") +# 형식만 본다 — 이메일 소유 증명은 여기 없다(Req_Signup 주석). +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +_MIN_PASSWORD_LEN = 8 + +# 구글 계정의 로그인 아이디 접두어. 사람이 이 접두어로 가입해 두면 같은 이름의 구글 계정이 +# 영영 못 만들어진다(유니크 충돌) — 가입 단계에서 막는다. +_SOCIAL_ID_PREFIX = "google_" + + +def _google_login_id(sub: str) -> str: + """구글 계정의 로그인 아이디. sub 를 그대로 붙인다 — 자르면 앞자리가 같은 두 계정이 겹친다.""" + return f"{_SOCIAL_ID_PREFIX}{sub}" + + +def _fit(value: str | None, limit: int) -> str | None: + """컬럼 길이에 맞춰 자른다. 구글 표시 이름이나 사장님 입력이 길면 INSERT 가 통째로 터지는데, + 그건 "이름이 길다" 가 아니라 "가입이 안 된다" 로 보인다. 표시용 값이라 자르는 편이 낫다.""" + if value is None: + return None + return value[:limit] class AuthService: @@ -41,6 +79,29 @@ class AuthService: role=user.role, ) + async def _finish_login(self, user: users) -> Res_Login: + """신원이 확인된 뒤의 마지막 단계 — id/pw 와 구글이 공유한다. + + 상태 확인 → 우리 토큰 발급 → 마지막 접속 갱신. 어느 수단으로 들어왔든 여기서부터는 + 같은 세션이다(구글 토큰을 세션으로 들고 다니지 않는다).""" + res = Res_Login() + + if user.status != UserStatus.ACTIVE.value: + res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER) + return res + + user_info = self._user_info(user) + res.access_token = CreateAccessToken(user_info) + res.refresh_token = CreateRefreshToken(user_info) + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [users.DBType()], + [lambda s: self.user_crud.update_last_accessed(s, user.user_id)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + async def attempt_login(self, login_id: str, password: str, connect_ip: str) -> Res_Login: LOG.i(f"LOGIN : id={login_id}") res = Res_Login() @@ -57,31 +118,172 @@ class AuthService: return res user: users - # 2) 비밀번호 검증 + # 2) 소셜 계정에는 대조할 비밀번호가 없다. + # 여기서 끊지 않으면 VerifyPW 가 None 해시를 만나 500 이 난다. + # '아이디/비번 오류' 로 뭉개지 않는 이유: 화면이 "구글로 로그인하세요" 를 안내해야 한다. + if user.provider != AuthProvider.LOCAL.value or not user.password: + res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) + return res + + # 3) 비밀번호 검증 if not await VerifyPW(password, user.password): res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO) return res - # 3) 상태 확인 (활성 아니면 차단) - if user.status != UserStatus.ACTIVE.value: - res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER) - return res + return await self._finish_login(user) - # 4) 토큰 발급 - user_info = self._user_info(user) - res.access_token = CreateAccessToken(user_info) - res.refresh_token = CreateRefreshToken(user_info) + # ---- 가입 -------------------------------------------------------------- + async def _create_account( + self, + *, + login_id: str, + password_hash: str | None, + name: str | None, + email: str | None, + company_name: str, + provider: AuthProvider, + provider_uid: str | None, + ) -> tuple[ErrorType, users]: + """회사 1개 + 그 회사의 첫 계정 1개를 한 트랜잭션으로 만든다. - # 5) 마지막 접속 시간 갱신 (Write DB, 트랜잭션) + ★ 가입은 곧 새 테넌트다. users.company_id 가 NOT NULL 이고 모든 도메인(사업장·사이트)이 + company_id 로 스코프되므로, 회사 없는 계정은 아무것도 만들지 못한다. + ★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서, 그 전에 + company.company_id 를 읽으면 None 이다 — 그대로 넣으면 NOT NULL 위반이다.""" + company_uuid = uuid.uuid4() + company = companies( + company_id=company_uuid, + name=_fit(company_name, 100), + email=_fit(email, 255), + status=CompanyStatus.ACTIVE.value, + ) + user = users( + user_id=uuid.uuid4(), + company_id=company_uuid, + id=login_id, + password=password_hash, + name=_fit(name, 50), + email=_fit(email, 255), + status=UserStatus.ACTIVE.value, + role=UserRole.USER.value, + provider=provider.value, + provider_uid=provider_uid, + ) err_type = await DB_SESSION_MNG.execute_lambda_run( [users.DBType()], - [lambda s: self.user_crud.update_last_accessed(s, user.user_id)], + [ + lambda s: self.user_crud.add_company(s, company), + lambda s: self.user_crud.add_user(s, user), + ], + ) + if err_type != ErrorType.SUCCESS: + return err_type, None + return ErrorType.SUCCESS, user + + async def _email_taken(self, email: str) -> bool: + err_type, existing = await DB_SESSION_MNG.execute_lambda( + users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_user_by_email(s, email), + ) + return err_type == ErrorType.SUCCESS and existing is not None + + async def signup(self, req: Req_Signup) -> Res_Login: + """id/pw 가입. 성공하면 곧바로 로그인 상태로 만든다(토큰을 실어 보낸다) — + 가입 직후 로그인 화면으로 되돌리면 방금 정한 비밀번호를 또 치게 된다.""" + res = Res_Login() + login_id = req.id.strip() + email = (req.email or "").strip().lower() + name = (req.name or "").strip() or None + + if not _LOGIN_ID_RE.match(login_id) or login_id.lower().startswith(_SOCIAL_ID_PREFIX): + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + if len(req.password) < _MIN_PASSWORD_LEN: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + if not _EMAIL_RE.match(email): + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + + # 아이디 중복. is_user 는 "없으면 SUCCESS" 다 — 있으면 DB_ALREADY_SAME_KEY 를 준다. + dup = await DB_SESSION_MNG.execute_lambda( + users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.is_user(s, login_id), + ) + if dup != ErrorType.SUCCESS: + res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST) + return res + + # 이메일 중복 — 구글로 이미 가입한 사람이 같은 이메일로 계정을 하나 더 만드는 걸 막는다. + if await self._email_taken(email): + res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST) + return res + + err_type, user = await self._create_account( + login_id=login_id, + password_hash=await GetHashedPW(req.password), + name=name, + email=email, + company_name=(req.company_name or "").strip() or name or login_id, + provider=AuthProvider.LOCAL, + provider_uid=None, ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res - return res + LOG.i(f"SIGNUP : id={login_id}") + return await self._finish_login(user) + + # ---- 구글 로그인 -------------------------------------------------------- + async def google_login(self, req: Req_GoogleLogin) -> Res_Login: + """구글 ID 토큰 → 우리 세션. 처음 온 계정이면 그 자리에서 만든다(별도 가입 절차 없음).""" + res = Res_Login() + + try: + account: GoogleAccount = await verify_id_token(req.credential) + except GoogleNotConfigured: + res.result.SetResult(ErrorType.OAUTH_NOT_CONFIGURED) + return res + except GoogleTokenInvalid: + res.result.SetResult(ErrorType.OAUTH_INVALID_TOKEN) + return res + + # 1) 이미 있는 구글 계정인가 — 판정 키는 sub 다(이메일이 바뀌어도 같은 사람). + err_type, user = await DB_SESSION_MNG.execute_lambda( + users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_user_by_provider_uid(s, AuthProvider.GOOGLE.value, account.sub), + ) + if err_type == ErrorType.SUCCESS and user is not None: + LOG.i(f"LOGIN(google) : sub={account.sub}") + return await self._finish_login(user) + + # 2) 같은 이메일이 다른 수단으로 이미 가입돼 있으면 **잇지 않는다.** + # 자동으로 이으면, 남의 이메일로 먼저 만들어 둔 id/pw 계정에 그 사람의 구글 로그인이 + # 그대로 들어간다(계정 선점). 소유 증명 없이 잇는 건 로그인 하나를 통째로 넘기는 것이다. + if account.email and await self._email_taken(account.email): + res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) + return res + + # 3) 첫 방문 — 계정과 회사를 만든다. + err_type, user = await self._create_account( + login_id=_google_login_id(account.sub), + password_hash=None, + name=account.name or None, + email=account.email or None, + company_name=account.name or account.email or _google_login_id(account.sub), + provider=AuthProvider.GOOGLE, + provider_uid=account.sub, + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + LOG.i(f"SIGNUP(google) : sub={account.sub}") + return await self._finish_login(user) async def get_me(self, user_info: UserInfo) -> Res_Me: res = Res_Me() @@ -113,6 +315,7 @@ class AuthService: res.email = user.email res.contact_number = user.contact_number res.role = UserRole(user.role) + res.provider = AuthProvider(user.provider) res.company = company return res @@ -122,6 +325,22 @@ class AuthService: # 비밀번호: 값 있으면 해시 교체, 비었으면 변경 안 함. if data.get("password"): + # 구글 계정에 비밀번호를 심으면 id/pw 로도 열리는 반쪽 계정이 된다 — + # 로그인 수단이 둘인데 어느 쪽도 상대를 모르는 상태다. 받지 않는다. + if len(data["password"]) < _MIN_PASSWORD_LEN: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + err_type, me = await DB_SESSION_MNG.execute_lambda( + users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_user_by_login_id(s, user_info.id), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(ErrorType.ACCOUNT_NOT_FOUND) + return res + if me.provider != AuthProvider.LOCAL.value: + res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) + return res data["password"] = await GetHashedPW(data["password"]) else: data.pop("password", None) diff --git a/solution/backend/services/external/google_identity.py b/solution/backend/services/external/google_identity.py new file mode 100644 index 0000000..d967986 --- /dev/null +++ b/solution/backend/services/external/google_identity.py @@ -0,0 +1,138 @@ +"""구글 ID 토큰 검증 — "이 토큰이 정말 구글이 **우리 앱에** 발급한 것인가" 만 본다. + +프론트(Google Identity Services)가 받아 온 ID 토큰을 그대로 우리 백엔드로 보내면, +여기서 구글 공개키로 서명을 확인하고 신원(sub·email)을 꺼낸다. 그 뒤로는 우리 JWT 다 — +구글 토큰을 세션으로 들고 다니지 않는다. + +★ 왜 client_secret 이 없나 + 코드 교환(authorization code flow)을 하지 않기 때문이다. GIS 는 브라우저에서 ID 토큰을 + 바로 준다. 서버가 할 일은 교환이 아니라 **검증**이고, 검증에 필요한 건 공개키와 client_id 뿐이다. + +★ 반드시 남겨야 할 검사 세 가지 (하나만 빠져도 조용히 뚫린다) + 1. 서명 — 구글 JWKS 의 공개키로. 이게 없으면 아무나 JSON 을 만들어 보낸다. + 2. aud — 우리 client_id 와 같아야 한다. 없으면 **다른 서비스에 발급된 진짜 구글 토큰**을 + 그대로 들고 와서 우리 계정이 된다(가장 흔한 구멍이다). + 3. iss — accounts.google.com. 서명과 함께 발급자를 못 박는다. + email_verified 도 함께 본다 — 미인증 이메일을 신원으로 쓰면 이메일 기반 판단이 전부 흔들린다. + +★ 공개키는 돌아간다(rotation). kid 가 캐시에 없으면 한 번 다시 받는다 — + TTL 만 믿고 있으면 키가 바뀐 직후 몇 분 동안 전원 로그인 실패다. +""" + +import asyncio +import time +from dataclasses import dataclass + +import httpx +from jose import jwt, JWTError + +from common.logger import LOG +from config.server_configs import google_oauth_config + +# 구글 공개키(JWKS). OpenID discovery 를 매번 타지 않고 고정 주소를 쓴다 — 구글이 바꾸지 않는 주소다. +_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs" + +# 구글은 두 표기를 모두 쓴다. 한쪽만 받으면 어느 날 갑자기 전원 로그인 실패다. +_ISSUERS = ("accounts.google.com", "https://accounts.google.com") + +# 캐시 수명. 구글 응답의 Cache-Control 은 보통 수 시간이라 1시간은 넉넉히 보수적이다. +_JWKS_TTL_SEC = 3600 + +_HTTP_TIMEOUT_SEC = 5.0 + +_jwks: dict | None = None +_jwks_at: float = 0.0 +# 토큰이 동시에 여러 개 들어와도 JWKS 는 한 번만 받는다. +_jwks_lock = asyncio.Lock() + + +class GoogleNotConfigured(RuntimeError): + """GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다. 서버는 뜬다.""" + + +class GoogleTokenInvalid(RuntimeError): + """서명·수신자(aud)·발급자(iss)·만료 중 하나라도 어긋났다.""" + + +@dataclass +class GoogleAccount: + """ID 토큰에서 꺼낸 신원. 여기 없는 값은 쓰지 않는다.""" + + sub: str # 구글 계정의 영구 식별자. 이메일이 바뀌어도 유지된다 — 계정 매칭 키는 이것뿐이다. + email: str + name: str + + +def is_configured() -> bool: + return bool(google_oauth_config.client_id) + + +async def _fetch_jwks() -> dict: + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client: + res = await client.get(_JWKS_URL) + res.raise_for_status() + return res.json() + + +async def _get_jwks(*, force: bool = False) -> dict: + global _jwks, _jwks_at + async with _jwks_lock: + fresh = _jwks is not None and (time.monotonic() - _jwks_at) < _JWKS_TTL_SEC + if fresh and not force: + return _jwks + try: + _jwks = await _fetch_jwks() + _jwks_at = time.monotonic() + except Exception as ex: + LOG.e_no_callstack(f"[GOOGLE] JWKS 조회 실패: {ex}") + # 낡은 캐시라도 있으면 그걸로 간다 — 구글이 잠깐 안 될 때 로그인 전체가 죽는 것보다 낫다. + if _jwks is None: + raise GoogleTokenInvalid("JWKS unavailable") from ex + return _jwks + + +def _has_kid(jwks: dict, kid: str | None) -> bool: + return any(key.get("kid") == kid for key in (jwks.get("keys") or [])) + + +async def verify_id_token(id_token: str) -> GoogleAccount: + if not is_configured(): + raise GoogleNotConfigured("GOOGLE_CLIENT_ID 가 비어 있다") + if not id_token: + raise GoogleTokenInvalid("empty token") + + try: + kid = jwt.get_unverified_header(id_token).get("kid") + except JWTError as ex: + raise GoogleTokenInvalid("malformed token") from ex + + jwks = await _get_jwks() + # 키 회전 직후: 캐시에 없는 kid 면 한 번만 다시 받는다. + if not _has_kid(jwks, kid): + jwks = await _get_jwks(force=True) + + try: + claims = jwt.decode( + id_token, + jwks, + algorithms=["RS256"], + audience=google_oauth_config.client_id, + issuer=_ISSUERS, + # at_hash 는 access_token 과 짝일 때만 의미가 있다. GIS 크리덴셜에는 access_token 이 + # 없으므로 켜 두면 "access_token 이 없다"는 이유로 정상 토큰이 거부된다. + options={"verify_at_hash": False}, + ) + except JWTError as ex: + # 이유를 사용자에게 흘리지 않는다 — 로그에만 남긴다. + LOG.w(f"[GOOGLE] ID 토큰 거부: {ex}") + raise GoogleTokenInvalid(str(ex)) from ex + + sub = str(claims.get("sub") or "") + email = str(claims.get("email") or "") + if not sub: + raise GoogleTokenInvalid("no sub") + # 미인증 이메일은 신원으로 쓸 수 없다 — 남의 주소를 적어 둔 계정일 수 있다. + if not claims.get("email_verified"): + raise GoogleTokenInvalid("email not verified") + + return GoogleAccount(sub=sub, email=email, name=str(claims.get("name") or "")) diff --git a/solution/backend/tests/test_auth.py b/solution/backend/tests/test_auth.py index 6724205..c596f4a 100644 --- a/solution/backend/tests/test_auth.py +++ b/solution/backend/tests/test_auth.py @@ -41,3 +41,119 @@ async def test_me_without_token_is_rejected(client): 기대결과: 인증 단계에서 거부 — HTTP 401 또는 403.""" r = await client.get("/v1/auth/me") assert r.status_code in (401, 403) + + +# ── 가입(id/pw) ────────────────────────────────────────────────────────────── +_SIGNUP = {"id": "sajang1", "password": "pw12345678", "name": "김사장", "email": "boss@example.com"} + + +async def test_signup_creates_account_and_logs_in(client, db_engine): + """검증: 가입 → 받은 토큰으로 곧바로 /me. + 기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원과 **새로 생긴 소속사**를 돌려준다.""" + r = await client.post("/v1/auth/signup", json=_SIGNUP) + body = r.json() + assert body["result"]["success"] is True + assert body["access_token"] and body["refresh_token"] + + me = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {body['access_token']}"})).json() + assert me["id"] == "sajang1" + assert me["email"] == "boss@example.com" + assert me["provider"] == 1 # AuthProvider.LOCAL + assert me["company"]["name"] == "김사장" # 회사명 미입력 → 이름으로 채운다 + + +async def test_signup_rejects_duplicate_id(client, db_engine): + """검증: 같은 아이디로 두 번 가입. + 기대결과: 두 번째는 1101(ACCOUNT_ALREADY_EXIST) — 이메일만 달라도 막힌다.""" + await client.post("/v1/auth/signup", json=_SIGNUP) + r = await client.post("/v1/auth/signup", json={**_SIGNUP, "email": "other@example.com"}) + assert r.json()["result"]["code"] == 1101 + + +async def test_signup_rejects_duplicate_email(client, db_engine): + """검증: 아이디는 다른데 이메일이 같은 가입. + 기대결과: 1101 — 한 사람에게 계정이 둘 생기는 걸 이메일에서 끊는다.""" + await client.post("/v1/auth/signup", json=_SIGNUP) + r = await client.post("/v1/auth/signup", json={**_SIGNUP, "id": "sajang2"}) + assert r.json()["result"]["code"] == 1101 + + +async def test_signup_rejects_weak_input(client, db_engine): + """검증: 짧은 비밀번호 / 규칙에 안 맞는 아이디 / 형식이 아닌 이메일. + 기대결과: 전부 101(INVALID_REQUEST_DATA) — 서버가 마지막 방어선이다(화면 검사만 믿지 않는다).""" + for bad in ( + {**_SIGNUP, "password": "short"}, + {**_SIGNUP, "id": "1abc"}, # 영문으로 시작해야 한다 + {**_SIGNUP, "id": "ab"}, # 4자 미만 + {**_SIGNUP, "id": "google_12345"}, # 구글 계정 아이디 접두어는 선점 금지 + {**_SIGNUP, "email": "not-an-email"}, + ): + r = await client.post("/v1/auth/signup", json=bad) + assert r.json()["result"]["code"] == 101, bad + + +# ── 구글 로그인 ────────────────────────────────────────────────────────────── +def _stub_google(monkeypatch, *, sub="1234567890", email="g@example.com", name="구글유저"): + """ID 토큰 검증을 대역으로 바꾼다. 서명 검증 자체는 test_google_identity.py 가 본다.""" + from services.external.google_identity import GoogleAccount + + async def _verify(_credential): + return GoogleAccount(sub=sub, email=email, name=name) + + monkeypatch.setattr("services.auth_service.verify_id_token", _verify) + + +async def test_google_login_creates_then_reuses_account(client, db_engine, monkeypatch): + """검증: 같은 구글 계정으로 두 번 로그인. + 기대결과: 첫 번째에 계정이 생기고, 두 번째는 **같은 user_id** 로 붙는다(계정이 늘지 않는다).""" + _stub_google(monkeypatch) + + first = (await client.post("/v1/auth/google", json={"credential": "x"})).json() + assert first["result"]["success"] is True + me1 = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {first['access_token']}"})).json() + assert me1["provider"] == 2 # AuthProvider.GOOGLE + assert me1["id"] == "google_1234567890" + + second = (await client.post("/v1/auth/google", json={"credential": "x"})).json() + me2 = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {second['access_token']}"})).json() + assert me2["user_id"] == me1["user_id"] + + +async def test_google_login_follows_sub_not_email(client, db_engine, monkeypatch): + """검증: 같은 sub 인데 구글 쪽 이메일이 바뀐 경우. + 기대결과: 같은 계정으로 들어온다 — 매칭 키가 이메일이 아니라 sub 라서.""" + _stub_google(monkeypatch, email="before@example.com") + first = (await client.post("/v1/auth/google", json={"credential": "x"})).json() + me1 = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {first['access_token']}"})).json() + + _stub_google(monkeypatch, email="after@example.com") + second = (await client.post("/v1/auth/google", json={"credential": "x"})).json() + me2 = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {second['access_token']}"})).json() + assert me2["user_id"] == me1["user_id"] + + +async def test_google_login_refuses_to_link_existing_local_account(client, db_engine, monkeypatch): + """검증: id/pw 로 이미 가입된 이메일로 구글 로그인. + 기대결과: 1105(ACCOUNT_PROVIDER_CONFLICT) — 소유 증명 없이 잇지 않는다(계정 선점 방지).""" + await client.post("/v1/auth/signup", json=_SIGNUP) + + _stub_google(monkeypatch, email=_SIGNUP["email"]) + r = await client.post("/v1/auth/google", json={"credential": "x"}) + assert r.json()["result"]["code"] == 1105 + + +async def test_password_login_against_google_account_is_refused(client, db_engine, monkeypatch): + """검증: 구글로 만들어진 계정에 id/pw 로그인 시도. + 기대결과: 1105 — 500 이 아니다(대조할 비밀번호가 없는 계정이라 해시 검증에 들어가면 터진다).""" + _stub_google(monkeypatch) + await client.post("/v1/auth/google", json={"credential": "x"}) + + r = await client.post("/v1/auth/login", json={"id": "google_1234567890", "password": "whatever"}) + assert r.json()["result"]["code"] == 1105 + + +async def test_google_login_is_off_when_client_id_is_empty(client, db_engine): + """검증: GOOGLE_CLIENT_ID 가 비어 있을 때(테스트 기본값) 구글 로그인 호출. + 기대결과: 1106(OAUTH_NOT_CONFIGURED) — 네트워크를 타지 않고 즉시 끊긴다.""" + r = await client.post("/v1/auth/google", json={"credential": "anything"}) + assert r.json()["result"]["code"] == 1106 diff --git a/solution/backend/tests/test_config.py b/solution/backend/tests/test_config.py index 87899bb..1cbeb29 100644 --- a/solution/backend/tests/test_config.py +++ b/solution/backend/tests/test_config.py @@ -4,7 +4,7 @@ .env 에 APP_ENV=local 이 들어 있어도 테스트는 반드시 test DB 를 봐야 한다 — 그 규칙이 깨지면 db_engine 픽스처의 TRUNCATE 가 dev DB 를 지운다. """ -from config.server_configs import APP_ENV, external_api_config, main_db_config +from config.server_configs import APP_ENV, external_api_config, google_oauth_config, main_db_config def test_tests_run_against_test_db(): @@ -20,3 +20,5 @@ def test_external_api_keys_are_absent_in_tests(): for name in ("perplexity_api_key", "kakao_rest_api_key", "gemini_api_key", "tour_api_key"): # 값 자체는 출력하지 않는다(실키가 로그·CI 에 남지 않게). assert not getattr(external_api_config, name), f"{name} 이 테스트 환경에 설정돼 있다 — .env 가 새어 들어왔다" + # 구글 client_id 도 같은 규칙이다. 이게 새어 들어오면 구글 로그인 테스트가 진짜 JWKS 를 받으러 나간다. + assert not google_oauth_config.client_id, "GOOGLE_CLIENT_ID 가 테스트 환경에 설정돼 있다 — .env 가 새어 들어왔다" diff --git a/solution/backend/tests/test_google_identity.py b/solution/backend/tests/test_google_identity.py new file mode 100644 index 0000000..7ae5926 --- /dev/null +++ b/solution/backend/tests/test_google_identity.py @@ -0,0 +1,144 @@ +"""구글 ID 토큰 검증 — 서명·수신자(aud)·발급자(iss)·이메일 인증. + +★ 왜 대역이 아니라 진짜 서명을 쓰나 + 이 파일이 지키는 건 "우리가 받아들이면 안 되는 토큰을 거부하는가" 하나다. 검증 함수를 + 대역으로 바꾸면 그 질문 자체가 사라진다. 그래서 여기서는 테스트용 RSA 키를 만들어 **진짜로 + 서명하고**, 구글 공개키 조회(_fetch_jwks)만 그 키로 바꿔 끼운다. 네트워크는 타지 않는다. + + 특히 aud 검사: 남의 서비스에 발급된 **진짜 구글 토큰**을 그대로 우리 서버에 보내면 + 서명도 발급자도 전부 맞다. 그걸 거르는 검사는 aud 하나뿐이다. +""" + +import time + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from jose import jwk +from jose import jwt as jose_jwt + +from services.external import google_identity +from services.external.google_identity import GoogleNotConfigured, GoogleTokenInvalid, verify_id_token + +_KID = "test-key-1" +_CLIENT_ID = "our-app.apps.googleusercontent.com" + + +@pytest.fixture(scope="module") +def keypair(): + """테스트 전용 RSA 키 → (서명용 PEM, 구글 JWKS 를 흉내 낸 공개키 묶음).""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + public_pem = key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + + entry = {k: (v.decode() if isinstance(v, bytes) else v) for k, v in jwk.construct(public_pem, "RS256").to_dict().items()} + entry["kid"] = _KID + return private_pem, {"keys": [entry]} + + +@pytest.fixture(autouse=True) +def google_configured(monkeypatch, keypair): + """client_id 를 채우고 JWKS 조회를 테스트 키로 바꾼다. 캐시는 테스트마다 비운다.""" + _, jwks = keypair + monkeypatch.setattr(google_identity.google_oauth_config, "client_id", _CLIENT_ID) + + async def _fetch(): + return jwks + + monkeypatch.setattr(google_identity, "_fetch_jwks", _fetch) + monkeypatch.setattr(google_identity, "_jwks", None) + monkeypatch.setattr(google_identity, "_jwks_at", 0.0) + + +def _token(keypair, **overrides) -> str: + private_pem, _ = keypair + claims = { + "iss": "https://accounts.google.com", + "aud": _CLIENT_ID, + "sub": "1234567890", + "email": "boss@example.com", + "email_verified": True, + "name": "김사장", + "exp": int(time.time()) + 600, + "iat": int(time.time()), + } + claims.update(overrides) + return jose_jwt.encode(claims, private_pem, algorithm="RS256", headers={"kid": _KID}) + + +async def test_valid_token_yields_identity(keypair): + """검증: 우리 client_id 로 발급된 정상 토큰. + 기대결과: sub·email·name 이 그대로 나온다.""" + account = await verify_id_token(_token(keypair)) + assert account.sub == "1234567890" + assert account.email == "boss@example.com" + assert account.name == "김사장" + + +async def test_token_for_another_app_is_refused(keypair): + """검증: 서명·발급자는 진짜인데 aud 가 **다른 서비스**인 토큰. + 기대결과: 거부 — 이 검사가 없으면 남의 앱 토큰으로 우리 계정에 들어온다.""" + with pytest.raises(GoogleTokenInvalid): + await verify_id_token(_token(keypair, aud="someone-else.apps.googleusercontent.com")) + + +async def test_token_from_another_issuer_is_refused(keypair): + """검증: 우리 aud 를 달고 있지만 iss 가 구글이 아닌 토큰. + 기대결과: 거부.""" + with pytest.raises(GoogleTokenInvalid): + await verify_id_token(_token(keypair, iss="https://evil.example.com")) + + +async def test_expired_token_is_refused(keypair): + """검증: 만료된 토큰. + 기대결과: 거부 — 한 번 새어 나간 토큰이 영원히 열쇠가 되지 않게.""" + with pytest.raises(GoogleTokenInvalid): + await verify_id_token(_token(keypair, exp=int(time.time()) - 10)) + + +async def test_unverified_email_is_refused(keypair): + """검증: email_verified=false. + 기대결과: 거부 — 미인증 이메일을 신원으로 쓰면 이메일 기반 중복 판정이 전부 흔들린다.""" + with pytest.raises(GoogleTokenInvalid): + await verify_id_token(_token(keypair, email_verified=False)) + + +async def test_tampered_signature_is_refused(keypair): + """검증: 본문을 바꾼 토큰(서명 불일치). + 기대결과: 거부.""" + head, payload, sig = _token(keypair).split(".") + other = _token(keypair, sub="9999999999").split(".")[1] + with pytest.raises(GoogleTokenInvalid): + await verify_id_token(f"{head}.{other}.{sig}") + + +async def test_unknown_kid_refetches_keys_once(keypair, monkeypatch): + """검증: 캐시에 없는 kid(키 회전 직후). + 기대결과: JWKS 를 한 번 더 받아 검증에 성공한다 — TTL 만 믿으면 회전 직후 전원 로그인 실패다.""" + private_pem, jwks = keypair + calls = {"n": 0} + + async def _fetch(): + calls["n"] += 1 + # 첫 호출은 우리 kid 가 없는(=낡은) 묶음을 준다. + return {"keys": []} if calls["n"] == 1 else jwks + + monkeypatch.setattr(google_identity, "_fetch_jwks", _fetch) + account = await verify_id_token(_token(keypair)) + assert account.sub == "1234567890" + assert calls["n"] == 2 + + +async def test_missing_client_id_disables_google_login(monkeypatch, keypair): + """검증: GOOGLE_CLIENT_ID 가 비어 있을 때. + 기대결과: GoogleNotConfigured — 검증을 시도조차 하지 않는다(aud 대조 대상이 없다).""" + monkeypatch.setattr(google_identity.google_oauth_config, "client_id", "") + with pytest.raises(GoogleNotConfigured): + await verify_id_token(_token(keypair)) diff --git a/solution/frontend/.env.example b/solution/frontend/.env.example index a65c193..872ce9c 100644 --- a/solution/frontend/.env.example +++ b/solution/frontend/.env.example @@ -9,3 +9,8 @@ VITE_PUBLISH_HOST=w4ai.o2o.kr # 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다. VITE_SITE_PREVIEW_URL=http://localhost:3000 + +# 구글 로그인 클라이언트 ID. 비우면 구글 버튼이 아예 안 뜬다(누르면 실패하는 버튼을 두지 않는다). +# ★ 로컬에서 compose 없이 띄울 때만 쓴다. compose 로 띄우면 루트 .env 의 GOOGLE_CLIENT_ID 가 +# 주입돼 이 값을 덮는다 — 백엔드의 aud 대조와 같은 값이어야 하므로 단일 출처는 루트 .env 다. +VITE_GOOGLE_CLIENT_ID= diff --git a/solution/frontend/src/api/generated/auth/auth.ts b/solution/frontend/src/api/generated/auth/auth.ts index f957880..65237d5 100644 --- a/solution/frontend/src/api/generated/auth/auth.ts +++ b/solution/frontend/src/api/generated/auth/auth.ts @@ -25,7 +25,9 @@ import type { import type { HTTPValidationError, + ReqGoogleLogin, ReqLogin, + ReqSignup, ReqUpdateMe, ResLogin, ResMe, @@ -105,6 +107,136 @@ export const useLogin = ,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/auth/signup`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqSignup, signal + }, + options); + } + + + +export const getSignupMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqSignup}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: ReqSignup}, TContext> => { + +const mutationKey = ['signup']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: ReqSignup}> = (props) => { + const {data} = props ?? {}; + + return signup(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type SignupMutationResult = NonNullable>> + export type SignupMutationBody = ReqSignup + export type SignupMutationError = void | HTTPValidationError + + /** + * @summary 회원가입 + */ +export const useSignup = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqSignup}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ReqSignup}, + TContext + > => { + + const mutationOptions = getSignupMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * 구글 ID 토큰(GIS credential)을 검증하고 JWT 토큰을 발급한다. 처음 온 계정은 그 자리에서 만든다. + * @summary 구글 로그인 + */ +export const googleLogin = ( + reqGoogleLogin: ReqGoogleLogin, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/auth/google`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqGoogleLogin, signal + }, + options); + } + + + +export const getGoogleLoginMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqGoogleLogin}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: ReqGoogleLogin}, TContext> => { + +const mutationKey = ['googleLogin']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: ReqGoogleLogin}> = (props) => { + const {data} = props ?? {}; + + return googleLogin(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type GoogleLoginMutationResult = NonNullable>> + export type GoogleLoginMutationBody = ReqGoogleLogin + export type GoogleLoginMutationError = void | HTTPValidationError + + /** + * @summary 구글 로그인 + */ +export const useGoogleLogin = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqGoogleLogin}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ReqGoogleLogin}, + TContext + > => { + + const mutationOptions = getGoogleLoginMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** * refresh 토큰으로 access 토큰을 재발급한다. * @summary 액세스 토큰 갱신 */ diff --git a/solution/frontend/src/api/generated/model/authProvider.ts b/solution/frontend/src/api/generated/model/authProvider.ts new file mode 100644 index 0000000..da7067b --- /dev/null +++ b/solution/frontend/src/api/generated/model/authProvider.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +/** + * users.provider 코드값. 이 계정이 무엇으로 신원을 증명하는가. + +한 계정은 수단 하나다 — 같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다. +이으려면 "먼저 가입한 쪽의 소유"를 증명받아야 하는데, 그 증명 없이 이메일만 보고 이으면 +남이 먼저 만들어 둔 계정에 내 구글 로그인이 들어간다(계정 선점). 보류 사유는 DECISIONS.md 1절. + */ +export type AuthProvider = typeof AuthProvider[keyof typeof AuthProvider]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const AuthProvider = { + LOCAL: 1, + GOOGLE: 2, +} as const; diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index f864869..2851bae 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -7,6 +7,7 @@ export * from './auditCheckData'; export * from './auditCheckDataRecommendation'; +export * from './authProvider'; export * from './buildStatus'; export * from './checkSlugParams'; export * from './companyData'; @@ -120,8 +121,12 @@ export * from './reqCreatePlace'; export * from './reqCreatePlaceOwnerUserId'; export * from './reqCreateUnit'; export * from './reqExtractFacts'; +export * from './reqGoogleLogin'; export * from './reqLogin'; export * from './reqPublishLocalContent'; +export * from './reqSignup'; +export * from './reqSignupCompanyName'; +export * from './reqSignupName'; export * from './reqSiteSlug'; export * from './reqSiteStatus'; export * from './reqSiteTemplate'; diff --git a/solution/frontend/src/api/generated/model/reqGoogleLogin.ts b/solution/frontend/src/api/generated/model/reqGoogleLogin.ts new file mode 100644 index 0000000..9e92d39 --- /dev/null +++ b/solution/frontend/src/api/generated/model/reqGoogleLogin.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +/** + * 구글 로그인. 프론트(GIS)가 받은 ID 토큰을 그대로 넘긴다. + +필드 이름이 `credential` 인 이유는 GIS 콜백이 주는 이름 그대로이기 때문이다 — +`access_token`/`id_token` 으로 바꿔 부르면 우리 토큰과 헷갈린다. + */ +export interface ReqGoogleLogin { + credential?: string; +} diff --git a/solution/frontend/src/api/generated/model/reqSignup.ts b/solution/frontend/src/api/generated/model/reqSignup.ts new file mode 100644 index 0000000..61d3a32 --- /dev/null +++ b/solution/frontend/src/api/generated/model/reqSignup.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { ReqSignupName } from './reqSignupName'; +import type { ReqSignupCompanyName } from './reqSignupCompanyName'; + +/** + * id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다. + +★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면 + 한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라 + **중복 판정용** 이다. + */ +export interface ReqSignup { + id?: string; + password?: string; + name?: ReqSignupName; + email?: string; + company_name?: ReqSignupCompanyName; +} diff --git a/solution/frontend/src/api/generated/model/reqSignupCompanyName.ts b/solution/frontend/src/api/generated/model/reqSignupCompanyName.ts new file mode 100644 index 0000000..5b6614a --- /dev/null +++ b/solution/frontend/src/api/generated/model/reqSignupCompanyName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ReqSignupCompanyName = string | null; diff --git a/solution/frontend/src/api/generated/model/reqSignupName.ts b/solution/frontend/src/api/generated/model/reqSignupName.ts new file mode 100644 index 0000000..70901d6 --- /dev/null +++ b/solution/frontend/src/api/generated/model/reqSignupName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ReqSignupName = string | null; diff --git a/solution/frontend/src/api/generated/model/resMe.ts b/solution/frontend/src/api/generated/model/resMe.ts index c03cb26..84fa745 100644 --- a/solution/frontend/src/api/generated/model/resMe.ts +++ b/solution/frontend/src/api/generated/model/resMe.ts @@ -10,6 +10,7 @@ import type { ResMeName } from './resMeName'; import type { ResMeEmail } from './resMeEmail'; import type { ResMeContactNumber } from './resMeContactNumber'; import type { UserRole } from './userRole'; +import type { AuthProvider } from './authProvider'; import type { ResMeCompany } from './resMeCompany'; export interface ResMe { @@ -21,5 +22,6 @@ export interface ResMe { email?: ResMeEmail; contact_number?: ResMeContactNumber; role?: UserRole; + provider?: AuthProvider; company?: ResMeCompany; } diff --git a/solution/frontend/src/components/auth/GoogleSignInButton.tsx b/solution/frontend/src/components/auth/GoogleSignInButton.tsx new file mode 100644 index 0000000..fd3d3aa --- /dev/null +++ b/solution/frontend/src/components/auth/GoogleSignInButton.tsx @@ -0,0 +1,76 @@ +import {useEffect, useRef, useState} from 'react'; +import {GOOGLE_CLIENT_ID, isGoogleLoginEnabled, loadGoogleIdentity} from '@/lib/googleIdentity'; + +/** + * 구글이 그려 주는 버튼을 그대로 쓴다. + * + * ★ 우리가 직접 만든 버튼을 쓰지 않는 이유: 구글 브랜드 가이드가 로고·문구·비율을 정해 두고, + * 그걸 어긋나게 그리면 심사에서 걸린다. renderButton 이 그 규격을 지켜 준다. + * ★ 스크립트가 안 오면(광고 차단기·사내망) 빈 칸이 남는다 — 안내 문구로 바꿔 준다. + * "눌러도 아무 일이 없는 자리"가 화면에 남는 게 제일 나쁘다. + */ +export function GoogleSignInButton({ + onCredential, + text = 'continue_with', +}: { + onCredential: (credential: string) => void; + text?: 'continue_with' | 'signin_with' | 'signup_with'; +}) { + const holder = useRef(null); + const [failed, setFailed] = useState(false); + + // 콜백은 ref 로 들고 간다 — GIS 는 initialize 시점의 함수를 붙잡고 있어서, + // 의존성에 넣으면 렌더마다 버튼을 다시 그리게 된다. + const handler = useRef(onCredential); + handler.current = onCredential; + + useEffect(() => { + if (!isGoogleLoginEnabled()) return; + let alive = true; + + loadGoogleIdentity() + .then((api) => { + if (!alive || !holder.current) return; + api.initialize({ + client_id: GOOGLE_CLIENT_ID, + callback: (res) => { + if (res.credential) handler.current(res.credential); + }, + // 화면을 열자마자 마지막 계정으로 자동 로그인되는 동작은 끈다 — + // 계정이 여러 개인 사람이 원하지 않은 계정으로 들어간다. + auto_select: false, + }); + api.renderButton(holder.current, { + type: 'standard', + theme: 'outline', + size: 'large', + shape: 'rectangular', + text, + locale: 'ko', + logo_alignment: 'center', + // GIS 는 숫자 px 만 받는다(최대 400). 컨테이너 폭이 잡히기 전이면 최소값으로 그린다. + width: holder.current.offsetWidth || 320, + }); + }) + .catch(() => { + if (alive) setFailed(true); + }); + + return () => { + alive = false; + }; + }, [text]); + + if (!isGoogleLoginEnabled()) return null; + + if (failed) { + return ( +

+ 구글 로그인을 불러오지 못했습니다. 아이디/비밀번호로 로그인해 주세요. +

+ ); + } + + // 높이를 미리 잡아 둔다 — 버튼이 뒤늦게 그려지면서 아래 내용이 밀리지 않게. + return
; +} diff --git a/solution/frontend/src/lib/autoSession.ts b/solution/frontend/src/lib/autoSession.ts index ca1e12d..93d8a27 100644 --- a/solution/frontend/src/lib/autoSession.ts +++ b/solution/frontend/src/lib/autoSession.ts @@ -1,6 +1,5 @@ -import {getAccessToken, login, me} from '@/api'; -import {UserRole} from '@/api'; -import {toAuthUser, useAuthStore} from '@/stores/auth'; +import {getAccessToken, login} from '@/api'; +import {establishSession} from '@/lib/session'; /** * 계정이 주입돼 있으면 화면을 열 때 조용히 세션을 확보한다. @@ -28,15 +27,7 @@ export function ensureAutoSession(): Promise { pending = (async () => { try { - const res = await login({id, password}); - if (!res.access_token || !res.refresh_token) return; - const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token}; - - // 순서가 중요하다. signIn 이 토큰을 저장하고, 그 토큰으로 me() 가 나간다 — - // 먼저 me() 를 부르면 토큰이 아직 없어 401 로 떨어진다(로그인 화면과 같은 순서다). - useAuthStore.getState().signIn(tokens, {userId: '', id, role: UserRole.USER}); - const meRes = await me(); - if (meRes.user_id && meRes.id) useAuthStore.getState().signIn(tokens, toAuthUser(meRes)); + await establishSession(await login({id, password}), id); } catch { // 편의 기능이다 — 실패해도 화면을 막지 않는다(2단계가 안내를 띄운다). } finally { diff --git a/solution/frontend/src/lib/errorMessages.ts b/solution/frontend/src/lib/errorMessages.ts index 6b01c2c..701c703 100644 --- a/solution/frontend/src/lib/errorMessages.ts +++ b/solution/frontend/src/lib/errorMessages.ts @@ -13,6 +13,12 @@ export const ERROR_MESSAGE: Record = { ACCOUNT_BLOCKED_USER: '차단된 계정입니다. 운영자에게 문의해 주세요.', ACCOUNT_NOT_FOUND: '계정을 찾을 수 없습니다.', ACCOUNT_FORBIDDEN: '이 작업을 할 권한이 없습니다.', + ACCOUNT_ALREADY_EXIST: '이미 사용 중인 아이디 또는 이메일입니다.', + // 자동 연결을 하지 않기로 한 결과라, "다시 시도" 가 아니라 **어느 쪽으로 들어가야 하는지**를 적는다. + ACCOUNT_PROVIDER_CONFLICT: '다른 방법으로 가입된 계정입니다. 처음 가입할 때 쓴 방법으로 로그인해 주세요.', + OAUTH_NOT_CONFIGURED: '구글 로그인이 아직 설정되지 않았습니다. 운영자에게 알려주세요.', + OAUTH_INVALID_TOKEN: '구글 로그인 정보를 확인하지 못했습니다. 다시 시도해 주세요.', + INVALID_REQUEST_DATA: '입력값을 다시 확인해 주세요.', // 사업장 — 검증 게이트 PLACE_NOT_FOUND: '사업장을 찾을 수 없습니다.', diff --git a/solution/frontend/src/lib/googleIdentity.ts b/solution/frontend/src/lib/googleIdentity.ts new file mode 100644 index 0000000..4199b42 --- /dev/null +++ b/solution/frontend/src/lib/googleIdentity.ts @@ -0,0 +1,91 @@ +/** + * 구글 로그인(Google Identity Services) 어댑터. + * + * ★ 스크립트를 index.html 이 아니라 여기서 붙인다. client_id 가 없는 앱(내부 운영 화면)까지 + * 구글 스크립트를 받아 오면, 쓰지도 않는 서드파티 요청이 모든 화면에 붙는다. + * ★ 여기서 받는 건 `credential`(구글 ID 토큰) 하나뿐이다. 그걸 백엔드에 넘기면 그다음부터는 + * 우리 토큰이다 — 구글 토큰을 세션으로 들고 다니지 않는다. + * ★ client_id 는 비밀이 아니다(번들에 그대로 들어간다). 이 값으로 할 수 있는 건 "우리 앱 앞으로" + * 토큰을 받는 것뿐이고, 그 토큰이 우리 계정이 되려면 백엔드의 aud 대조를 통과해야 한다. + */ +const SCRIPT_URL = 'https://accounts.google.com/gsi/client'; +const SCRIPT_ID = 'google-identity-services'; + +export const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID ?? ''; + +/** 빈 값이면 구글 로그인 자체를 화면에 올리지 않는다 — 누르면 실패하는 버튼을 두지 않는다. */ +export function isGoogleLoginEnabled(): boolean { + return Boolean(GOOGLE_CLIENT_ID); +} + +type CredentialResponse = {credential?: string}; + +export type GoogleButtonOptions = { + type?: 'standard' | 'icon'; + theme?: 'outline' | 'filled_blue' | 'filled_black'; + size?: 'small' | 'medium' | 'large'; + shape?: 'rectangular' | 'pill' | 'circle' | 'square'; + text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin'; + locale?: string; + width?: number; + logo_alignment?: 'left' | 'center'; +}; + +type GoogleIdApi = { + initialize(config: { + client_id: string; + callback: (response: CredentialResponse) => void; + auto_select?: boolean; + cancel_on_tap_outside?: boolean; + }): void; + renderButton(parent: HTMLElement, options: GoogleButtonOptions): void; + disableAutoSelect(): void; +}; + +declare global { + interface Window { + google?: {accounts?: {id?: GoogleIdApi}}; + } +} + +// 스크립트는 한 번만 받는다. 로그인·가입 두 화면이 같은 약속을 나눠 쓴다. +let loading: Promise | null = null; + +export function loadGoogleIdentity(): Promise { + if (!isGoogleLoginEnabled()) return Promise.reject(new Error('GOOGLE_CLIENT_ID 없음')); + + const ready = window.google?.accounts?.id; + if (ready) return Promise.resolve(ready); + if (loading) return loading; + + loading = new Promise((resolve, reject) => { + const done = () => { + const api = window.google?.accounts?.id; + if (api) resolve(api); + else reject(new Error('GIS 로드는 됐는데 accounts.id 가 없다')); + }; + const fail = () => { + // 다음 시도에서 다시 받을 수 있게 비운다 — 광고 차단기·사내망에서 한 번 막히는 일이 흔하다. + loading = null; + reject(new Error('GIS 스크립트를 받지 못했다')); + }; + + const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null; + if (existing) { + existing.addEventListener('load', done, {once: true}); + existing.addEventListener('error', fail, {once: true}); + return; + } + + const script = document.createElement('script'); + script.id = SCRIPT_ID; + script.src = SCRIPT_URL; + script.async = true; + script.defer = true; + script.addEventListener('load', done, {once: true}); + script.addEventListener('error', fail, {once: true}); + document.head.appendChild(script); + }); + + return loading; +} diff --git a/solution/frontend/src/lib/session.ts b/solution/frontend/src/lib/session.ts new file mode 100644 index 0000000..34cc307 --- /dev/null +++ b/solution/frontend/src/lib/session.ts @@ -0,0 +1,24 @@ +import {me, UserRole} from '@/api'; +import type {ResLogin} from '@/api'; +import {toAuthUser, useAuthStore} from '@/stores/auth'; + +/** + * 로그인 응답 → 세션. id/pw · 가입 · 구글이 **같은 마지막 단계**를 쓴다. + * + * ★ 순서가 중요하다. signIn 이 토큰을 먼저 저장해야 뒤이은 me() 가 Authorization 을 달고 나간다 — + * me() 를 먼저 부르면 토큰이 없어 401 로 떨어진다. + * ★ 성공 응답이라도 토큰이 없을 수 있다(RemoveNoneResponse 가 빈 필드를 통째로 지운다). + * 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐르므로 여기서 끊는다. + */ +export async function establishSession(res: ResLogin, fallbackId: string): Promise { + if (!res.access_token || !res.refresh_token) return false; + + const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token}; + const store = useAuthStore.getState(); + store.signIn(tokens, {userId: '', id: fallbackId, role: UserRole.USER}); + + const meRes = await me(); + // 신원이 안 왔으면 방금 심은 임시 사용자를 그대로 둔다 — 토큰은 유효하므로 화면은 진행시킨다. + if (meRes.user_id && meRes.id) store.signIn(tokens, toAuthUser(meRes)); + return true; +} diff --git a/solution/frontend/src/pages/LoginPage.tsx b/solution/frontend/src/pages/LoginPage.tsx index 88a4183..1ecc207 100644 --- a/solution/frontend/src/pages/LoginPage.tsx +++ b/solution/frontend/src/pages/LoginPage.tsx @@ -1,25 +1,34 @@ import {useState, type FormEvent} from 'react'; -import {Navigate, useLocation, useNavigate} from 'react-router'; +import {Link, Navigate, useLocation, useNavigate} from 'react-router'; import {LogIn} from 'lucide-react'; -import {login, me, UserRole} from '@/api'; +import {googleLogin, login} from '@/api'; +import {GoogleSignInButton} from '@/components/auth/GoogleSignInButton'; import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; +import {isGoogleLoginEnabled} from '@/lib/googleIdentity'; import {notifyApiError} from '@/lib/notify'; -import {toAuthUser, useAuthStore} from '@/stores/auth'; +import {establishSession} from '@/lib/session'; +import {useAuthStore} from '@/stores/auth'; -export function LoginPage() { +/** + * 로그인 화면. **사장님 앱과 내부 운영 화면이 같이 쓴다.** + * + * ★ `selfServe` 로 갈린다 — 사장님 앱은 스스로 가입하고 구글로도 들어오지만, 내부 운영 계정은 + * 우리가 만들어 준다. 가입 링크를 두 곳에 다 두면 admin 라우터에 없는 `/signup` 으로 보내 + * 404 가 난다(admin/src/app/router.tsx 에 그 경로는 없다). + */ +export function LoginPage({selfServe = true}: {selfServe?: boolean}) { const navigate = useNavigate(); const location = useLocation(); const user = useAuthStore((s) => s.user); - const signIn = useAuthStore((s) => s.signIn); const [id, setId] = useState(''); const [password, setPassword] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); // ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록) -// 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다. -const from = (location.state as {from?: string} | null)?.from ?? '/'; + // 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다. + const from = (location.state as {from?: string} | null)?.from ?? '/'; if (user) return ; @@ -32,20 +41,10 @@ const from = (location.state as {from?: string} | null)?.from ?? '/'; notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.'); return; } - // ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다. - // 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다. - if (!res.access_token || !res.refresh_token) { + if (!(await establishSession(res, id))) { notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); return; } - const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token}; - - // 토큰을 먼저 심어야 뒤이은 me() 가 Authorization 을 달고 나간다. - signIn(tokens, {userId: '', id, role: UserRole.USER}); - - const meRes = await me(); - // 신원이 안 왔으면 방금 심은 임시 사용자를 그대로 둔다 — 토큰은 유효하므로 화면은 진행시킨다. - if (meRes.user_id && meRes.id) signIn(tokens, toAuthUser(meRes)); navigate(from, {replace: true}); } catch (error) { notifyApiError(error, '로그인에 실패했습니다.'); @@ -54,6 +53,26 @@ const from = (location.state as {from?: string} | null)?.from ?? '/'; } }; + const handleGoogle = async (credential: string) => { + setIsSubmitting(true); + try { + const res = await googleLogin({credential}); + if (res.result?.success === false) { + notifyApiError({data: res}, '구글 로그인에 실패했습니다.'); + return; + } + if (!(await establishSession(res, ''))) { + notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); + return; + } + navigate(from, {replace: true}); + } catch (error) { + notifyApiError(error, '구글 로그인에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + return (
-

관리자

+

+ {selfServe ? '로그인' : '관리자'} +

- 사업장·정보 확인·발행을 관리합니다. + {selfServe + ? '내 가게 사이트를 만들고 발행합니다.' + : '사업장·정보 확인·발행을 관리합니다.'}

@@ -105,12 +128,25 @@ const from = (location.state as {from?: string} | null)?.from ?? '/'; 로그인 -

- 빌더는 로그인 없이도 사용할 수 있습니다.{' '} - - 빌더로 이동 - -

+ {selfServe && isGoogleLoginEnabled() && ( + <> +
+ + 또는 + +
+ + + )} + + {selfServe && ( +

+ 아직 계정이 없으신가요?{' '} + + 회원가입 + +

+ )}
); diff --git a/solution/frontend/src/pages/SignupPage.tsx b/solution/frontend/src/pages/SignupPage.tsx new file mode 100644 index 0000000..4d2691b --- /dev/null +++ b/solution/frontend/src/pages/SignupPage.tsx @@ -0,0 +1,218 @@ +import {useState, type FormEvent} from 'react'; +import {Link, Navigate, useNavigate} from 'react-router'; +import {UserPlus} from 'lucide-react'; +import {googleLogin, signup} from '@/api'; +import {GoogleSignInButton} from '@/components/auth/GoogleSignInButton'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {isGoogleLoginEnabled} from '@/lib/googleIdentity'; +import {notify, notifyApiError} from '@/lib/notify'; +import {establishSession} from '@/lib/session'; +import {useAuthStore} from '@/stores/auth'; + +/** + * 회원가입. 가입 = **새 회사(테넌트) 1개 + 그 회사의 첫 계정 1개** 다(백엔드 auth_service.signup). + * + * ★ 여기 검사는 서버 규칙(services/auth_service.py 의 _LOGIN_ID_RE·_MIN_PASSWORD_LEN)의 사본이다. + * 두 벌이라 어긋날 수 있지만, 서버가 마지막 방어선이고 여기는 "제출 전에 알려주는" 역할이다. + * 규칙을 바꾸면 두 곳을 같이 고친다. + */ +const ID_RE = /^[a-zA-Z][a-zA-Z0-9._-]{3,19}$/; +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; +const MIN_PASSWORD_LEN = 8; + +export function SignupPage() { + const navigate = useNavigate(); + const user = useAuthStore((s) => s.user); + + const [form, setForm] = useState({ + id: '', + password: '', + passwordConfirm: '', + name: '', + email: '', + companyName: '', + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + if (user) return ; + + const set = (key: keyof typeof form) => (e: {target: {value: string}}) => + setForm((prev) => ({...prev, [key]: e.target.value})); + + const validate = (): string | null => { + if (!ID_RE.test(form.id)) return '아이디는 영문으로 시작하는 4~20자입니다(영문·숫자·. _ - 사용).'; + if (form.password.length < MIN_PASSWORD_LEN) return `비밀번호는 ${MIN_PASSWORD_LEN}자 이상이어야 합니다.`; + if (form.password !== form.passwordConfirm) return '비밀번호가 서로 다릅니다.'; + if (!EMAIL_RE.test(form.email)) return '이메일 형식을 확인해 주세요.'; + return null; + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + const invalid = validate(); + if (invalid) { + notify.error(invalid); + return; + } + + setIsSubmitting(true); + try { + const res = await signup({ + id: form.id.trim(), + password: form.password, + name: form.name.trim() || null, + email: form.email.trim(), + company_name: form.companyName.trim() || null, + }); + if (res.result?.success === false) { + notifyApiError({data: res}, '가입하지 못했습니다.'); + return; + } + // 가입 응답에 토큰이 실려 온다 — 방금 정한 비밀번호를 다시 치게 하지 않는다. + if (!(await establishSession(res, form.id.trim()))) { + notifyApiError({data: res}, '가입은 됐지만 로그인 토큰이 오지 않았습니다. 다시 로그인해 주세요.'); + navigate('/login', {replace: true}); + return; + } + notify.success('가입이 완료되었습니다.'); + navigate('/', {replace: true}); + } catch (error) { + notifyApiError(error, '가입하지 못했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + // 구글은 가입과 로그인이 같은 동작이다 — 처음 온 계정이면 백엔드가 그 자리에서 만든다. + const handleGoogle = async (credential: string) => { + setIsSubmitting(true); + try { + const res = await googleLogin({credential}); + if (res.result?.success === false) { + notifyApiError({data: res}, '구글로 가입하지 못했습니다.'); + return; + } + if (!(await establishSession(res, ''))) { + notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); + return; + } + navigate('/', {replace: true}); + } catch (error) { + notifyApiError(error, '구글로 가입하지 못했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+ Web4Ai +

회원가입

+

내 가게 사이트를 만들려면 계정이 필요합니다.

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + {isGoogleLoginEnabled() && ( + <> +
+ + 또는 + +
+ + + )} + +

+ 이미 계정이 있으신가요?{' '} + + 로그인 + +

+ +
+ ); +} diff --git a/solution/frontend/src/vite-env.d.ts b/solution/frontend/src/vite-env.d.ts index 57f3747..bbba3ba 100644 --- a/solution/frontend/src/vite-env.d.ts +++ b/solution/frontend/src/vite-env.d.ts @@ -7,6 +7,8 @@ interface ImportMetaEnv { /** ⚠️ 번들에 구워진다 — 내부 테스트 호스트에서만 채운다(lib/autoSession). */ readonly VITE_AUTO_LOGIN_ID?: string; readonly VITE_AUTO_LOGIN_PW?: string; + /** 구글 OAuth 클라이언트 ID. 비면 구글 로그인 버튼 자체가 안 뜬다(lib/googleIdentity). */ + readonly VITE_GOOGLE_CLIENT_ID?: string; } interface ImportMeta {