[feat] solution/backend,frontend: id/pw 가입 · 구글 로그인 — 계정을 만들 길이 없던 걸 연다

계정 생성 API 가 아예 없었다(그동안 users 를 손으로 INSERT 했다). 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다.

- auth_service.signup: 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**. users.company_id 가
  NOT NULL 이고 모든 도메인이 company 로 스코프돼서, 회사 없는 계정은 아무것도 못 만든다
- services/external/google_identity: 구글 ID 토큰의 서명·iss·만료에 더해 **aud(우리 client_id)와
  email_verified 를 본다.** aud 검사가 빠지면 남의 앱에 발급된 '진짜' 구글 토큰으로 우리 계정에
  들어온다 — 서명도 발급자도 전부 맞으므로 다른 검사로는 안 걸린다
- users.provider/provider_uid 추가, password NULL 허용, id 20→64자(google_<sub> 가 20자를 넘는다).
  provider 에 server_default 를 같이 준 이유: ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
  NOT NULL 컬럼이면 그 경로가 통째로 깨진다
- attempt_login: 소셜 계정을 먼저 끊는다. 안 끊으면 bcrypt 가 None 해시를 만나 500 이다
- 같은 이메일이라도 id/pw 계정과 구글 계정을 **잇지 않는다.** 이으면 계정 선점이다 —
  남의 이메일로 먼저 만들어 둔 계정에 그 사람의 구글 로그인이 들어간다 → DECISIONS 1-5
- LoginPage 는 admin 과 공유라 selfServe 로 갈랐다. admin 은 가입 링크도 구글 버튼도 안 뜬다
  (admin 라우터에 /signup 이 없어 404 가 난다)
- GOOGLE_CLIENT_ID 는 루트 .env 한 곳. compose 가 VITE_GOOGLE_CLIENT_ID 로 흘려보낸다 —
  두 곳에 적으면 백엔드 aud 대조와 화면 버튼이 조용히 갈라진다

★ 이미 도는 DB 는 init.sql 을 다시 적용해야 한다(말미 ALTER 섹션).

pytest: auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·email_verified·변조
거절 확인) 통과. 전체 527 passed / 8 failed(전부 기존 실패, 인증과 무관).
tsc·eslint·vite build 통과.
This commit is contained in:
Mina Choi 2026-09-02 09:33:59 +09:00
parent c250b656fd
commit 48109fdc99
37 changed files with 1554 additions and 62 deletions

View File

@ -24,6 +24,14 @@ KAKAO_REST_API_KEY= # 미발급. 없으면 네이버 지역검색을
GEMINI_API_KEY= GEMINI_API_KEY=
TOUR_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 허용 오리진. 쉼표로 여럿. # CORS 허용 오리진. 쉼표로 여럿.
# 서버에 올리면 반드시 적는다. 안 적으면 화면은 뜨고 API 만 막힌다. # 서버에 올리면 반드시 적는다. 안 적으면 화면은 뜨고 API 만 막힌다.
# CLIENT_URL=http://172.30.1.36:30031,http://localhost:3002 # CLIENT_URL=http://172.30.1.36:30031,http://localhost:3002

View File

@ -39,6 +39,10 @@
`site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부 `site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더 이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더
재실행만으로는 안 되고 **백엔드에서 재발행**해 payload 를 다시 만들어야 한다. 재실행만으로는 안 되고 **백엔드에서 재발행**해 payload 를 다시 만들어야 한다.
- **`GOOGLE_CLIENT_ID` 도 두 곳에 있고 같아야 한다.** 백엔드(`GOOGLE_CLIENT_ID`) ↔ 프론트
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.**
어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다).
- **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데 - **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는 블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
CDN 을 앞에 세워야 한다. 아니면 비워라. CDN 을 앞에 세워야 한다. 아니면 비워라.

View File

