first commit
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -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
|
||||||
355
.gitignore
vendored
355
.gitignore
vendored
@@ -1,176 +1,179 @@
|
|||||||
# ---> Python
|
# custom gitignore for nanobot-auth-service
|
||||||
# Byte-compiled / optimized / DLL files
|
*.sqlite3
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
# ---> Python
|
||||||
*$py.class
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
# C extensions
|
*.py[cod]
|
||||||
*.so
|
*$py.class
|
||||||
|
|
||||||
# Distribution / packaging
|
# C extensions
|
||||||
.Python
|
*.so
|
||||||
build/
|
|
||||||
develop-eggs/
|
# Distribution / packaging
|
||||||
dist/
|
.Python
|
||||||
downloads/
|
build/
|
||||||
eggs/
|
develop-eggs/
|
||||||
.eggs/
|
dist/
|
||||||
lib/
|
downloads/
|
||||||
lib64/
|
eggs/
|
||||||
parts/
|
.eggs/
|
||||||
sdist/
|
lib/
|
||||||
var/
|
lib64/
|
||||||
wheels/
|
parts/
|
||||||
share/python-wheels/
|
sdist/
|
||||||
*.egg-info/
|
var/
|
||||||
.installed.cfg
|
wheels/
|
||||||
*.egg
|
share/python-wheels/
|
||||||
MANIFEST
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
# PyInstaller
|
*.egg
|
||||||
# Usually these files are written by a python script from a template
|
MANIFEST
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
# PyInstaller
|
||||||
*.spec
|
# 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.
|
||||||
# Installer logs
|
*.manifest
|
||||||
pip-log.txt
|
*.spec
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
# Installer logs
|
||||||
# Unit test / coverage reports
|
pip-log.txt
|
||||||
htmlcov/
|
pip-delete-this-directory.txt
|
||||||
.tox/
|
|
||||||
.nox/
|
# Unit test / coverage reports
|
||||||
.coverage
|
htmlcov/
|
||||||
.coverage.*
|
.tox/
|
||||||
.cache
|
.nox/
|
||||||
nosetests.xml
|
.coverage
|
||||||
coverage.xml
|
.coverage.*
|
||||||
*.cover
|
.cache
|
||||||
*.py,cover
|
nosetests.xml
|
||||||
.hypothesis/
|
coverage.xml
|
||||||
.pytest_cache/
|
*.cover
|
||||||
cover/
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
# Translations
|
.pytest_cache/
|
||||||
*.mo
|
cover/
|
||||||
*.pot
|
|
||||||
|
# Translations
|
||||||
# Django stuff:
|
*.mo
|
||||||
*.log
|
*.pot
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
# Django stuff:
|
||||||
db.sqlite3-journal
|
*.log
|
||||||
|
local_settings.py
|
||||||
# Flask stuff:
|
db.sqlite3
|
||||||
instance/
|
db.sqlite3-journal
|
||||||
.webassets-cache
|
|
||||||
|
# Flask stuff:
|
||||||
# Scrapy stuff:
|
instance/
|
||||||
.scrapy
|
.webassets-cache
|
||||||
|
|
||||||
# Sphinx documentation
|
# Scrapy stuff:
|
||||||
docs/_build/
|
.scrapy
|
||||||
|
|
||||||
# PyBuilder
|
# Sphinx documentation
|
||||||
.pybuilder/
|
docs/_build/
|
||||||
target/
|
|
||||||
|
# PyBuilder
|
||||||
# Jupyter Notebook
|
.pybuilder/
|
||||||
.ipynb_checkpoints
|
target/
|
||||||
|
|
||||||
# IPython
|
# Jupyter Notebook
|
||||||
profile_default/
|
.ipynb_checkpoints
|
||||||
ipython_config.py
|
|
||||||
|
# IPython
|
||||||
# pyenv
|
profile_default/
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
ipython_config.py
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
|
||||||
# .python-version
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
# pipenv
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
# .python-version
|
||||||
# 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
|
# pipenv
|
||||||
# install all needed dependencies.
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
#Pipfile.lock
|
# 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
|
||||||
# UV
|
# install all needed dependencies.
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
#Pipfile.lock
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
# UV
|
||||||
#uv.lock
|
# 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
|
||||||
# poetry
|
# commonly ignored for libraries.
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
#uv.lock
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
# poetry
|
||||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
#poetry.lock
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
# pdm
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
#poetry.lock
|
||||||
#pdm.lock
|
|
||||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
# pdm
|
||||||
# in version control.
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
#pdm.lock
|
||||||
.pdm.toml
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
.pdm-python
|
# in version control.
|
||||||
.pdm-build/
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
.pdm-python
|
||||||
__pypackages__/
|
.pdm-build/
|
||||||
|
|
||||||
# Celery stuff
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
celerybeat-schedule
|
__pypackages__/
|
||||||
celerybeat.pid
|
|
||||||
|
# Celery stuff
|
||||||
# SageMath parsed files
|
celerybeat-schedule
|
||||||
*.sage.py
|
celerybeat.pid
|
||||||
|
|
||||||
# Environments
|
# SageMath parsed files
|
||||||
.env
|
*.sage.py
|
||||||
.venv
|
|
||||||
env/
|
# Environments
|
||||||
venv/
|
.env
|
||||||
ENV/
|
.venv
|
||||||
env.bak/
|
env/
|
||||||
venv.bak/
|
venv/
|
||||||
|
ENV/
|
||||||
# Spyder project settings
|
env.bak/
|
||||||
.spyderproject
|
venv.bak/
|
||||||
.spyproject
|
|
||||||
|
# Spyder project settings
|
||||||
# Rope project settings
|
.spyderproject
|
||||||
.ropeproject
|
.spyproject
|
||||||
|
|
||||||
# mkdocs documentation
|
# Rope project settings
|
||||||
/site
|
.ropeproject
|
||||||
|
|
||||||
# mypy
|
# mkdocs documentation
|
||||||
.mypy_cache/
|
/site
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
# Pyre type checker
|
.dmypy.json
|
||||||
.pyre/
|
dmypy.json
|
||||||
|
|
||||||
# pytype static type analyzer
|
# Pyre type checker
|
||||||
.pytype/
|
.pyre/
|
||||||
|
|
||||||
# Cython debug symbols
|
# pytype static type analyzer
|
||||||
cython_debug/
|
.pytype/
|
||||||
|
|
||||||
# PyCharm
|
# Cython debug symbols
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
cython_debug/
|
||||||
# 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
|
# PyCharm
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
#.idea/
|
# 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
|
||||||
# Ruff stuff:
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
.ruff_cache/
|
#.idea/
|
||||||
|
|
||||||
# PyPI configuration file
|
# Ruff stuff:
|
||||||
.pypirc
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
|||||||
91
README.md
91
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 <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"user": {"id": 1, "phone": "13800000000"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__all__ = []
|
||||||
421
app/main.py
Normal file
421
app/main.py
Normal file
@@ -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}
|
||||||
110
app/manual_review.py
Normal file
110
app/manual_review.py
Normal file
@@ -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()
|
||||||
19
pyproject.toml
Normal file
19
pyproject.toml
Normal file
@@ -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*"]
|
||||||
Reference in New Issue
Block a user