feat(sync): 首次登录后固定随机初始凭据
This commit is contained in:
@@ -82,6 +82,14 @@ def main():
|
||||
db.add_user(args.username or input("用户名: "), getpass.getpass("密码(至少12字符): "))
|
||||
elif args.command == "serve":
|
||||
db.migrate()
|
||||
bootstrap = db.prepare_bootstrap_user()
|
||||
if bootstrap:
|
||||
print(json.dumps({
|
||||
"event": "SYNC_BOOTSTRAP_CREDENTIALS",
|
||||
"username": bootstrap["username"],
|
||||
"password": bootstrap["password"],
|
||||
"must_change_credentials": True,
|
||||
}), flush=True)
|
||||
import uvicorn
|
||||
host = os.environ.get("SYNC_HOST", "0.0.0.0")
|
||||
if host not in {"0.0.0.0", "127.0.0.1", "::1"}:
|
||||
|
||||
@@ -19,7 +19,7 @@ from starlette.concurrency import run_in_threadpool
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from .database import Database, password_hash, row, rows, run
|
||||
from .models import Commit, Login, Refresh, Upload, VaultCreate
|
||||
from .models import Commit, CredentialChange, Login, Refresh, Upload, VaultCreate
|
||||
from .readiness import Readiness
|
||||
|
||||
|
||||
@@ -105,11 +105,14 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
|
||||
# Pydantic 的原始错误可能带请求正文,禁止回显密码或笔记。
|
||||
return JSONResponse({"error": {"code": "INVALID_REQUEST", "details": {}}}, status_code=422)
|
||||
|
||||
def identity(conn, authorization):
|
||||
def identity(conn, authorization, *, allow_bootstrap=False):
|
||||
token = authorization.removeprefix("Bearer ") if authorization.startswith("Bearer ") else ""
|
||||
session = row(conn, "SELECT s.*, d.user_id, d.revoked FROM sessions s JOIN devices d ON d.id=s.device_id WHERE s.token=:token", token=digest(token))
|
||||
if not session or session["revoked"] or session["expires"] <= clock():
|
||||
raise SyncError(401, "SESSION_EXPIRED")
|
||||
if not allow_bootstrap and row(conn, "SELECT user_id FROM bootstrap_state WHERE user_id=:user",
|
||||
user=session["user_id"]):
|
||||
raise SyncError(403, "CREDENTIAL_CHANGE_REQUIRED")
|
||||
return session
|
||||
|
||||
def vault(conn, vault_id, authorization, *, lock=False):
|
||||
@@ -127,7 +130,11 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
|
||||
run(conn, "INSERT INTO sessions VALUES (:token,:refresh,:device,:expires,:refresh_expires)",
|
||||
token=digest(access), refresh=digest(refresh), device=device_id,
|
||||
expires=int(clock()) + 900, refresh_expires=int(clock()) + 30 * 86400)
|
||||
return {"access_token": access, "refresh_token": refresh, "expires_in": 900, "device_id": device_id}
|
||||
device = row(conn, "SELECT user_id FROM devices WHERE id=:id", id=device_id)
|
||||
must_change = bool(row(conn, "SELECT user_id FROM bootstrap_state WHERE user_id=:user",
|
||||
user=device["user_id"]))
|
||||
return {"access_token": access, "refresh_token": refresh, "expires_in": 900,
|
||||
"device_id": device_id, "must_change_credentials": must_change}
|
||||
|
||||
@app.get("/health")
|
||||
def health(response: Response):
|
||||
@@ -210,9 +217,31 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
|
||||
@app.delete("/sync/v1/auth/sessions", status_code=204)
|
||||
def logout(authorization: str = Header(default="")):
|
||||
with db.transaction() as conn:
|
||||
session = identity(conn, authorization)
|
||||
session = identity(conn, authorization, allow_bootstrap=True)
|
||||
run(conn, "DELETE FROM sessions WHERE token=:token", token=session["token"])
|
||||
|
||||
@app.put("/sync/v1/account/credentials")
|
||||
def change_credentials(body: CredentialChange, authorization: str = Header(default="")):
|
||||
with db.transaction() as conn:
|
||||
session = identity(conn, authorization, allow_bootstrap=True)
|
||||
user = row(conn, "SELECT * FROM users WHERE id=:id", id=session["user_id"])
|
||||
expected = user["password"]
|
||||
if not secrets.compare_digest(
|
||||
password_hash(body.current_password, expected.split(":")[0]), expected):
|
||||
raise SyncError(401, "CURRENT_PASSWORD_INVALID")
|
||||
conflict = row(conn, "SELECT id FROM users WHERE username=:name AND id<>:id",
|
||||
name=body.username, id=user["id"])
|
||||
if conflict:
|
||||
raise SyncError(409, "USERNAME_TAKEN")
|
||||
run(conn, "UPDATE users SET username=:name,password=:password WHERE id=:id",
|
||||
name=body.username, password=password_hash(body.password), id=user["id"])
|
||||
run(conn, "DELETE FROM bootstrap_state WHERE user_id=:user", user=user["id"])
|
||||
run(conn, "DELETE FROM sessions WHERE device_id IN (SELECT id FROM devices WHERE user_id=:user) AND token<>:token",
|
||||
user=user["id"], token=session["token"])
|
||||
run(conn, "UPDATE devices SET revoked=1 WHERE user_id=:user AND id<>:device",
|
||||
user=user["id"], device=session["device_id"])
|
||||
return {"username": body.username, "credentials_fixed": True}
|
||||
|
||||
@app.get("/sync/v1/devices")
|
||||
def devices(authorization: str = Header(default="")):
|
||||
with db.transaction() as conn:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
SCHEMA = [
|
||||
@@ -17,6 +18,7 @@ SCHEMA = [
|
||||
"CREATE TABLE IF NOT EXISTS files (vault_id TEXT NOT NULL, file_id TEXT NOT NULL, sequence BIGINT NOT NULL, path_key TEXT NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(vault_id, file_id))",
|
||||
"CREATE TABLE IF NOT EXISTS login_limits (key TEXT PRIMARY KEY, started BIGINT NOT NULL, attempts INTEGER NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS upload_receipts (id TEXT PRIMARY KEY, vault_id TEXT NOT NULL, device_id TEXT NOT NULL, hash TEXT NOT NULL, completed BIGINT NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS bootstrap_state (user_id TEXT PRIMARY KEY, created BIGINT NOT NULL)",
|
||||
]
|
||||
|
||||
|
||||
@@ -66,6 +68,33 @@ class Database:
|
||||
conn.execute(text("INSERT INTO users VALUES (:id,:name,:password)"),
|
||||
{"id": secrets.token_hex(16), "name": username, "password": password_hash(password)})
|
||||
|
||||
def prepare_bootstrap_user(self):
|
||||
"""在账户尚未固定时生成本次服务启动专用的临时密码。"""
|
||||
password = secrets.token_urlsafe(24)
|
||||
with self.transaction() as conn:
|
||||
state = row(conn, "SELECT user_id FROM bootstrap_state")
|
||||
user = row(conn, "SELECT * FROM users WHERE id=:id", id=state["user_id"]) if state else None
|
||||
if state and not user:
|
||||
run(conn, "DELETE FROM bootstrap_state")
|
||||
state = None
|
||||
if not state:
|
||||
if row(conn, "SELECT id FROM users LIMIT 1"):
|
||||
return None
|
||||
user_id = secrets.token_hex(16)
|
||||
run(conn, "INSERT INTO users VALUES (:id,:name,:password)",
|
||||
id=user_id, name="admin", password=password_hash(password))
|
||||
run(conn, "INSERT INTO bootstrap_state VALUES (:user,:created)",
|
||||
user=user_id, created=int(time.time()))
|
||||
else:
|
||||
user_id = state["user_id"]
|
||||
run(conn, "UPDATE users SET password=:password WHERE id=:id",
|
||||
password=password_hash(password), id=user_id)
|
||||
run(conn, "DELETE FROM sessions WHERE device_id IN (SELECT id FROM devices WHERE user_id=:user)",
|
||||
user=user_id)
|
||||
run(conn, "UPDATE devices SET revoked=1 WHERE user_id=:user", user=user_id)
|
||||
account = row(conn, "SELECT username FROM users WHERE id=:id", id=user_id)
|
||||
return {"username": account["username"], "password": password}
|
||||
|
||||
|
||||
def row(conn, sql, **params):
|
||||
return conn.execute(text(sql), params).mappings().first()
|
||||
|
||||
@@ -20,6 +20,19 @@ class Refresh(DTO):
|
||||
refresh_token: str = Field(min_length=32, max_length=256)
|
||||
|
||||
|
||||
class CredentialChange(DTO):
|
||||
current_password: str = Field(min_length=12, max_length=256)
|
||||
username: str = Field(min_length=1, max_length=80)
|
||||
password: str = Field(min_length=12, max_length=256)
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def username_valid(cls, value):
|
||||
if value != value.strip():
|
||||
raise ValueError("账户名首尾不能包含空白")
|
||||
return value
|
||||
|
||||
|
||||
class VaultCreate(DTO):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ TABLES: dict[str, tuple[str, ...]] = {
|
||||
"files": ("vault_id", "file_id", "sequence", "path_key", "deleted"),
|
||||
"login_limits": ("key", "started", "attempts"),
|
||||
"upload_receipts": ("id", "vault_id", "device_id", "hash", "completed"),
|
||||
"bootstrap_state": ("user_id", "created"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#07120f">
|
||||
<title>OpenNexus Sync Console</title>
|
||||
<script type="module" crossorigin src="/console/assets/index-CsQwWg1J.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/console/assets/index-B8qnzSCe.css">
|
||||
<script type="module" crossorigin src="/console/assets/index-C4AkVW4e.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/console/assets/index-xFodnbVC.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user