@ -23,7 +23,8 @@ const ADMIN_NAV: NavItem[] = [
* 그 예외가 기본값이 된다. 사장님 앱과 앱을 가른 이유가 이 규칙을 지키기 위해서다. * 그 예외가 기본값이 된다. 사장님 앱과 앱을 가른 이유가 이 규칙을 지키기 위해서다.
*/ */
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{path: '/login', element: <LoginPage />}, // selfServe=false: 내부 운영 계정은 우리가 만들어 준다 — 가입 링크도 구글 로그인도 두지 않는다.
{path: '/login', element: <LoginPage selfServe={false} />},
{path: '/', element: <Navigate to="/places" replace />}, {path: '/', element: <Navigate to="/places" replace />},

View File

@ -21,6 +21,9 @@ x-common-env: &common-env
DB_NAME: ${DB_NAME:-web4ai_db} DB_NAME: ${DB_NAME:-web4ai_db}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-} JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-}
JWT_REFRESH_SECRET: ${JWT_REFRESH_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 이 전부 이걸 쓴다. # ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다.
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
SITE_PAYLOAD_DIR: /app/out/payloads SITE_PAYLOAD_DIR: /app/out/payloads
@ -190,6 +193,8 @@ services:
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000} VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-} VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-} VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
# 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처).
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
volumes: volumes:
- ./package.json:/app/package.json - ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json - ./package-lock.json:/app/package-lock.json
@ -259,6 +264,8 @@ services:
# ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다. # ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다.
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-} VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-} VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
# 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다.
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
image: o2o-web4ai-solution-site image: o2o-web4ai-solution-site
container_name: o2o-web4ai-solution-site container_name: o2o-web4ai-solution-site
volumes: volumes:

View File

@ -65,6 +65,17 @@
| 코드 격리 | `site` 에 발행 상태를 두고, 해지 처리는 **삭제가 아니라 상태 전이**로만 구현한다. 물리 삭제 경로를 만들지 않는다 | | 코드 격리 | `site` 에 발행 상태를 두고, 해지 처리는 **삭제가 아니라 상태 전이**로만 구현한다. 물리 삭제 경로를 만들지 않는다 |
| 반영됨 (2026-08-26) | `SiteStatus.SUSPENDED`(해지 유예 — 페이지 살아 있음) / `UNPUBLISHED`(내림)를 분리했다. `PublishAction.SUSPEND`·`RESUME` 으로 `publish_logs` 에 남는다. 유예 기간 길이만 정하면 된다 | | 반영됨 (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) ## 2. 이식하면서 내린 결정 (2026-08-26)

View File

@ -104,6 +104,19 @@ docker compose exec solution-worker python scripts/republish_all.py
프리렌더만 다시 돌리면 옛 주소가 그대로 나온다. 반드시 **백엔드에서 재발행**해 payload 를 프리렌더만 다시 돌리면 옛 주소가 그대로 나온다. 반드시 **백엔드에서 재발행**해 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 끔) ### 1단계 — 서버에서 "굽기"만 재현 (Azure 끔)
배포 대상 서버(접속·경로·이미 물린 포트)는 [SERVERS.md](SERVERS.md) 가 단일 출처다. 배포 대상 서버(접속·경로·이미 물린 포트)는 [SERVERS.md](SERVERS.md) 가 단일 출처다.
```bash ```bash

View File

@ -31,11 +31,15 @@ ARG VITE_SITE_PREVIEW_URL
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession). # 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession).
ARG VITE_AUTO_LOGIN_ID ARG VITE_AUTO_LOGIN_ID
ARG VITE_AUTO_LOGIN_PW 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 \ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \ VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \
VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \ VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \
VITE_AUTO_LOGIN_ID=$VITE_AUTO_LOGIN_ID \ 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 RUN npm run build -w @o2o/frontend
FROM nginx:alpine FROM nginx:alpine

View File

@ -68,14 +68,16 @@ CREATE TABLE IF NOT EXISTS company.companies (
CREATE TABLE IF NOT EXISTS company.users ( CREATE TABLE IF NOT EXISTS company.users (
user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK) user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK)
company_id uuid NOT NULL, -- 소속 회사(company.companies.company_id) company_id uuid NOT NULL, -- 소속 회사(company.companies.company_id)
id VARCHAR(20) NOT NULL, -- 로그인 ID id VARCHAR(64) NOT NULL, -- 로그인 ID (구글 계정은 google_<sub>)
password VARCHAR(255) NOT NULL, -- 해시된 비밀번호이어야 함 password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL
name VARCHAR(50) NULL, -- 이름 name VARCHAR(50) NULL, -- 이름
email VARCHAR(255) NULL, -- 이메일 email VARCHAR(255) NULL, -- 이메일
contact_number VARCHAR(20) NULL, -- 연락처 contact_number VARCHAR(20) NULL, -- 연락처
last_accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 마지막 접속 시각 last_accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 마지막 접속 시각
status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성) status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
role SMALLINT NOT NULL DEFAULT 1, -- 권한(UserRole): 1=user(일반), 2=owner(최고관리자), 3=developer(내부 운영) 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) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 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)로 건다. -- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE 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 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; 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 ALTER TABLE place.places
ADD COLUMN IF NOT EXISTS content_updated_at TIMESTAMPTZ NULL, -- 2026-08-27 노출값 변경 시각(개별 재빌드 판별) 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) ADD COLUMN IF NOT EXISTS external_source SMALLINT NULL; -- 2026-08-27 검증 소스(kakao/naver)
-- 2026-09-02 구글 로그인. 소셜 계정은 비밀번호가 없고(NULL), 로그인 아이디가 google_<sub> 라 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;

