diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ed053a6 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +AUTH_DB_PATH=~/.nanobot/auth_service.sqlite3 +AUTH_JWT_SECRET=change-this-secret +AUTH_TOKEN_TTL_HOURS=24 +AUTH_CORS_ORIGINS=* +AUTH_INVITE_CODES=invite-a,invite-b +AUTH_ADMIN_KEY=change-this-admin-key +AUTH_HOST=0.0.0.0 +AUTH_PORT=9100 diff --git a/.gitignore b/.gitignore index 36b13f1..49a6b03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,176 +1,179 @@ -# ---> Python -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - +# custom gitignore for nanobot-auth-service +*.sqlite3 + +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + diff --git a/README.md b/README.md index bd70037..5c499f1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,91 @@ -# nanobot-auth +# nanobot-auth-service +Standalone phone/password auth service for nanobot web chat. + +## Features + +- `POST /auth/register` (phone + password + invite code; returns pending) +- `POST /auth/login` +- `GET /auth/me` (Bearer token) +- `GET /auth/register/status/{request_id}` +- `GET /admin/requests` (admin key required) +- `POST /admin/requests/{id}/approve` (admin key required) +- `POST /admin/requests/{id}/reject` (admin key required) +- SQLite persistence +- JWT access tokens + +## Quick Start + +```bash +cd nanobot-auth-service +pip install -e . +cp .env.example .env +source .env +uvicorn app.main:app --host ${AUTH_HOST:-0.0.0.0} --port ${AUTH_PORT:-9100} +``` + +## Env Vars + +- `AUTH_DB_PATH`: sqlite file path +- `AUTH_JWT_SECRET`: JWT signing secret +- `AUTH_TOKEN_TTL_HOURS`: access token ttl +- `AUTH_CORS_ORIGINS`: comma-separated origins or `*` +- `AUTH_INVITE_CODES`: comma-separated whitelist (empty means no whitelist check) +- `AUTH_ADMIN_KEY`: required by admin endpoints +- `AUTH_HOST`: bind host (run command) +- `AUTH_PORT`: bind port (run command) + +## API Contract + +`POST /auth/register` + +```json +{ + "phone": "13800000000", + "password": "secret123", + "invite_code": "invite-a" +} +``` + +Response: + +```json +{ + "ok": true, + "status": "pending", + "request_id": 1, + "message": "pending review" +} +``` + +Manual approval flow (operator terminal): + +```bash +cd nanobot-auth-service +python app/manual_review.py +``` + +The script lists pending requests and asks for each item: + +- input `y` => approve and create user +- input `r` => reject with reason +- any other input => skip + +`POST /auth/login` has the same request/response shape. + +`GET /auth/me` + +Header: + +```http +Authorization: Bearer +``` + +Response: + +```json +{ + "ok": true, + "user": {"id": 1, "phone": "13800000000"} +} +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0fe873f --- /dev/null +++ b/app/main.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import os +import re +import secrets +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import jwt +from fastapi import Depends, FastAPI, Header, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +PHONE_PATTERN = re.compile(r"^\d{6,20}$") + +AUTH_DB_PATH = os.getenv("AUTH_DB_PATH", "auth_service.sqlite3") +AUTH_JWT_SECRET = os.getenv("AUTH_JWT_SECRET", "change-this-secret") +AUTH_TOKEN_TTL_HOURS = int(os.getenv("AUTH_TOKEN_TTL_HOURS", "24")) +AUTH_CORS_ORIGINS = os.getenv("AUTH_CORS_ORIGINS", "*") +AUTH_INVITE_CODES = { + item.strip() + for item in os.getenv("AUTH_INVITE_CODES", "").split(",") + if item.strip() +} +AUTH_ADMIN_KEY = os.getenv("AUTH_ADMIN_KEY", "") + + +def _ensure_db(path_str: str) -> Path: + path = Path(path_str).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(path) as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + created_at TEXT NOT NULL, + last_login_at TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS registration_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + invite_code TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + reviewed_at TEXT, + review_note TEXT + ) + """ + ) + conn.commit() + return path + + +DB_PATH = _ensure_db(AUTH_DB_PATH) + + +class AuthPayload(BaseModel): + phone: str + password: str + + +class RegisterPayload(AuthPayload): + invite_code: str + + +class TokenResponse(BaseModel): + ok: bool + access_token: str + token_type: str = "bearer" + expires_in: int + user: dict + + +class RegisterPendingResponse(BaseModel): + ok: bool + status: str + request_id: int + message: str + + +def _connect() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def _normalize_phone(phone: str) -> str: + p = str(phone).strip() + # Accept common user input formats in test environments, then normalize. + p = p.replace(" ", "").replace("-", "") + if p.startswith("+86"): + p = p[3:] + if not PHONE_PATTERN.match(p): + raise HTTPException(status_code=400, detail="phone must be 6-20 digits") + return p + + +def _validate_password(password: str) -> None: + if len(password) < 6: + raise HTTPException(status_code=400, detail="password must be at least 6 characters") + + +def _hash_password(password: str, salt: bytes) -> str: + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 200_000) + return base64.b64encode(digest).decode("ascii") + + +def _create_access_token(user_id: int, phone: str) -> str: + now = datetime.now(timezone.utc) + payload = { + "uid": user_id, + "sub": phone, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(hours=max(1, AUTH_TOKEN_TTL_HOURS))).timestamp()), + } + return jwt.encode(payload, AUTH_JWT_SECRET, algorithm="HS256") + + +def _token_response(user_id: int, phone: str) -> TokenResponse: + token = _create_access_token(user_id, phone) + return TokenResponse( + ok=True, + access_token=token, + expires_in=max(1, AUTH_TOKEN_TTL_HOURS) * 3600, + user={"id": user_id, "phone": phone}, + ) + + +def _extract_bearer(authorization: str | None) -> str: + if not authorization: + raise HTTPException(status_code=401, detail="missing authorization") + prefix = "Bearer " + if not authorization.startswith(prefix): + raise HTTPException(status_code=401, detail="invalid authorization") + token = authorization[len(prefix):].strip() + if not token: + raise HTTPException(status_code=401, detail="missing token") + return token + + +def _current_user(authorization: str | None = Header(default=None)) -> dict: + token = _extract_bearer(authorization) + try: + payload = jwt.decode(token, AUTH_JWT_SECRET, algorithms=["HS256"]) + except jwt.PyJWTError as e: + raise HTTPException(status_code=401, detail="invalid token") from e + + uid = int(payload.get("uid", 0)) + phone = str(payload.get("sub", "")) + if uid <= 0 or not phone: + raise HTTPException(status_code=401, detail="invalid token payload") + return {"id": uid, "phone": phone} + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _require_invite_code(invite_code: str) -> str: + code = str(invite_code).strip() + if not code: + raise HTTPException(status_code=400, detail="invite code is required") + if AUTH_INVITE_CODES and code not in AUTH_INVITE_CODES: + raise HTTPException(status_code=400, detail="invalid invite code") + return code + + +def _admin_ok(x_admin_key: str | None = Header(default=None)) -> None: + if not AUTH_ADMIN_KEY: + raise HTTPException(status_code=500, detail="AUTH_ADMIN_KEY is not configured") + if x_admin_key != AUTH_ADMIN_KEY: + raise HTTPException(status_code=401, detail="invalid admin key") + + +def _approve_request_with_conn(conn: sqlite3.Connection, request_id: int, note: str = "approved") -> None: + row = conn.execute( + """ + SELECT id, phone, password_hash, salt, status + FROM registration_requests + WHERE id = ? + """, + (request_id,), + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="request not found") + if row["status"] != "pending": + raise HTTPException(status_code=400, detail=f"request already {row['status']}") + + try: + conn.execute( + """ + INSERT INTO users(phone, password_hash, salt, created_at) + VALUES(?, ?, ?, ?) + """, + (row["phone"], row["password_hash"], row["salt"], _now_iso()), + ) + conn.execute( + """ + UPDATE registration_requests + SET status = 'approved', reviewed_at = ?, review_note = ? + WHERE id = ? + """, + (_now_iso(), note, request_id), + ) + conn.commit() + except sqlite3.IntegrityError as e: + conn.execute( + """ + UPDATE registration_requests + SET status = 'rejected', reviewed_at = ?, review_note = ? + WHERE id = ? + """, + (_now_iso(), "phone already exists", request_id), + ) + conn.commit() + raise HTTPException(status_code=400, detail="phone already exists") from e + + +def _reject_request_with_conn(conn: sqlite3.Connection, request_id: int, note: str) -> None: + row = conn.execute( + "SELECT id, status FROM registration_requests WHERE id = ?", + (request_id,), + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="request not found") + if row["status"] != "pending": + raise HTTPException(status_code=400, detail=f"request already {row['status']}") + conn.execute( + """ + UPDATE registration_requests + SET status = 'rejected', reviewed_at = ?, review_note = ? + WHERE id = ? + """, + (_now_iso(), note or "rejected", request_id), + ) + conn.commit() + + +app = FastAPI(title="nanobot-auth-service", version="0.1.0") + +origins = ["*"] if AUTH_CORS_ORIGINS.strip() == "*" else [o.strip() for o in AUTH_CORS_ORIGINS.split(",") if o.strip()] +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +def health() -> dict: + return { + "ok": True, + "service": "auth", + "db": str(DB_PATH), + "invite_code_check": bool(AUTH_INVITE_CODES), + } + + +@app.post("/auth/register", response_model=RegisterPendingResponse) +def register(payload: RegisterPayload): + phone = _normalize_phone(payload.phone) + _validate_password(payload.password) + invite_code = _require_invite_code(payload.invite_code) + + salt = secrets.token_bytes(16) + password_hash = _hash_password(payload.password, salt) + now = _now_iso() + + try: + with _connect() as conn: + existing = conn.execute( + "SELECT id FROM users WHERE phone = ?", + (phone,), + ).fetchone() + if existing is not None: + raise HTTPException(status_code=400, detail="phone already exists") + + pending = conn.execute( + "SELECT id FROM registration_requests WHERE phone = ? AND status = 'pending'", + (phone,), + ).fetchone() + if pending is not None: + return RegisterPendingResponse( + ok=True, + status="pending", + request_id=int(pending["id"]), + message="pending review", + ) + + cur = conn.execute( + """ + INSERT INTO registration_requests(phone, password_hash, salt, invite_code, status, created_at) + VALUES(?, ?, ?, ?, 'pending', ?) + """, + (phone, password_hash, base64.b64encode(salt).decode("ascii"), invite_code, now), + ) + request_id = int(cur.lastrowid) + conn.commit() + except HTTPException: + raise + except sqlite3.IntegrityError as e: + raise HTTPException(status_code=400, detail="phone already exists") from e + + return RegisterPendingResponse( + ok=True, + status="pending", + request_id=request_id, + message="pending review", + ) + + +@app.get("/auth/register/status/{request_id}") +def register_status(request_id: int): + with _connect() as conn: + row = conn.execute( + """ + SELECT id, phone, status, review_note + FROM registration_requests + WHERE id = ? + """, + (request_id,), + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="request not found") + return { + "ok": True, + "request_id": int(row["id"]), + "phone": row["phone"], + "status": row["status"], + "review_note": row["review_note"] or "", + } + + +@app.post("/auth/login", response_model=TokenResponse) +def login(payload: AuthPayload): + phone = _normalize_phone(payload.phone) + + with _connect() as conn: + row = conn.execute( + "SELECT id, phone, password_hash, salt FROM users WHERE phone = ?", + (phone,), + ).fetchone() + if row is None: + raise HTTPException(status_code=401, detail="invalid credentials") + + salt = base64.b64decode(row["salt"].encode("ascii")) + expected = row["password_hash"] + actual = _hash_password(payload.password, salt) + if not hmac.compare_digest(expected, actual): + raise HTTPException(status_code=401, detail="invalid credentials") + + conn.execute( + "UPDATE users SET last_login_at = ? WHERE id = ?", + (datetime.now(timezone.utc).isoformat(), int(row["id"])), + ) + conn.commit() + + return _token_response(int(row["id"]), str(row["phone"])) + + +@app.get("/auth/me") +def me(user: dict = Depends(_current_user)): + return {"ok": True, "user": user} + + +@app.get("/admin/requests") +def admin_requests(status_filter: str = "pending", _admin: None = Depends(_admin_ok)): + with _connect() as conn: + rows = conn.execute( + """ + SELECT id, phone, invite_code, status, created_at, reviewed_at, review_note + FROM registration_requests + WHERE status = ? + ORDER BY id ASC + """, + (status_filter,), + ).fetchall() + items: list[dict[str, Any]] = [] + for r in rows: + items.append( + { + "id": int(r["id"]), + "phone": r["phone"], + "invite_code": r["invite_code"], + "status": r["status"], + "created_at": r["created_at"], + "reviewed_at": r["reviewed_at"], + "review_note": r["review_note"], + } + ) + return {"ok": True, "items": items} + + +@app.post("/admin/requests/{request_id}/approve") +def admin_approve(request_id: int, _admin: None = Depends(_admin_ok)): + with _connect() as conn: + _approve_request_with_conn(conn, request_id, note="approved by admin") + return {"ok": True, "status": "approved", "request_id": request_id} + + +@app.post("/admin/requests/{request_id}/reject") +def admin_reject(request_id: int, note: str = "rejected by admin", _admin: None = Depends(_admin_ok)): + with _connect() as conn: + _reject_request_with_conn(conn, request_id, note=note) + return {"ok": True, "status": "rejected", "request_id": request_id} diff --git a/app/manual_review.py b/app/manual_review.py new file mode 100644 index 0000000..2d442b2 --- /dev/null +++ b/app/manual_review.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import os +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +DB_PATH = Path(os.getenv("AUTH_DB_PATH", "auth_service.sqlite3")).expanduser() + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def connect() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def approve(conn: sqlite3.Connection, request_id: int) -> None: + row = conn.execute( + "SELECT id, phone, password_hash, salt, status FROM registration_requests WHERE id = ?", + (request_id,), + ).fetchone() + if row is None: + print(f"request #{request_id} not found") + return + if row["status"] != "pending": + print(f"request #{request_id} already {row['status']}") + return + + try: + conn.execute( + "INSERT INTO users(phone, password_hash, salt, created_at) VALUES(?, ?, ?, ?)", + (row["phone"], row["password_hash"], row["salt"], now_iso()), + ) + conn.execute( + "UPDATE registration_requests SET status='approved', reviewed_at=?, review_note=? WHERE id=?", + (now_iso(), "approved in manual_review", request_id), + ) + conn.commit() + print(f"approved request #{request_id} ({row['phone']})") + except sqlite3.IntegrityError: + conn.execute( + "UPDATE registration_requests SET status='rejected', reviewed_at=?, review_note=? WHERE id=?", + (now_iso(), "phone already exists", request_id), + ) + conn.commit() + print(f"rejected request #{request_id}: phone already exists") + + +def reject(conn: sqlite3.Connection, request_id: int, note: str) -> None: + row = conn.execute( + "SELECT id, status, phone FROM registration_requests WHERE id = ?", + (request_id,), + ).fetchone() + if row is None: + print(f"request #{request_id} not found") + return + if row["status"] != "pending": + print(f"request #{request_id} already {row['status']}") + return + + conn.execute( + "UPDATE registration_requests SET status='rejected', reviewed_at=?, review_note=? WHERE id=?", + (now_iso(), note, request_id), + ) + conn.commit() + print(f"rejected request #{request_id} ({row['phone']})") + + +def main() -> None: + if not DB_PATH.exists(): + print(f"db not found: {DB_PATH}") + return + + with connect() as conn: + rows = conn.execute( + """ + SELECT id, phone, invite_code, created_at + FROM registration_requests + WHERE status='pending' + ORDER BY id ASC + """ + ).fetchall() + + if not rows: + print("no pending requests") + return + + print(f"pending requests: {len(rows)}") + for row in rows: + print("-" * 64) + print( + f"#{row['id']} phone={row['phone']} invite={row['invite_code']} " + f"created_at={row['created_at']}" + ) + answer = input("approve this request? [y/N/r]: ").strip().lower() + if answer == "y": + approve(conn, int(row["id"])) + elif answer == "r": + note = input("reject reason: ").strip() or "rejected manually" + reject(conn, int(row["id"]), note) + else: + print("skip") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2be331a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "nanobot-auth-service" +version = "0.1.0" +description = "Standalone auth service for nanobot web channel" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.116.0", + "uvicorn>=0.35.0", + "PyJWT>=2.8.0" +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"]