From c4efd6406d83b620f4ceec6788d47b6d94d58fc3 Mon Sep 17 00:00:00 2001 From: jaehwang Date: Tue, 15 Sep 2026 16:00:54 +0900 Subject: [PATCH] add login --- backend/README.md | 23 +++- backend/main.py | 3 +- backend/pipelines/playreel.py | 7 +- backend/pipelines/poster_alive.py | 5 +- backend/pyproject.toml | 1 + backend/routers/archive.py | 1 + backend/routers/auth.py | 110 +++++++++++++++ backend/routers/f1.py | 13 +- backend/routers/playreel.py | 15 +- backend/settings.py | 6 + backend/tables/task.py | 4 + backend/tables/user.py | 42 ++++++ backend/utils/database.py | 5 +- backend/utils/google_identity.py | 116 ++++++++++++++++ backend/utils/session_token.py | 41 ++++++ backend/uv.lock | 181 +++++++++++++++++++++++++ frontend/app/(ado2)/layout.tsx | 5 +- frontend/app/(playreel)/layout.tsx | 5 +- frontend/components/auth-gate.tsx | 42 ++++++ frontend/components/google-sign-in.tsx | 54 ++++++++ frontend/lib/auth.ts | 122 +++++++++++++++++ frontend/lib/prefs.ts | 2 +- 22 files changed, 786 insertions(+), 17 deletions(-) create mode 100644 backend/routers/auth.py create mode 100644 backend/tables/user.py create mode 100644 backend/utils/google_identity.py create mode 100644 backend/utils/session_token.py create mode 100644 frontend/components/auth-gate.tsx create mode 100644 frontend/components/google-sign-in.tsx create mode 100644 frontend/lib/auth.ts diff --git a/backend/README.md b/backend/README.md index 082501a..b899f36 100644 --- a/backend/README.md +++ b/backend/README.md @@ -26,8 +26,29 @@ uv sync | `TYPECAST_API_KEY` | 롱컷 tts (축제는 OpenAI를 써서 필요 없다) | | `SUNO_API_KEY` · `SUNO_CALLBACK_URL` | bgm | | `HIGGSFIELD_ACCOUNT` | upscale · i2v 계정 가드 | -| `MYSQL_URL` | DB 세션 | +| `MYSQL_HOST` · `MYSQL_PORT` · `MYSQL_USER` · `MYSQL_PASSWORD` · `MYSQL_DB` | DB 세션 | | `AZURE_BLOB_BASE_URL` · `AZURE_BLOB_SAS_TOKEN` | blob 업로드 | +| `GOOGLE_CLIENT_ID` | 구글 로그인. 비면 로그인만 꺼지고 서버는 뜬다 | +| `JWT_SECRET` | 세션 쿠키 서명. 바뀌면 로그인된 사람이 전부 풀린다 | + +### 구글 로그인 + +구글 클라우드 콘솔에서 **웹 애플리케이션** OAuth 클라이언트를 만들고 승인된 자바스크립트 +원본에 서비스 주소를 넣는다. 발급된 client id를 `GOOGLE_CLIENT_ID`에 넣는다. +`client_secret`은 쓰지 않는다 — 코드 교환 없이 ID 토큰만 검증한다. + +`JWT_SECRET`은 아무 긴 임의 문자열이면 된다. + +```bash +python3 -c "import secrets; print(secrets.token_urlsafe(48))" +``` + +사용자는 처음 로그인할 때 만들어지고 잡을 `job_limit`(기본 3)개까지 만들 수 있다. +무제한으로 둘 계정은 `user` 테이블의 그 값을 올린다. + +```sql +UPDATE user SET job_limit = 100000 WHERE email = 'jhyeu@o2o.kr'; +``` ### Docker diff --git a/backend/main.py b/backend/main.py index b4a4b63..4ff1e4b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,7 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from pipelines import worker -from routers import archive, f1, playreel +from routers import archive, auth, f1, playreel from utils import blob from utils.database import close_engine, create_missing_tables @@ -21,6 +21,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="poster-alive", lifespan=lifespan) +app.include_router(auth.router) app.include_router(f1.router) app.include_router(playreel.router) app.include_router(archive.router) diff --git a/backend/pipelines/playreel.py b/backend/pipelines/playreel.py index 5e52cb0..cd854e7 100644 --- a/backend/pipelines/playreel.py +++ b/backend/pipelines/playreel.py @@ -33,6 +33,7 @@ from services.split_detail import split_details from services.tts import synthesize from services.upscale_poster import as_png_path, upscale_poster from tables.task import PlayreelTask +from tables.user import UNASSIGNED_USER_ID from utils import blob from utils.higgsfield import KLING_3_0 from utils.video import frames_at @@ -79,10 +80,10 @@ def thumbnail_url(task: PlayreelTask) -> str | None: return artifact_url(PIPELINE, task.id, THUMBNAIL) if task.video_url else None -async def create_task(session: AsyncSession, url: str, goods_id: str, - slug: str) -> PlayreelTask: +async def create_task(session: AsyncSession, url: str, goods_id: str, slug: str, *, + user_id: str = UNASSIGNED_USER_ID) -> PlayreelTask: task = PlayreelTask(source_url=url, goods_id=goods_id, slug=slug, - name=f"공연 {goods_id}", + name=f"공연 {goods_id}", user_id=user_id, stage_timings=initial_timings(PlayreelState)) session.add(task) await session.commit() diff --git a/backend/pipelines/poster_alive.py b/backend/pipelines/poster_alive.py index e284401..b0b60d3 100644 --- a/backend/pipelines/poster_alive.py +++ b/backend/pipelines/poster_alive.py @@ -24,6 +24,7 @@ from services.render import render from services.tts import synthesize from services.upscale_poster import as_png_path from tables.task import PosterAliveTask, new_task_id +from tables.user import UNASSIGNED_USER_ID from utils import blob from utils.image import sniff_extension @@ -31,10 +32,12 @@ PIPELINE = "poster_alive" async def create_task(session: AsyncSession, name: str, poster: bytes, *, - skip_review: bool = False) -> PosterAliveTask: + skip_review: bool = False, + user_id: str = UNASSIGNED_USER_ID) -> PosterAliveTask: # poster_url이 NOT NULL이라 blob에 올린 뒤에야 행을 넣을 수 있다. # 그 경로에 id가 필요하므로 여기서 미리 만든다 task = PosterAliveTask(id=new_task_id(), name=name, skip_review=skip_review, + user_id=user_id, stage_timings=initial_timings(PosterAliveState)) with Image.open(io.BytesIO(poster)) as image: task.poster_width, task.poster_height = image.size diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7d4488e..8a06776 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "openai>=3.6.0", "pillow>=12.3.0", "pydantic-settings>=2.15.0", + "python-jose[cryptography]>=3.5.0", "python-multipart>=0.0.32", "scipy>=1.18.1", "sqlalchemy>=2.0.52", diff --git a/backend/routers/archive.py b/backend/routers/archive.py index 4769362..7cd8647 100644 --- a/backend/routers/archive.py +++ b/backend/routers/archive.py @@ -15,6 +15,7 @@ from routers.view import UNNAMED from tables.task import PlayreelTask, PosterAliveTask from utils.database import get_session +# 완성본 구경은 로그인 없이 연다 router = APIRouter(prefix="/api/archive", tags=["archive"]) diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..e1d6ceb --- /dev/null +++ b/backend/routers/auth.py @@ -0,0 +1,110 @@ +"""/api/auth — 구글 로그인과 세션 + +브라우저가 구글에서 받아온 credential을 한 번 검증하고, 그 뒤로는 자체 쿠키를 쓴다. +""" +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from tables.user import User +from utils import session_token +from utils.database import get_session +from utils.google_identity import (GoogleLoginDisabled, GoogleTokenInvalid, is_enabled, + verify_id_token) + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class GoogleLoginBody(BaseModel): + credential: str + + +def user_view(user: User) -> dict: + """구글 sub는 내보내지 않는다 — 화면이 쓸 일이 없다""" + return { + "email": user.email, + "name": user.name, + "jobs_created": user.jobs_created, + "job_limit": user.job_limit, + "jobs_left": user.jobs_left, + } + + +async def find_or_create(session: AsyncSession, account) -> User: + user = await session.get(User, account.sub) + if user is None: + user = User(id=account.sub, email=account.email, name=account.name) + session.add(user) + else: + # 이메일·이름은 구글 쪽에서 바뀔 수 있어 로그인할 때마다 맞춘다 + user.email, user.name = account.email, account.name + await session.commit() + return user + + +@router.get("/config") +async def config(): + """프론트가 버튼을 그릴지 정하는 데 쓴다. client_id는 비밀이 아니다""" + from settings import settings + return {"enabled": is_enabled(), "client_id": settings.google_client_id} + + +@router.post("/google") +async def google_login(body: GoogleLoginBody, response: Response, + session: AsyncSession = Depends(get_session)): + try: + account = await verify_id_token(body.credential) + except GoogleLoginDisabled: + raise HTTPException(503, "구글 로그인이 설정되지 않았습니다") + except GoogleTokenInvalid as failure: + raise HTTPException(401, str(failure)) + + user = await find_or_create(session, account) + response.set_cookie( + session_token.COOKIE_NAME, session_token.issue(user.id), + max_age=session_token.cookie_max_age(), + httponly=True, samesite="lax", path="/", + ) + return user_view(user) + + +@router.get("/me") +async def me(request: Request, session: AsyncSession = Depends(get_session)): + user = await optional_user(request, session) + if user is None: + raise HTTPException(401, "로그인이 필요합니다") + return user_view(user) + + +@router.post("/logout") +async def logout(response: Response): + response.delete_cookie(session_token.COOKIE_NAME, path="/") + return {"ok": True} + + +async def optional_user(request: Request, session: AsyncSession) -> User | None: + token = request.cookies.get(session_token.COOKIE_NAME) + if not token: + return None + try: + user_id = session_token.read(token) + except session_token.SessionInvalid: + return None + return await session.get(User, user_id) + + +async def current_user(request: Request, + session: AsyncSession = Depends(get_session)) -> User: + """로그인이 필요한 엔드포인트가 의존성으로 받는다""" + user = await optional_user(request, session) + if user is None: + raise HTTPException(401, "로그인이 필요합니다") + return user + + +async def creating_user(user: User = Depends(current_user)) -> User: + """잡을 만드는 엔드포인트용 — 한도를 여기서 막는다""" + if not user.can_create_job: + raise HTTPException( + 403, f"만들 수 있는 개수를 다 썼습니다 ({user.jobs_created}/{user.job_limit})") + return user diff --git a/backend/routers/f1.py b/backend/routers/f1.py index 7ac5c3f..2734ff8 100644 --- a/backend/routers/f1.py +++ b/backend/routers/f1.py @@ -11,13 +11,17 @@ from models.pipeline_state import PosterAliveState from pipelines import poster_alive from pipelines.artifact import load_image from pipelines.runner import AWAITING_REVIEW, FAILED, QUEUED, RUNNING, reset_from +from routers.auth import creating_user, current_user from routers.view import motion_elements, poster_alive_view +from tables.user import User from services.i2v import MIN_LONG_EDGE_PX from services.motion import plan_motion from tables.task import PosterAliveTask from utils.database import get_session -router = APIRouter(prefix="/api/f1", tags=["f1"]) +# 이 라우터의 모든 경로가 로그인을 요구한다. 잡 생성만 한도 검사를 더 받는다 +router = APIRouter(prefix="/api/f1", tags=["f1"], + dependencies=[Depends(current_user)]) ACCEPTED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} MAX_UPLOAD_BYTES = 30 * 1024 * 1024 @@ -60,6 +64,7 @@ async def rewrite_motion_plan(task: PosterAliveTask, motions: list[str]) -> None @router.post("/jobs") async def create(poster: UploadFile = File(...), name: str = Form(""), auto: bool = Form(False), + user: User = Depends(creating_user), session: AsyncSession = Depends(get_session)): if poster.content_type not in ACCEPTED_TYPES: raise HTTPException(415, f"지원 형식: JPG/PNG/WEBP/GIF (받은 것: {poster.content_type})") @@ -81,7 +86,11 @@ async def create(poster: UploadFile = File(...), name: str = Form(""), f"(받은 것: {width}x{height}). 고해상 원본으로 올려주세요.") # 이름을 비워 두면 narration_text 가 포스터에서 읽은 행사명으로 채운다 - task = await poster_alive.create_task(session, name.strip(), raw, skip_review=auto) + task = await poster_alive.create_task(session, name.strip(), raw, skip_review=auto, + user_id=user.id) + # 잡을 지워도 회복되지 않게 누적값을 올린다 + user.jobs_created += 1 + await session.commit() return {"id": task.id, "ahead": 0} diff --git a/backend/routers/playreel.py b/backend/routers/playreel.py index 222f4ae..a4b519e 100644 --- a/backend/routers/playreel.py +++ b/backend/routers/playreel.py @@ -11,11 +11,15 @@ from pipelines import playreel from pipelines.gate import (REVERSIBLE_GATES, PlayreelGate, apply_playreel_edits, previous_gate, resume_stage) from pipelines.runner import AWAITING_REVIEW, DONE, FAILED, QUEUED, RUNNING, reset_from +from routers.auth import creating_user, current_user from routers.view import playreel_view from tables.task import PlayreelTask +from tables.user import User from utils.database import get_session -router = APIRouter(prefix="/api/playreel", tags=["playreel"]) +# 이 라우터의 모든 경로가 로그인을 요구한다. 잡 생성만 한도 검사를 더 받는다 +router = APIRouter(prefix="/api/playreel", tags=["playreel"], + dependencies=[Depends(current_user)]) # 프론트 parseGoodsId와 같은 규칙. 프론트 검사는 입력 도중의 안내이고 판정은 여기서 한다 GOODS_ID_PATTERNS = ( @@ -56,7 +60,8 @@ async def find_task(session: AsyncSession, task_id: str) -> PlayreelTask: @router.post("/jobs") -async def create(body: CreateBody, session: AsyncSession = Depends(get_session)): +async def create(body: CreateBody, user: User = Depends(creating_user), + session: AsyncSession = Depends(get_session)): goods_id = parse_goods_id(body.url) if not goods_id: raise HTTPException(400, "지원하지 않는 주소입니다. 공연 상품페이지 주소를 넣어 주세요.") @@ -64,7 +69,11 @@ async def create(body: CreateBody, session: AsyncSession = Depends(get_session)) if not SLUG_PATTERN.fullmatch(slug): raise HTTPException(400, "slug는 영문 소문자·숫자·밑줄만 씁니다") - task = await playreel.create_task(session, body.url.strip(), goods_id, slug) + task = await playreel.create_task(session, body.url.strip(), goods_id, slug, + user_id=user.id) + # 잡을 지워도 회복되지 않게 누적값을 올린다 + user.jobs_created += 1 + await session.commit() return {"id": task.id} diff --git a/backend/settings.py b/backend/settings.py index 8caa5ca..abac19c 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -21,6 +21,12 @@ class Settings(BaseSettings): azure_blob_base_url: str = "" azure_blob_sas_token: str = "" + # 비어 있으면 구글 로그인이 꺼진다. 값은 비밀이 아니라 프론트 번들에도 들어간다 + google_client_id: str = "" + # 자체 세션 토큰 서명 키. 바뀌면 로그인된 사람이 전부 풀린다 + jwt_secret: str = "" + jwt_days: int = 14 + @property def mysql_url(self) -> str: password = quote_plus(self.mysql_password) diff --git a/backend/tables/task.py b/backend/tables/task.py index 8703414..3495334 100644 --- a/backend/tables/task.py +++ b/backend/tables/task.py @@ -9,6 +9,7 @@ from sqlalchemy import JSON, Boolean, DateTime, Enum, Float, Integer, String, Te from sqlalchemy.orm import Mapped, mapped_column from models.pipeline_state import PlayreelState, PosterAliveState +from tables.user import UNASSIGNED_USER_ID, USER_ID_LENGTH from utils.database import Base URL_LENGTH = 512 @@ -31,6 +32,9 @@ class TaskBase(Base): id: Mapped[str] = mapped_column(String(TASK_ID_LENGTH), primary_key=True, default=new_task_id) + # 만든 사람의 구글 sub. 모르면 UNASSIGNED_USER_ID가 들어간다 + user_id: Mapped[str] = mapped_column(String(USER_ID_LENGTH), index=True, + default=UNASSIGNED_USER_ID) name: Mapped[str] = mapped_column(String(255), default="") status: Mapped[str] = mapped_column(String(32), default="queued", index=True) diff --git a/backend/tables/user.py b/backend/tables/user.py new file mode 100644 index 0000000..202a7c6 --- /dev/null +++ b/backend/tables/user.py @@ -0,0 +1,42 @@ +"""구글 계정으로 로그인한 사람 + +id가 곧 구글 sub다. 이메일이 바뀌어도 유지되는 값이라 따로 발급하지 않는다. +잡 생성 횟수는 행을 세지 않고 누적값을 올린다 — 잡을 지워도 회복되지 않는다. +""" +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from utils.database import Base + +USER_ID_LENGTH = 64 +EMAIL_LENGTH = 320 +DEFAULT_JOB_LIMIT = 3 + +# 소유자를 모르는 잡의 자리. 나중에 admin의 sub로 덮어쓴다 +UNASSIGNED_USER_ID = "0" + + +class User(Base): + __tablename__ = "user" + + id: Mapped[str] = mapped_column(String(USER_ID_LENGTH), primary_key=True) + email: Mapped[str] = mapped_column(String(EMAIL_LENGTH), index=True) + name: Mapped[str] = mapped_column(String(255), default="") + + # 만들 수 있는 총 잡 수. 무제한으로 둘 계정은 이 값을 크게 올린다 + job_limit: Mapped[int] = mapped_column(Integer, default=DEFAULT_JOB_LIMIT) + jobs_created: Mapped[int] = mapped_column(Integer, default=0) + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + last_login_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), + onupdate=func.now()) + + @property + def can_create_job(self) -> bool: + return self.jobs_created < self.job_limit + + @property + def jobs_left(self) -> int: + return max(0, self.job_limit - self.jobs_created) diff --git a/backend/utils/database.py b/backend/utils/database.py index 445c796..6a0ad1c 100644 --- a/backend/utils/database.py +++ b/backend/utils/database.py @@ -1,6 +1,5 @@ -"""MySQL 비동기 세션. - -FastAPI 의존성으로 쓴다: +"""MySQL 비동기 세션 +FastAPI 의존성으로 받아 씀 async def handler(session: AsyncSession = Depends(get_session)): ... """ from collections.abc import AsyncGenerator diff --git a/backend/utils/google_identity.py b/backend/utils/google_identity.py new file mode 100644 index 0000000..2b81fae --- /dev/null +++ b/backend/utils/google_identity.py @@ -0,0 +1,116 @@ +"""구글 ID 토큰 검증 +브라우저가 받아온 토큰이 구글이 우리 앱 앞으로 발급한 것인지만 본다 + +코드 교환을 하지 않아 client_secret이 없다 — 검증에 필요한 것은 공개키와 client_id뿐이다. +검증을 통과하면 그 뒤로는 우리 토큰을 쓴다. 구글 토큰을 세션으로 들고 다니지 않는다. + +빠지면 안 되는 검사 셋 + 서명 — 구글 JWKS 공개키. 없으면 아무나 만든 JSON이 통과한다 + aud — 우리 client_id. 없으면 다른 앱 앞으로 발급된 진짜 구글 토큰이 통과한다 + iss — accounts.google.com +""" +import asyncio +import time + +import httpx +from jose import JWTError, jwt + +from settings import settings + +JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs" +# 구글이 두 표기를 다 쓴다. 한쪽만 받으면 어느 날 전원 로그인 실패가 된다 +ISSUERS = ("accounts.google.com", "https://accounts.google.com") +JWKS_TTL_SECONDS = 3600 +HTTP_TIMEOUT = 5.0 + +_jwks: dict | None = None +_jwks_fetched_at = 0.0 +# 토큰이 동시에 여러 개 들어와도 JWKS는 한 번만 받는다 +_jwks_lock = asyncio.Lock() + + +class GoogleLoginDisabled(RuntimeError): + """GOOGLE_CLIENT_ID 없음 — 구글 로그인만 꺼지고 서버는 뜬다""" + + +class GoogleTokenInvalid(RuntimeError): + """서명·aud·iss·만료 중 하나가 어긋남""" + + +class GoogleAccount: + def __init__(self, sub: str, email: str, name: str): + self.sub = sub + self.email = email + self.name = name + + +def is_enabled() -> bool: + return bool(settings.google_client_id) + + +async def fetch_jwks() -> dict: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: + response = await client.get(JWKS_URL) + response.raise_for_status() + return response.json() + + +async def get_jwks(*, force: bool = False) -> dict: + global _jwks, _jwks_fetched_at + async with _jwks_lock: + fresh = _jwks is not None and (time.monotonic() - _jwks_fetched_at) < JWKS_TTL_SECONDS + if fresh and not force: + return _jwks + try: + _jwks = await fetch_jwks() + _jwks_fetched_at = time.monotonic() + except Exception as failure: + print(f"[google] JWKS 조회 실패: {failure}", flush=True) + # 낡은 캐시라도 있으면 그걸로 간다. 구글이 잠깐 안 될 때 로그인이 통째로 죽는 것보다 낫다 + if _jwks is None: + raise GoogleTokenInvalid("구글 공개키를 받지 못했습니다") from failure + return _jwks + + +def has_key(jwks: dict, kid: str | None) -> bool: + return any(key.get("kid") == kid for key in jwks.get("keys") or []) + + +async def verify_id_token(credential: str) -> GoogleAccount: + if not is_enabled(): + raise GoogleLoginDisabled("GOOGLE_CLIENT_ID 없음") + if not credential: + raise GoogleTokenInvalid("빈 토큰") + + try: + kid = jwt.get_unverified_header(credential).get("kid") + except JWTError as failure: + raise GoogleTokenInvalid("토큰 형식이 아님") from failure + + jwks = await get_jwks() + # 공개키는 주기적으로 바뀐다. 캐시에 없는 kid면 한 번만 다시 받는다 + if not has_key(jwks, kid): + jwks = await get_jwks(force=True) + + try: + claims = jwt.decode( + credential, jwks, algorithms=["RS256"], + audience=settings.google_client_id, issuer=ISSUERS, + # at_hash는 access_token과 짝일 때만 의미가 있다. 브라우저가 주는 + # 크리덴셜에는 access_token이 없어 켜 두면 정상 토큰이 거부된다 + options={"verify_at_hash": False}, + ) + except JWTError as failure: + # 사유는 로그에만 남긴다 + print(f"[google] ID 토큰 거부: {failure}", flush=True) + raise GoogleTokenInvalid("구글 토큰이 유효하지 않습니다") from failure + + sub = str(claims.get("sub") or "") + if not sub: + raise GoogleTokenInvalid("sub 없음") + # 미인증 이메일은 신원으로 쓸 수 없다 + if not claims.get("email_verified"): + raise GoogleTokenInvalid("이메일이 인증되지 않은 계정입니다") + + return GoogleAccount(sub=sub, email=str(claims.get("email") or ""), + name=str(claims.get("name") or "")) diff --git a/backend/utils/session_token.py b/backend/utils/session_token.py new file mode 100644 index 0000000..de96d50 --- /dev/null +++ b/backend/utils/session_token.py @@ -0,0 +1,41 @@ +"""자체 세션 토큰 +구글 검증을 통과한 뒤로는 이걸 HttpOnly 쿠키로 들고 다닌다 + +담는 것은 user.id 하나다. 이메일·이름은 바뀔 수 있어 토큰에 넣지 않고 매번 DB에서 읽는다. +""" +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt + +from settings import settings + +ALGORITHM = "HS256" +COOKIE_NAME = "poster_alive_session" + + +class SessionInvalid(RuntimeError): + """서명이 안 맞거나 만료됨""" + + +def issue(user_id: str) -> str: + if not settings.jwt_secret: + raise RuntimeError("JWT_SECRET 없음 — 세션을 발급할 수 없습니다") + expires = datetime.now(timezone.utc) + timedelta(days=settings.jwt_days) + return jwt.encode({"sub": user_id, "exp": expires}, settings.jwt_secret, + algorithm=ALGORITHM) + + +def read(token: str) -> str: + """토큰에서 user.id를 꺼낸다""" + try: + claims = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM]) + except JWTError as failure: + raise SessionInvalid("세션이 유효하지 않습니다") from failure + user_id = str(claims.get("sub") or "") + if not user_id: + raise SessionInvalid("sub 없음") + return user_id + + +def cookie_max_age() -> int: + return settings.jwt_days * 24 * 3600 diff --git a/backend/uv.lock b/backend/uv.lock index d70d690..92445bf 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -95,6 +95,7 @@ dependencies = [ { name = "openai" }, { name = "pillow" }, { name = "pydantic-settings" }, + { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, { name = "scipy" }, { name = "sqlalchemy" }, @@ -111,6 +112,7 @@ requires-dist = [ { name = "openai", specifier = ">=3.6.0" }, { name = "pillow", specifier = ">=12.3.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "python-multipart", specifier = ">=0.0.32" }, { name = "scipy", specifier = ">=1.18.1" }, { name = "sqlalchemy", specifier = ">=2.0.52" }, @@ -126,6 +128,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -135,6 +196,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + [[package]] name = "fastapi" version = "0.141.1" @@ -450,6 +573,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -529,6 +670,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + [[package]] name = "python-multipart" version = "0.0.32" @@ -564,6 +724,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "scipy" version = "1.18.1" @@ -615,6 +787,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" diff --git a/frontend/app/(ado2)/layout.tsx b/frontend/app/(ado2)/layout.tsx index 91e83d8..f3ecaa4 100644 --- a/frontend/app/(ado2)/layout.tsx +++ b/frontend/app/(ado2)/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import Ado2Logo from "@/components/ado2-logo"; +import AuthGate from "@/components/auth-gate"; import NavLink from "@/components/nav-link"; import { FEATURES } from "@/lib/features"; @@ -27,7 +28,9 @@ export default function Ado2Layout({ children }: { children: React.ReactNode })
무빙포스터 · 내부 빌드
-
{children}
+
+ {children} +
); diff --git a/frontend/app/(playreel)/layout.tsx b/frontend/app/(playreel)/layout.tsx index 91c03b7..720073f 100644 --- a/frontend/app/(playreel)/layout.tsx +++ b/frontend/app/(playreel)/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import Ado2Logo from "@/components/ado2-logo"; +import AuthGate from "@/components/auth-gate"; import NavLink from "@/components/nav-link"; import { FEATURES } from "@/lib/features"; @@ -38,7 +39,9 @@ export default function PlayreelLayout({ children }: { children: React.ReactNode
Playreel · an ADO2 product · 내부 빌드
-
{children}
+
+ {children} +
); diff --git a/frontend/components/auth-gate.tsx b/frontend/components/auth-gate.tsx new file mode 100644 index 0000000..f324c66 --- /dev/null +++ b/frontend/components/auth-gate.tsx @@ -0,0 +1,42 @@ +"use client"; + +/** 로그인하지 않았으면 화면 대신 로그인만 보여준다 */ + +import { useState } from "react"; +import { usePathname } from "next/navigation"; +import GoogleSignIn from "@/components/google-sign-in"; +import { signIn, useMe } from "@/lib/auth"; + +// 완성본 구경은 로그인 없이 연다. 백엔드의 /api/archive 와 같은 규칙 +const OPEN_PATHS = ["/archive", "/playreel/archive"]; + +export default function AuthGate({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const { me, setMe } = useMe(); + const [error, setError] = useState(null); + + if (OPEN_PATHS.some((open) => pathname === open || pathname.startsWith(`${open}/`))) { + return <>{children}; + } + + if (me === null) { + return

확인하는 중…

; + } + + if (me === false) { + return ( +
+

로그인이 필요합니다

+

구글 계정으로 들어와 주세요.

+ { + setError(null); + signIn(credential).then(setMe).catch((failure) => setError((failure as Error).message)); + }} /> + {error &&

{error}

} +
+ ); + } + + return <>{children}; +} diff --git a/frontend/components/google-sign-in.tsx b/frontend/components/google-sign-in.tsx new file mode 100644 index 0000000..8baef78 --- /dev/null +++ b/frontend/components/google-sign-in.tsx @@ -0,0 +1,54 @@ +"use client"; + +/** 구글이 그려주는 버튼. 우리가 모양을 흉내 내면 브랜드 규정에 걸린다 */ + +import { useEffect, useRef, useState } from "react"; +import { + fetchConfig, loadGoogleIdentity, type GoogleButtonOptions, +} from "@/lib/auth"; + +export default function GoogleSignIn({ onCredential, text = "signin_with" }: { + onCredential: (credential: string) => void; + text?: GoogleButtonOptions["text"]; +}) { + const host = useRef(null); + const [error, setError] = useState(null); + // 콜백이 매 렌더마다 바뀌어도 구글에 다시 등록하지 않게 최신 것만 들고 있는다 + const latest = useRef(onCredential); + latest.current = onCredential; + + useEffect(() => { + let cancelled = false; + + (async () => { + const config = await fetchConfig(); + if (!config.enabled) throw new Error("구글 로그인이 설정되지 않았습니다"); + + const google = await loadGoogleIdentity(); + if (cancelled || !host.current) return; + + google.initialize({ + client_id: config.client_id, + callback: (response) => { + if (response.credential) latest.current(response.credential); + }, + auto_select: false, + cancel_on_tap_outside: true, + }); + google.renderButton(host.current, { + type: "standard", theme: "filled_black", size: "large", + shape: "pill", text, logo_alignment: "center", width: 320, + }); + })().catch((failure) => { + if (!cancelled) setError((failure as Error).message); + }); + + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [text]); + + if (error) { + return

{error}

; + } + return
; +} diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts new file mode 100644 index 0000000..dda7dc4 --- /dev/null +++ b/frontend/lib/auth.ts @@ -0,0 +1,122 @@ +"use client"; + +/** + * 구글 로그인. + * 브라우저가 구글에서 받은 credential 을 서버에 한 번 넘기면, 그 뒤로는 HttpOnly 쿠키가 + * 붙는다 — 토큰을 프론트가 들고 있지 않는다. + * + * GIS 스크립트는 여기서 붙인다. 로그인 화면에 오기 전에는 서드파티 요청을 만들지 않는다. + */ + +import { useEffect, useState } from "react"; +import { apiFetch } from "@/lib/api"; + +const SCRIPT_URL = "https://accounts.google.com/gsi/client?hl=ko"; +const SCRIPT_ID = "google-identity-services"; + +export interface Me { + email: string; + name: string; + jobs_created: number; + job_limit: number; + jobs_left: number; +} + +export interface AuthConfig { + enabled: boolean; + client_id: string; +} + +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"; + width?: number; + logo_alignment?: "left" | "center"; +}; + +type GoogleIdApi = { + initialize(config: { + client_id: string; + callback: (response: CredentialResponse) => void; + auto_select?: boolean; + cancel_on_tap_outside?: boolean; + }): void; + renderButton(parent: HTMLElement, options: GoogleButtonOptions): void; + disableAutoSelect(): void; +}; + +declare global { + interface Window { + google?: { accounts?: { id?: GoogleIdApi } }; + } +} + +// 스크립트는 한 번만 받는다 +let loading: Promise | null = null; + +export function loadGoogleIdentity(): Promise { + const ready = window.google?.accounts?.id; + if (ready) return Promise.resolve(ready); + if (loading) return loading; + + loading = new Promise((resolve, reject) => { + const done = () => { + const api = window.google?.accounts?.id; + if (api) resolve(api); + else reject(new Error("구글 스크립트에 accounts.id 가 없습니다")); + }; + const fail = () => { + // 다음 시도에서 다시 받을 수 있게 비운다 — 광고 차단기나 사내망에서 한 번 막히는 일이 흔하다 + loading = null; + reject(new Error("구글 로그인 스크립트를 받지 못했습니다")); + }; + + 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; +} + +export const fetchConfig = () => apiFetch("/api/auth/config"); + +export const signIn = (credential: string) => + apiFetch("/api/auth/google", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ credential }), + }); + +export const signOut = () => apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" }); + +/** 로그인 여부. null 이면 아직 확인 중, false 면 로그아웃 상태 */ +export function useMe() { + const [me, setMe] = useState(null); + + const refresh = () => + apiFetch("/api/auth/me").then(setMe).catch(() => setMe(false)); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- 최초 1회 확인 + refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { me, refresh, setMe }; +} diff --git a/frontend/lib/prefs.ts b/frontend/lib/prefs.ts index 58be3e0..acfd39e 100644 --- a/frontend/lib/prefs.ts +++ b/frontend/lib/prefs.ts @@ -3,7 +3,7 @@ /** * 브라우저 로컬 환경설정 — 게이트 자동 승인. * 키 `playreel.autoApprove` = { fetch_confirm?: true, narration_confirm?: true } - * 대상은 크레딧이 들지 않고 되돌릴 수 있는 게이트(①·③)뿐. ②·④·⑤는 GATE_META.credits/비가역이라 제외. + * 대상은 되돌릴 수 있는 게이트(①·③)뿐. ②·④·⑤는 비가역이라 제외. * 서버에는 저장하지 않는다(사용자별 계정 개념이 아직 없음). 서버 `approve` 자동 호출은 /playreel/[id] 에서. */