View File

@ -6,6 +6,7 @@ from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import text from sqlalchemy.sql import text
from common.enums import ( from common.enums import (
AuthProvider,
DBType, DBType,
UserStatus, UserStatus,
UserRole, UserRole,
@ -68,14 +69,22 @@ class users(MainTableMixin, MAIN_BASE):
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id = Column(UUID(as_uuid=True), nullable=False, index=True) company_id = Column(UUID(as_uuid=True), nullable=False, index=True)
id = Column(String(20), nullable=False, unique=True, index=True) # 로그인 아이디 # 20자였다. 구글 계정의 로그인 아이디를 `google_<sub>`(최대 28자)로 만들면서 넓혔다 —
password = Column(String(255), nullable=False) # bcrypt 해시 (ERD VARCHAR(30)→255 확장) # 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) name = Column(String(50), nullable=True)
email = Column(String(255), nullable=True) email = Column(String(255), nullable=True)
contact_number = Column(String(20), nullable=True) contact_number = Column(String(20), nullable=True)
last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value) status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value)
role = Column(SmallInteger, nullable=False, default=UserRole.USER.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 — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키
# ============================================================ # ============================================================

View File

@ -52,6 +52,9 @@ class ErrorType(Enum):
ACCOUNT_BLOCKED_USER = auto() ACCOUNT_BLOCKED_USER = auto()
ACCOUNT_NOT_FOUND = auto() ACCOUNT_NOT_FOUND = auto()
ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도 ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도
ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절)
OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다
OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패
# 사업장(places) 관련 에러 # 사업장(places) 관련 에러
PLACE_NOT_FOUND = 1200 PLACE_NOT_FOUND = 1200
@ -153,6 +156,17 @@ class UserRole(CodeEnum):
DEVELOPER = 3 # 개발자(내부 운영): 최고관리자 권한 전부 + 고객사에 보이지 않음 DEVELOPER = 3 # 개발자(내부 운영): 최고관리자 권한 전부 + 고객사에 보이지 않음
class AuthProvider(CodeEnum):
"""users.provider 코드값. 이 계정이 무엇으로 신원을 증명하는가.
한 계정은 수단 하나다 — 같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.
이으려면 "먼저 가입한 쪽의 소유"를 증명받아야 하는데, 그 증명 없이 이메일만 보고 이으면
남이 먼저 만들어 둔 계정에 내 구글 로그인이 들어간다(계정 선점). 보류 사유는 DECISIONS.md 1절."""
LOCAL = 1 # id/pw
GOOGLE = 2 # 구글 ID 토큰
class CompanyStatus(CodeEnum): class CompanyStatus(CodeEnum):
"""companies.status 코드값.""" """companies.status 코드값."""

View File

@ -99,6 +99,19 @@ class JwtToken(BaseSettings):
refresh_expire_day: int = Field(7, validation_alias="JWT_REFRESH_EXPIRE_DAY") 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): class ExternalApiConfig(BaseSettings):
"""키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다.""" """키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다."""
@ -144,3 +157,8 @@ def get_jwt_token_config() -> JwtToken:
@lru_cache @lru_cache
def get_external_api_config() -> ExternalApiConfig: def get_external_api_config() -> ExternalApiConfig:
return ExternalApiConfig() return ExternalApiConfig()
@lru_cache
def get_google_oauth_config() -> GoogleOAuthConfig:
return GoogleOAuthConfig()

View File

