feat(sync): 首次登录后固定随机初始凭据
This commit is contained in:
@@ -24,11 +24,12 @@ uv run pytest
|
||||
|
||||
1. 将 `.env.example` 复制为 `.env`,生成独立数据库、MinIO 管理和同步访问凭据。数据库 URL 使用 `postgresql+psycopg://…`,其中密码须 URL 编码。
|
||||
2. 执行 `docker compose up -d`。一次性 `initialize` 服务等待依赖后幂等创建 schema 与 `opennexus` Bucket;重复运行只检查并补齐缺失资源,不覆盖已有行或对象。长期运行的 `sync` 服务继续使用只限该 Bucket 的同步账号,不使用 MinIO root 身份。
|
||||
3. 执行 `docker compose run --rm sync /service/.venv/bin/python -m sync_server create-user`,密码交互输入,不放命令参数。
|
||||
4. 使用 Caddy 示例配置 TLS。默认通过 `SYNC_BIND_ADDRESS=127.0.0.1` 与
|
||||
3. 全新数据库会生成账户 `admin` 和本次启动专用的随机密码。使用 `docker compose logs sync` 查找 `SYNC_BOOTSTRAP_CREDENTIALS`;随机密码不会写入镜像、环境变量或数据库明文。只要账户尚未固定,服务每次重启都会更换该密码并撤销旧会话。
|
||||
4. 使用随机密码首次登录控制台后,必须立即修改账户名和密码。保存成功后凭据写入数据库,此后服务重启不再更换。已有正式账户的升级实例不会额外创建默认账户。仍可使用 `create-user` 运维命令增加独立账户,密码通过终端交互输入。
|
||||
5. 使用 Caddy 示例配置 TLS。默认通过 `SYNC_BIND_ADDRESS=127.0.0.1` 与
|
||||
`SYNC_PORT=8080` 只监听本机。仅限已授权的隔离测试阶段将监听地址改为
|
||||
`0.0.0.0` 并直接开放测试端口;该模式不作为生产发布配置。
|
||||
5. 检查 `/health`、`/ready` 及经过授权的上传/读取;`/ready` 探测数据库 schema、staging 读写和对象存储测试前缀。
|
||||
6. 检查 `/health`、`/ready` 及经过授权的上传/读取;`/ready` 探测数据库 schema、staging 读写和对象存储测试前缀。
|
||||
|
||||
`initialize` 命令已通过真实 PostgreSQL/MinIO 的空实例与重复运行验证,并由 Compose 的一次性服务调用。MinIO 同步账号仍须由管理员创建并限制到 `opennexus` Bucket,`.env` 中的 root 与同步凭据必须不同。
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ const username = ref('')
|
||||
const password = ref('')
|
||||
const deviceName = ref('OpenNexus Web Console')
|
||||
const sessionLabel = ref('')
|
||||
const credentialsRequired = ref(false)
|
||||
const currentPassword = ref('')
|
||||
const newUsername = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const newVaultName = ref('')
|
||||
const vaults = ref<Vault[]>([])
|
||||
const devices = ref<Device[]>([])
|
||||
@@ -72,6 +77,10 @@ function leaveConsole() {
|
||||
password.value = ''
|
||||
vaults.value = []
|
||||
devices.value = []
|
||||
credentialsRequired.value = false
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
@@ -82,17 +91,41 @@ async function signIn() {
|
||||
const device = deviceName.value.trim()
|
||||
password.value = ''
|
||||
try {
|
||||
await api.login(account, secret, device)
|
||||
credentialsRequired.value = await api.login(account, secret, device)
|
||||
sessionLabel.value = `${account} · ${device}`
|
||||
newUsername.value = account
|
||||
signedIn.value = true
|
||||
await loadAccount()
|
||||
notify('设备会话已建立')
|
||||
if (!credentialsRequired.value) await loadAccount()
|
||||
notify(credentialsRequired.value ? '请立即固定账户与密码' : '设备会话已建立')
|
||||
} catch (error) {
|
||||
leaveConsole()
|
||||
notify(error instanceof Error ? error.message : 'LOGIN_FAILED', true)
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function fixCredentials() {
|
||||
if (busy.value) return
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
notify('两次输入的新密码不一致', true)
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
const oldSecret = currentPassword.value
|
||||
const nextSecret = newPassword.value
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
try {
|
||||
const result = await api.changeCredentials(oldSecret, newUsername.value.trim(), nextSecret)
|
||||
credentialsRequired.value = false
|
||||
sessionLabel.value = `${result.username} · ${deviceName.value.trim()}`
|
||||
await loadAccount()
|
||||
notify('账户与密码已固定;以后重启不会再随机更换')
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : 'CREDENTIAL_CHANGE_FAILED', true)
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function createVault() {
|
||||
const name = newVaultName.value.trim()
|
||||
if (!name || busy.value) return
|
||||
@@ -198,6 +231,22 @@ onBeforeUnmount(() => {
|
||||
<button class="secondary-button" type="button" :disabled="busy" @click="logout">退出登录</button>
|
||||
</div>
|
||||
|
||||
<section v-if="credentialsRequired" class="login-card credential-card" aria-labelledby="credential-title">
|
||||
<div class="card-heading">
|
||||
<span class="lock-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="11" rx="3" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></svg></span>
|
||||
<div><p>首次登录</p><h2 id="credential-title">固定账户凭据</h2></div>
|
||||
</div>
|
||||
<p class="surface-intro">当前密码只对本次服务启动有效。修改账户和密码后,凭据将写入数据库并在后续重启中保持不变。</p>
|
||||
<form @submit.prevent="fixCredentials">
|
||||
<label>当前随机密码<input v-model="currentPassword" type="password" autocomplete="current-password" minlength="12" maxlength="256" required></label>
|
||||
<label>新账户<input v-model="newUsername" autocomplete="username" maxlength="80" required></label>
|
||||
<label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label>
|
||||
<label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label>
|
||||
<button class="primary-button" type="submit" :disabled="busy || !currentPassword || !newPassword || !confirmPassword">{{ busy ? '正在保存…' : '保存并固定凭据' }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<div class="metric-grid">
|
||||
<article><span>远端 Vault</span><strong>{{ vaults.length }}</strong><small>当前账户可访问</small></article>
|
||||
<article><span>已使用空间</span><strong>{{ formatBytes(used) }}</strong><small>总配额 {{ formatBytes(quota) }}</small></article>
|
||||
@@ -242,6 +291,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Session {
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
device_id: string
|
||||
must_change_credentials: boolean
|
||||
}
|
||||
|
||||
export interface Vault {
|
||||
@@ -39,6 +40,7 @@ export class SyncApi {
|
||||
private access = ''
|
||||
private refresh = ''
|
||||
deviceId = ''
|
||||
mustChangeCredentials = false
|
||||
|
||||
get signedIn() { return Boolean(this.access) }
|
||||
|
||||
@@ -80,6 +82,7 @@ export class SyncApi {
|
||||
this.access = session.access_token
|
||||
this.refresh = session.refresh_token
|
||||
this.deviceId = session.device_id
|
||||
this.mustChangeCredentials = Boolean(session.must_change_credentials)
|
||||
}
|
||||
|
||||
async status(): Promise<ServiceStatus> {
|
||||
@@ -103,7 +106,7 @@ export class SyncApi {
|
||||
}
|
||||
}
|
||||
|
||||
async login(username: string, password: string, deviceName: string): Promise<void> {
|
||||
async login(username: string, password: string, deviceName: string): Promise<boolean> {
|
||||
const response = await this.raw('/sync/v1/auth/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password, device_name: deviceName }),
|
||||
@@ -115,6 +118,17 @@ export class SyncApi {
|
||||
const session = await safeJson<Session>(response)
|
||||
if (!session) throw new Error('INVALID_RESPONSE')
|
||||
this.accept(session)
|
||||
return this.mustChangeCredentials
|
||||
}
|
||||
|
||||
async changeCredentials(currentPassword: string, username: string, password: string) {
|
||||
const result = await this.request<{ username: string; credentials_fixed: boolean }>(
|
||||
'/sync/v1/account/credentials', {
|
||||
method: 'PUT', body: JSON.stringify({ current_password: currentPassword, username, password }),
|
||||
}, false,
|
||||
)
|
||||
this.mustChangeCredentials = false
|
||||
return result
|
||||
}
|
||||
|
||||
vaults() { return this.request<{ items: Vault[] }>('/sync/v1/vaults') }
|
||||
@@ -136,5 +150,6 @@ export class SyncApi {
|
||||
this.access = ''
|
||||
this.refresh = ''
|
||||
this.deviceId = ''
|
||||
this.mustChangeCredentials = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ main { width: min(1180px, calc(100% - 48px)); margin: 0 auto; position: relative
|
||||
.protocol-grid strong { font-size: 18px; font-weight: 600; }
|
||||
.protocol-grid span { color: var(--muted); font-size: 11px; }
|
||||
.login-card { padding: 31px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); position: relative; overflow: hidden; }
|
||||
.credential-card { width: min(100%, 560px); margin: 32px auto 0; }
|
||||
.card-glow { position: absolute; width: 210px; height: 210px; right: -100px; top: -120px; border-radius: 50%; background: var(--accent); filter: blur(60px); opacity: .08; }
|
||||
.card-heading { display: flex; gap: 14px; align-items: center; margin-bottom: 28px; position: relative; }
|
||||
.card-heading p, .surface-heading p { margin: 0 0 3px; color: var(--accent); font-size: 10px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -153,3 +153,45 @@ def test_login_limits_and_protocol(env):
|
||||
for _ in range(10):
|
||||
assert client.post("/sync/v1/auth/sessions", json={"username": "unknown", "password": "controlled-fixture-password", "device_name": "fixture"}).status_code == 401
|
||||
assert client.post("/sync/v1/auth/sessions", json={"username": "unknown", "password": "controlled-fixture-password", "device_name": "fixture"}).status_code == 429
|
||||
|
||||
|
||||
def test_bootstrap_password_rotates_until_account_is_fixed(tmp_path):
|
||||
db = Database("sqlite:///" + str(tmp_path / "bootstrap.db"))
|
||||
db.migrate()
|
||||
first = db.prepare_bootstrap_user()
|
||||
second = db.prepare_bootstrap_user()
|
||||
assert first["username"] == second["username"] == "admin"
|
||||
assert first["password"] != second["password"]
|
||||
|
||||
app = create_app(db, DiskObjects(tmp_path / "objects"), tmp_path / "staging")
|
||||
with TestClient(app) as client:
|
||||
old = client.post("/sync/v1/auth/sessions", json={
|
||||
"username": "admin", "password": first["password"], "device_name": "旧启动",
|
||||
})
|
||||
assert old.status_code == 401
|
||||
login = client.post("/sync/v1/auth/sessions", json={
|
||||
"username": "admin", "password": second["password"], "device_name": "首次登录",
|
||||
})
|
||||
assert login.status_code == 200
|
||||
assert login.json()["must_change_credentials"] is True
|
||||
headers = {"Authorization": "Bearer " + login.json()["access_token"]}
|
||||
blocked = client.get("/sync/v1/vaults", headers=headers)
|
||||
assert blocked.status_code == 403
|
||||
assert blocked.json()["error"]["code"] == "CREDENTIAL_CHANGE_REQUIRED"
|
||||
|
||||
changed = client.put("/sync/v1/account/credentials", headers=headers, json={
|
||||
"current_password": second["password"],
|
||||
"username": "owner",
|
||||
"password": "fixed-production-password",
|
||||
})
|
||||
assert changed.json() == {"username": "owner", "credentials_fixed": True}
|
||||
assert client.post("/sync/v1/vaults", headers=headers, json={"name": "固定账户"}).status_code == 200
|
||||
|
||||
assert db.prepare_bootstrap_user() is None
|
||||
with TestClient(app) as client:
|
||||
login = client.post("/sync/v1/auth/sessions", json={
|
||||
"username": "owner", "password": "fixed-production-password", "device_name": "重启后",
|
||||
})
|
||||
assert login.status_code == 200
|
||||
assert login.json()["must_change_credentials"] is False
|
||||
db.engine.dispose()
|
||||
|
||||
Reference in New Issue
Block a user