[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:
parent
c250b656fd
commit
48109fdc99
@ -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
|
||||||
|
|||||||
@ -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 을 앞에 세워야 한다. 아니면 비워라.
|
||||||
|
|||||||
@ -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 />},
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@ -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 코드값."""
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)],
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
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
|
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)
|
||||||
|
|||||||
138
solution/backend/services/external/google_identity.py
vendored
Normal file
138
solution/backend/services/external/google_identity.py
vendored
Normal 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 ""))
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 가 새어 들어왔다"
|
||||||
|
|||||||
144
solution/backend/tests/test_google_identity.py
Normal file
144
solution/backend/tests/test_google_identity.py
Normal 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))
|
||||||
@ -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=
|
||||||
|
|||||||
@ -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 액세스 토큰 갱신
|
||||||
*/
|
*/
|
||||||
|
|||||||
22
solution/frontend/src/api/generated/model/authProvider.ts
Normal file
22
solution/frontend/src/api/generated/model/authProvider.ts
Normal 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;
|
||||||
@ -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';
|
||||||
|
|||||||
16
solution/frontend/src/api/generated/model/reqGoogleLogin.ts
Normal file
16
solution/frontend/src/api/generated/model/reqGoogleLogin.ts
Normal 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;
|
||||||
|
}
|
||||||
23
solution/frontend/src/api/generated/model/reqSignup.ts
Normal file
23
solution/frontend/src/api/generated/model/reqSignup.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
76
solution/frontend/src/components/auth/GoogleSignInButton.tsx
Normal file
76
solution/frontend/src/components/auth/GoogleSignInButton.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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: '사업장을 찾을 수 없습니다.',
|
||||||
|
|||||||
91
solution/frontend/src/lib/googleIdentity.ts
Normal file
91
solution/frontend/src/lib/googleIdentity.ts
Normal 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;
|
||||||
|
}
|
||||||
24
solution/frontend/src/lib/session.ts
Normal file
24
solution/frontend/src/lib/session.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|
||||||
|
{selfServe && 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="continue_with" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selfServe && (
|
||||||
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
|
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
|
||||||
빌더는 로그인 없이도 사용할 수 있습니다.{' '}
|
아직 계정이 없으신가요?{' '}
|
||||||
<a href="/builder" className="font-medium text-primary underline-offset-2 hover:underline">
|
<Link to="/signup" className="font-medium text-primary underline-offset-2 hover:underline">
|
||||||
빌더로 이동
|
회원가입
|
||||||
</a>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
218
solution/frontend/src/pages/SignupPage.tsx
Normal file
218
solution/frontend/src/pages/SignupPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
solution/frontend/src/vite-env.d.ts
vendored
2
solution/frontend/src/vite-env.d.ts
vendored
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user