@ -1,12 +1,15 @@
"""설정 싱글턴 — 호출부 21개 파일이 이 이름들을 가져다 쓴다.""" """설정 싱글턴 — 호출부 21개 파일이 이 이름들을 가져다 쓴다."""
from config.config_models import ( from config.config_models import (
APP_ENV, # 재export — 테스트가 "지금 어느 환경으로 도는가" 를 여기서 읽는다(tests/test_config.py)
ExternalApiConfig, ExternalApiConfig,
GoogleOAuthConfig,
JwtToken, JwtToken,
LogConfig, LogConfig,
MainDBConfig, MainDBConfig,
WebServerConfig, WebServerConfig,
get_external_api_config, get_external_api_config,
get_google_oauth_config,
get_jwt_token_config, get_jwt_token_config,
get_log_config, get_log_config,
get_main_db_config, get_main_db_config,
@ -18,6 +21,7 @@ log_config: LogConfig = get_log_config()
main_db_config: MainDBConfig = get_main_db_config() main_db_config: MainDBConfig = get_main_db_config()
jwt_token_config: JwtToken = get_jwt_token_config() jwt_token_config: JwtToken = get_jwt_token_config()
external_api_config: ExternalApiConfig = get_external_api_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: if not jwt_token_config.access_key or not jwt_token_config.refresh_key:

View File

@ -19,6 +19,14 @@ class IUserCRUD(ABC):
async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]: async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]:
pass 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 @abstractmethod
async def is_user(self, cdb: AsyncSession, login_id: str) -> ErrorType: async def is_user(self, cdb: AsyncSession, login_id: str) -> ErrorType:
pass pass
@ -31,6 +39,10 @@ class IUserCRUD(ABC):
async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType: async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType:
pass pass
@abstractmethod
async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType:
pass
@abstractmethod @abstractmethod
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]: async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
pass pass
@ -66,6 +78,45 @@ class UserCRUD(IUserCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None 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: async def is_user(self, cdb: AsyncSession, login_id: str) -> ErrorType:
try: try:
query = select(users).where(users.id == login_id, users.deleted == False).limit(1) # noqa: E712 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) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED 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]: async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
try: try:
query = select(companies).where(companies.company_id == company_id, companies.deleted == False).limit(1) # noqa: E712 query = select(companies).where(companies.company_id == company_id, companies.deleted == False).limit(1) # noqa: E712

View File

@ -4,7 +4,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
from services.auth_service import AuthService 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() 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)) 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( @router.post(
path="/refresh_token", path="/refresh_token",
dependencies=[Depends(IsValidRefreshToken)], dependencies=[Depends(IsValidRefreshToken)],

View File

@ -2,7 +2,7 @@ from typing import Optional
from pydantic import Field from pydantic import Field
from common.enums import UserRole from common.enums import AuthProvider, UserRole
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -16,6 +16,29 @@ class Req_Login(AuthProtocol):
password: str = "" 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): class Res_Login(Res_WebPacketProtocol):
access_token: str = "" access_token: str = ""
refresh_token: str = "" refresh_token: str = ""
@ -47,4 +70,7 @@ class Res_Me(Res_WebPacketProtocol):
email: Optional[str] = None email: Optional[str] = None
contact_number: Optional[str] = None contact_number: Optional[str] = None
role: UserRole = UserRole.USER role: UserRole = UserRole.USER
# 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다)
# 내 정보 화면이 붙을 때 이 값으로 갈라야 한다.
provider: AuthProvider = AuthProvider.LOCAL
company: Optional[CompanyData] = Field(default=None) company: Optional[CompanyData] = Field(default=None)

View File

@ -1,14 +1,23 @@
import re
import uuid import uuid
from fastapi import Depends from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users from common.database.model.models import companies, users
from common.enums import DBWRType, ErrorType, UserRole, UserStatus from common.enums import AuthProvider, CompanyStatus, DBWRType, ErrorType, UserRole, UserStatus
from common.logger import LOG from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.user_crud import IUserCRUD, UserCRUD 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 ( from router.v1.validator.dependencies import (
CreateAccessToken, CreateAccessToken,
CreateRefreshToken, CreateRefreshToken,
@ -16,6 +25,35 @@ from router.v1.validator.dependencies import (
GetHashedPW, GetHashedPW,
VerifyPW, 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: class AuthService:
@ -41,6 +79,29 @@ class AuthService:
role=user.role, 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: async def attempt_login(self, login_id: str, password: str, connect_ip: str) -> Res_Login:
LOG.i(f"LOGIN : id={login_id}") LOG.i(f"LOGIN : id={login_id}")
res = Res_Login() res = Res_Login()
@ -57,31 +118,172 @@ class AuthService:
return res return res
user: users 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): if not await VerifyPW(password, user.password):
res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO) res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO)
return res return res
# 3) 상태 확인 (활성 아니면 차단) return await self._finish_login(user)
if user.status != UserStatus.ACTIVE.value:
res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER)
return res
# 4) 토큰 발급 # ---- 가입 --------------------------------------------------------------
user_info = self._user_info(user) async def _create_account(
res.access_token = CreateAccessToken(user_info) self,
res.refresh_token = CreateRefreshToken(user_info) *,
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( err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()], [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: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res 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: async def get_me(self, user_info: UserInfo) -> Res_Me:
res = Res_Me() res = Res_Me()
@ -113,6 +315,7 @@ class AuthService:
res.email = user.email res.email = user.email
res.contact_number = user.contact_number res.contact_number = user.contact_number
res.role = UserRole(user.role) res.role = UserRole(user.role)
res.provider = AuthProvider(user.provider)
res.company = company res.company = company
return res return res
@ -122,6 +325,22 @@ class AuthService:
# 비밀번호: 값 있으면 해시 교체, 비었으면 변경 안 함. # 비밀번호: 값 있으면 해시 교체, 비었으면 변경 안 함.
if data.get("password"): 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"]) data["password"] = await GetHashedPW(data["password"])
else: else:
data.pop("password", None) data.pop("password", None)

View File

@ -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 ""))

View File

@ -41,3 +41,119 @@ async def test_me_without_token_is_rejected(client):
기대결과: 인증 단계에서 거부 — HTTP 401 또는 403.""" 기대결과: 인증 단계에서 거부 — HTTP 401 또는 403."""
r = await client.get("/v1/auth/me") r = await client.get("/v1/auth/me")
assert r.status_code in (401, 403) 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

View File

@ -4,7 +4,7 @@
.env 에 APP_ENV=local 이 들어 있어도 테스트는 반드시 test DB 를 봐야 한다 — .env 에 APP_ENV=local 이 들어 있어도 테스트는 반드시 test DB 를 봐야 한다 —
그 규칙이 깨지면 db_engine 픽스처의 TRUNCATE 가 dev 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(): 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"): for name in ("perplexity_api_key", "kakao_rest_api_key", "gemini_api_key", "tour_api_key"):
# 값 자체는 출력하지 않는다(실키가 로그·CI 에 남지 않게). # 값 자체는 출력하지 않는다(실키가 로그·CI 에 남지 않게).
assert not getattr(external_api_config, name), f"{name} 이 테스트 환경에 설정돼 있다 — .env 가 새어 들어왔다" assert not getattr(external_api_config, name), f"{name} 이 테스트 환경에 설정돼 있다 — .env 가 새어 들어왔다"
# 구글 client_id 도 같은 규칙이다. 이게 새어 들어오면 구글 로그인 테스트가 진짜 JWKS 를 받으러 나간다.
assert not google_oauth_config.client_id, "GOOGLE_CLIENT_ID 가 테스트 환경에 설정돼 있다 — .env 가 새어 들어왔다"

View File

@ -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))

View File

@ -9,3 +9,8 @@ VITE_PUBLISH_HOST=w4ai.o2o.kr
# 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다. # 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다.
VITE_SITE_PREVIEW_URL=http://localhost:3000 VITE_SITE_PREVIEW_URL=http://localhost:3000
# 구글 로그인 클라이언트 ID. 비우면 구글 버튼이 아예 안 뜬다(누르면 실패하는 버튼을 두지 않는다).
# ★ 로컬에서 compose 없이 띄울 때만 쓴다. compose 로 띄우면 루트 .env 의 GOOGLE_CLIENT_ID 가
# 주입돼 이 값을 덮는다 — 백엔드의 aud 대조와 같은 값이어야 하므로 단일 출처는 루트 .env 다.
VITE_GOOGLE_CLIENT_ID=

View File

@ -25,7 +25,9 @@ import type {
import type { import type {
HTTPValidationError, HTTPValidationError,
ReqGoogleLogin,
ReqLogin, ReqLogin,
ReqSignup,
ReqUpdateMe, ReqUpdateMe,
ResLogin, ResLogin,
ResMe, ResMe,
@ -105,6 +107,136 @@ export const useLogin = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
} }
/** /**
* id/pw 로 가입한다. 회사(테넌트) 1개가 함께 생기고, 성공하면 곧바로 로그인 토큰을 발급한다.
* @summary 회원가입
*/
export const signup = (
reqSignup: ReqSignup,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResLogin>(
{url: `/v1/auth/signup`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: reqSignup, signal
},
options);
}
export const getSignupMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof signup>>, TError,{data: ReqSignup}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof signup>>, 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<Awaited<ReturnType<typeof signup>>, {data: ReqSignup}> = (props) => {
const {data} = props ?? {};
return signup(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type SignupMutationResult = NonNullable<Awaited<ReturnType<typeof signup>>>
export type SignupMutationBody = ReqSignup
export type SignupMutationError = void | HTTPValidationError
/**
* @summary 회원가입
*/
export const useSignup = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof signup>>, TError,{data: ReqSignup}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof signup>>,
TError,
{data: ReqSignup},
TContext
> => {
const mutationOptions = getSignupMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* 구글 ID 토큰(GIS credential)을 검증하고 JWT 토큰을 발급한다. 처음 온 계정은 그 자리에서 만든다.
* @summary 구글 로그인
*/
export const googleLogin = (
reqGoogleLogin: ReqGoogleLogin,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResLogin>(
{url: `/v1/auth/google`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: reqGoogleLogin, signal
},
options);
}
export const getGoogleLoginMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof googleLogin>>, TError,{data: ReqGoogleLogin}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof googleLogin>>, 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<Awaited<ReturnType<typeof googleLogin>>, {data: ReqGoogleLogin}> = (props) => {
const {data} = props ?? {};
return googleLogin(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type GoogleLoginMutationResult = NonNullable<Awaited<ReturnType<typeof googleLogin>>>
export type GoogleLoginMutationBody = ReqGoogleLogin
export type GoogleLoginMutationError = void | HTTPValidationError
/**
* @summary 구글 로그인
*/
export const useGoogleLogin = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof googleLogin>>, TError,{data: ReqGoogleLogin}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof googleLogin>>,
TError,
{data: ReqGoogleLogin},
TContext
> => {
const mutationOptions = getGoogleLoginMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* refresh 토큰으로 access 토큰을 재발급한다. * refresh 토큰으로 access 토큰을 재발급한다.
* @summary 액세스 토큰 갱신 * @summary 액세스 토큰 갱신
*/ */

View File

@ -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;

View File

@ -7,6 +7,7 @@
export * from './auditCheckData'; export * from './auditCheckData';
export * from './auditCheckDataRecommendation'; export * from './auditCheckDataRecommendation';
export * from './authProvider';
export * from './buildStatus'; export * from './buildStatus';
export * from './checkSlugParams'; export * from './checkSlugParams';
export * from './companyData'; export * from './companyData';
@ -120,8 +121,12 @@ export * from './reqCreatePlace';
export * from './reqCreatePlaceOwnerUserId'; export * from './reqCreatePlaceOwnerUserId';
export * from './reqCreateUnit'; export * from './reqCreateUnit';
export * from './reqExtractFacts'; export * from './reqExtractFacts';
export * from './reqGoogleLogin';
export * from './reqLogin'; export * from './reqLogin';
export * from './reqPublishLocalContent'; export * from './reqPublishLocalContent';
export * from './reqSignup';
export * from './reqSignupCompanyName';
export * from './reqSignupName';
export * from './reqSiteSlug'; export * from './reqSiteSlug';
export * from './reqSiteStatus'; export * from './reqSiteStatus';
export * from './reqSiteTemplate'; export * from './reqSiteTemplate';

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -10,6 +10,7 @@ import type { ResMeName } from './resMeName';
import type { ResMeEmail } from './resMeEmail'; import type { ResMeEmail } from './resMeEmail';
import type { ResMeContactNumber } from './resMeContactNumber'; import type { ResMeContactNumber } from './resMeContactNumber';
import type { UserRole } from './userRole'; import type { UserRole } from './userRole';
import type { AuthProvider } from './authProvider';
import type { ResMeCompany } from './resMeCompany'; import type { ResMeCompany } from './resMeCompany';
export interface ResMe { export interface ResMe {
@ -21,5 +22,6 @@ export interface ResMe {
email?: ResMeEmail; email?: ResMeEmail;
contact_number?: ResMeContactNumber; contact_number?: ResMeContactNumber;
role?: UserRole; role?: UserRole;
provider?: AuthProvider;
company?: ResMeCompany; company?: ResMeCompany;
} }

View File

@ -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<HTMLDivElement>(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 (
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
구글 로그인을 불러오지 못했습니다. 아이디/비밀번호로 로그인해 주세요.
</p>
);
}
// 높이를 미리 잡아 둔다 — 버튼이 뒤늦게 그려지면서 아래 내용이 밀리지 않게.
return <div ref={holder} className="flex h-10 w-full items-center justify-center" />;
}

View File

@ -1,6 +1,5 @@
import {getAccessToken, login, me} from '@/api'; import {getAccessToken, login} from '@/api';
import {UserRole} from '@/api'; import {establishSession} from '@/lib/session';
import {toAuthUser, useAuthStore} from '@/stores/auth';
/** /**
* 계정이 주입돼 있으면 화면을 열 때 조용히 세션을 확보한다. * 계정이 주입돼 있으면 화면을 열 때 조용히 세션을 확보한다.
@ -28,15 +27,7 @@ export function ensureAutoSession(): Promise<void> {
pending = (async () => { pending = (async () => {
try { try {
const res = await login({id, password}); await establishSession(await login({id, password}), id);
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));
} catch { } catch {
// 편의 기능이다 — 실패해도 화면을 막지 않는다(2단계가 안내를 띄운다). // 편의 기능이다 — 실패해도 화면을 막지 않는다(2단계가 안내를 띄운다).
} finally { } finally {

View File

@ -13,6 +13,12 @@ export const ERROR_MESSAGE: Record<string, string> = {
ACCOUNT_BLOCKED_USER: '차단된 계정입니다. 운영자에게 문의해 주세요.', ACCOUNT_BLOCKED_USER: '차단된 계정입니다. 운영자에게 문의해 주세요.',
ACCOUNT_NOT_FOUND: '계정을 찾을 수 없습니다.', ACCOUNT_NOT_FOUND: '계정을 찾을 수 없습니다.',
ACCOUNT_FORBIDDEN: '이 작업을 할 권한이 없습니다.', ACCOUNT_FORBIDDEN: '이 작업을 할 권한이 없습니다.',
ACCOUNT_ALREADY_EXIST: '이미 사용 중인 아이디 또는 이메일입니다.',
// 자동 연결을 하지 않기로 한 결과라, "다시 시도" 가 아니라 **어느 쪽으로 들어가야 하는지**를 적는다.
ACCOUNT_PROVIDER_CONFLICT: '다른 방법으로 가입된 계정입니다. 처음 가입할 때 쓴 방법으로 로그인해 주세요.',
OAUTH_NOT_CONFIGURED: '구글 로그인이 아직 설정되지 않았습니다. 운영자에게 알려주세요.',
OAUTH_INVALID_TOKEN: '구글 로그인 정보를 확인하지 못했습니다. 다시 시도해 주세요.',
INVALID_REQUEST_DATA: '입력값을 다시 확인해 주세요.',
// 사업장 — 검증 게이트 // 사업장 — 검증 게이트
PLACE_NOT_FOUND: '사업장을 찾을 수 없습니다.', PLACE_NOT_FOUND: '사업장을 찾을 수 없습니다.',

View File

@ -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<GoogleIdApi> | null = null;
export function loadGoogleIdentity(): Promise<GoogleIdApi> {
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<GoogleIdApi>((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;
}

View File

@ -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<boolean> {
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;
}

View File

@ -1,25 +1,34 @@
import {useState, type FormEvent} from 'react'; 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} 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 {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {isGoogleLoginEnabled} from '@/lib/googleIdentity';
import {notifyApiError} from '@/lib/notify'; 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 navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const signIn = useAuthStore((s) => s.signIn);
const [id, setId] = useState(''); const [id, setId] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록) // ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)
// 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다. // 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다.
const from = (location.state as {from?: string} | null)?.from ?? '/'; const from = (location.state as {from?: string} | null)?.from ?? '/';
if (user) return <Navigate to={from} replace />; if (user) return <Navigate to={from} replace />;
@ -32,20 +41,10 @@ const from = (location.state as {from?: string} | null)?.from ?? '/';
notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.'); notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.');
return; return;
} }
// ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다. if (!(await establishSession(res, id))) {
// 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다.
if (!res.access_token || !res.refresh_token) {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return; 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}); navigate(from, {replace: true});
} catch (error) { } catch (error) {
notifyApiError(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 ( return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4"> <div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
<form <form
@ -66,9 +85,13 @@ const from = (location.state as {from?: string} | null)?.from ?? '/';
alt="Web4Ai" alt="Web4Ai"
className="mx-auto mb-3 h-9 w-auto" className="mx-auto mb-3 h-9 w-auto"
/> />
<h1 className="text-sm font-medium text-muted-foreground">관리자</h1> <h1 className="text-sm font-medium text-muted-foreground">
{selfServe ? '로그인' : '관리자'}
</h1>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
사업장·정보 확인·발행을 관리합니다. {selfServe
? '내 가게 사이트를 만들고 발행합니다.'
: '사업장·정보 확인·발행을 관리합니다.'}
</p> </p>
</div> </div>
@ -105,12 +128,25 @@ const from = (location.state as {from?: string} | null)?.from ?? '/';
<span>로그인</span> <span>로그인</span>
</Button> </Button>
<p className="text-center text-[11px] leading-relaxed text-muted-foreground"> {selfServe && isGoogleLoginEnabled() && (
빌더는 로그인 없이도 사용할 수 있습니다.{' '} <>
<a href="/builder" className="font-medium text-primary underline-offset-2 hover:underline"> <div className="flex items-center gap-2">
빌더로 이동 <span className="h-px flex-1 bg-border" />
</a> <span className="text-[11px] text-muted-foreground">또는</span>
</p> <span className="h-px flex-1 bg-border" />
</div>
<GoogleSignInButton onCredential={handleGoogle} text="continue_with" />
</>
)}
{selfServe && (
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
아직 계정이 없으신가요?{' '}
<Link to="/signup" className="font-medium text-primary underline-offset-2 hover:underline">
회원가입
</Link>
</p>
)}
</form> </form>
</div> </div>
); );

View File

@ -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 <Navigate to="/" replace />;
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 (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-8">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7"
>
<div className="space-y-1 text-center">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="mx-auto mb-3 h-9 w-auto" />
<h1 className="text-sm font-medium text-muted-foreground">회원가입</h1>
<p className="text-xs text-muted-foreground">내 가게 사이트를 만들려면 계정이 필요합니다.</p>
</div>
<div className="space-y-3">
<div>
<label htmlFor="signup-id" className="mb-1.5 block text-xs font-semibold">
아이디
</label>
<Input
id="signup-id"
value={form.id}
onChange={set('id')}
autoComplete="username"
placeholder="영문으로 시작하는 4~20자"
required
/>
</div>
<div>
<label htmlFor="signup-pw" className="mb-1.5 block text-xs font-semibold">
비밀번호
</label>
<Input
id="signup-pw"
type="password"
value={form.password}
onChange={set('password')}
autoComplete="new-password"
placeholder={`${MIN_PASSWORD_LEN}자 이상`}
required
/>
</div>
<div>
<label htmlFor="signup-pw2" className="mb-1.5 block text-xs font-semibold">
비밀번호 확인
</label>
<Input
id="signup-pw2"
type="password"
value={form.passwordConfirm}
onChange={set('passwordConfirm')}
autoComplete="new-password"
required
/>
</div>
<div>
<label htmlFor="signup-name" className="mb-1.5 block text-xs font-semibold">
이름
</label>
<Input id="signup-name" value={form.name} onChange={set('name')} autoComplete="name" />
</div>
<div>
<label htmlFor="signup-email" className="mb-1.5 block text-xs font-semibold">
이메일
</label>
<Input
id="signup-email"
type="email"
value={form.email}
onChange={set('email')}
autoComplete="email"
required
/>
</div>
<div>
<label htmlFor="signup-company" className="mb-1.5 block text-xs font-semibold">
상호 <span className="font-normal text-muted-foreground">(선택)</span>
</label>
<Input
id="signup-company"
value={form.companyName}
onChange={set('companyName')}
placeholder="비우면 이름으로 채웁니다"
/>
</div>
</div>
<Button type="submit" variant="primary" className="w-full" isLoading={isSubmitting}>
<UserPlus />
<span>가입하기</span>
</Button>
{isGoogleLoginEnabled() && (
<>
<div className="flex items-center gap-2">
<span className="h-px flex-1 bg-border" />
<span className="text-[11px] text-muted-foreground">또는</span>
<span className="h-px flex-1 bg-border" />
</div>
<GoogleSignInButton onCredential={handleGoogle} text="signup_with" />
</>
)}
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
이미 계정이 있으신가요?{' '}
<Link to="/login" className="font-medium text-primary underline-offset-2 hover:underline">
로그인
</Link>
</p>
</form>
</div>
);
}

View File

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