diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 73b32785..caa5e5a2 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -196,12 +196,29 @@ async def jwks() -> dict[str, Any]: # ---------------------------------------------------------------- 设置与引导 -def _settings_out(value: dict[str, Any]) -> dict[str, Any]: +#: 设置页列出的管理员最多几个(只拿来说「切过去用谁登录」「谁还没设密码」,不是用户列表) +ADMINS_SHOWN = 10 + + +async def _admin_accounts(session: AsyncSession) -> dict[str, Any]: + """没停用的管理员里,哪些能用口令登录、哪些还没设口令。 + + 设置页据此决定切到账号密码登录时要不要建第一个管理员:已经有能登录的就不用建(切过去用它登录),只有没设口令的 + 要说清是谁——关登录时新建用户可以不填密码,这样的管理员切过去登录不了。判据和 _local_admin_exists 同一个。 + """ + rows = (await session.execute(select(User.username, User.display_name, User.password_hash).where( + User.role == "admin", User.disabled.is_(False)).order_by(User.username, User.id))).all() + login = [{"username": r.username, "display_name": r.display_name or r.username} for r in rows if r.password_hash] + passwordless = [r.username for r in rows if not r.password_hash] + return {"local_admins": login[:ADMINS_SHOWN], "passwordless_admins": passwordless[:ADMINS_SHOWN]} + + +async def _settings_out(session: AsyncSession, value: dict[str, Any]) -> dict[str, Any]: config = {k: v for k, v in (value.get("oidc") or {}).items() if k in _OIDC_FIELDS} if config.get("client_secret"): config["client_secret"] = crypto.mask(config["client_secret"]) return {"mode": current_mode(), "env_locked": env_mode() is not None, "oidc": config, - "user_can_chat": bool(value.get("user_can_chat"))} + "user_can_chat": bool(value.get("user_can_chat")), **await _admin_accounts(session)} def _require_admin() -> None: @@ -211,9 +228,9 @@ def _require_admin() -> None: @router.get("/settings") -async def get_settings() -> dict[str, Any]: +async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str, Any]: _require_admin() - return _settings_out(await load_settings()) + return await _settings_out(session, await load_settings()) class BootstrapIn(BaseModel): @@ -321,7 +338,7 @@ async def put_settings(payload: SettingsIn, request: Request, response: Response from app.cluster import caches await caches.broadcast("auth_settings") - out = _settings_out(value) + out = await _settings_out(session, value) if token is not None and created is not None: _login_response(request, response, token) out["principal"] = await principal_full(session, await sessions.principal_of(session, created, current_mode())) diff --git a/backend/tests/test_auth_identity.py b/backend/tests/test_auth_identity.py index 00fadaf5..91b7c473 100644 --- a/backend/tests/test_auth_identity.py +++ b/backend/tests/test_auth_identity.py @@ -84,6 +84,34 @@ async def test_switching_to_local_needs_the_first_admin_in_the_same_request(): "库里只存 token 的 sha256" +async def test_switching_to_local_with_an_admin_who_has_a_password_does_not_ask_for_another(): + """关登录时已经建了设过密码的管理员:设置页不该再要求建第一个管理员,切过去用他登录。""" + await auth_env.make_user("ada", "admin") + await auth_env.make_user("bo", "builder") + c = auth_env.client() + got = (await c.get("/api/auth/settings")).json() + assert got["local_admins"] == [{"username": "ada", "display_name": "ada"}], got + assert got["passwordless_admins"] == [] + r = await c.put("/api/auth/settings", json={"mode": "local"}) + assert r.status_code == 200, r.text + assert "principal" not in r.json(), "没新建管理员就不替谁登录:切过去回登录页" + assert (await c.get("/api/auth/me")).status_code == 401 + assert (await (await auth_env.login("ada")).get("/api/auth/me")).json()["role"] == "admin" + + +async def test_an_admin_without_a_password_is_named_and_still_needs_the_first_admin(): + """关登录时新建用户可以不填密码:这样的管理员切过去登录不了,要建一个能登录的,并说清是谁没设密码。""" + async with SessionLocal() as s: + s.add(User(username="nopw", display_name="没设密码的", role="admin", password_hash=None)) + s.add(User(username="gone", display_name="停用的", role="admin", password_hash=None, disabled=True)) + await s.commit() + c = auth_env.client() + got = (await c.get("/api/auth/settings")).json() + assert got["local_admins"] == [] and got["passwordless_admins"] == ["nopw"], got + r = await c.put("/api/auth/settings", json={"mode": "local"}) + assert r.status_code == 422 and "第一个管理员" in r.json()["detail"] + + async def test_roles_401_403_and_forged_actor(): admin = await auth_env.turn_on_local() await auth_env.make_user("bob", "builder") diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index b20eb648..cbbfe29e 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -14,8 +14,10 @@ export const auth = { logout: () => post('/auth/logout'), /** OIDC:浏览器整页跳转到这里 */ oidcStartUrl: (next?: string) => `/api/auth/oidc/start${qs({ next })}`, - settings: () => get<{ mode: AuthMode; env_locked: boolean; oidc: Record; user_can_chat: boolean }>( - '/auth/settings'), + /** local_admins:没停用、能用密码登录的管理员;passwordless_admins:没停用、还没设密码的管理员(切到账号密码登录时据此 + * 决定要不要同时建第一个管理员) */ + settings: () => get<{ mode: AuthMode; env_locked: boolean; oidc: Record; user_can_chat: boolean; + local_admins?: { username: string; display_name: string }[]; passwordless_admins?: string[] }>('/auth/settings'), saveSettings: (body: { mode?: AuthMode; oidc?: Record; user_can_chat?: boolean; bootstrap?: { username: string; password: string; display_name?: string } }) => put('/auth/settings', body), } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 55bbbfe6..070cc150 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -5706,6 +5706,11 @@ export const IDENTITY_TEXT = { envLocked: '登录方式由服务端环境变量指定,这里不能修改', bootstrap: '第一个管理员', bootstrapHint: '切换到本地账号时必须同时建出第一个管理员,否则切换后没有人能登录', + /** 已经有能用密码登录的管理员:切过去不用再建,保存后回登录页用他登录 */ + existingAdmins: (names: string) => `已有能用密码登录的管理员:${names}。切换后用其中一个账号登录,保存后会回到登录页`, + /** 有管理员、但都没设密码(关登录时新建用户可以不填密码) */ + passwordlessAdmins: (names: string) => + `已有的管理员 ${names} 还没设密码,切换后不能用来登录:可以在下面的用户列表里给其中一位重设密码,或者在这里新建一个管理员`, oidcTitle: '单点登录设置', oidcIssuer: '签发方地址', oidcClientId: '客户端标识', diff --git a/frontend/src/pages/settings/IdentityTab.tsx b/frontend/src/pages/settings/IdentityTab.tsx index b221ddca..5c4fb7e6 100644 --- a/frontend/src/pages/settings/IdentityTab.tsx +++ b/frontend/src/pages/settings/IdentityTab.tsx @@ -13,7 +13,8 @@ import type { AuthMode, GroupRow, Role, UserRow } from '../../typesApp' /* * 设置 → 身份(A7、§3.12):登录方式、单点登录、使用者能否问数据、用户、组。只给管理员;关登录时所有人等同管理员。 * - * 从关闭登录切到本地账号时,必须在同一个请求里建出第一个管理员(否则切过去没有人能登录);切到单点登录时至少 + * 从关闭登录(或单点登录)切到本地账号时,库里还没有能用密码登录的管理员,就必须在同一个请求里建出第一个管理员(否则切过去 + * 没有人能登录);已经有了就不用建,保存后回登录页用他登录。有管理员但都没设密码(关登录时新建用户可以不填)的,照实说是谁。切到单点登录时至少 * 配一个映射为管理员的用户或组(admin_users、admin_groups 至少一项:小团队的单点登录服务常常没有组声明)。环境变量指定了登录方式时这里只读。人员变动只改组成员,不发应用的新版本(写审计) */ @@ -37,7 +38,8 @@ export function IdentityTab() { return (
- + {/* 用户列表里新建了管理员、重设了密码:登录方式那一块要重新判断还要不要建第一个管理员 */} +
) @@ -51,7 +53,11 @@ function ModeSection({ settings, onSaved }: { settings: Settings; onSaved: () => const [boot, setBoot] = useState({ username: '', password: '', display_name: '' }) const [busy, setBusy] = useState(false) const radio = useRadioGroup(MODES, mode, setMode) - const needsBoot = settings.mode === 'off' && mode === 'local' + const loginAdmins = settings.local_admins ?? [] + const passwordless = settings.passwordless_admins ?? [] + const toLocal = settings.mode !== 'local' && mode === 'local' + // 已经有能用密码登录的管理员就不用再建:后端也是这条判据(_local_admin_exists) + const needsBoot = toLocal && loginAdmins.length === 0 const oidcList = (key: string): string => Array.isArray(oidc[key]) ? oidc[key].join('\n') : typeof oidc[key] === 'string' ? oidc[key] : '' const setList = (key: string, text: string) => @@ -91,10 +97,20 @@ function ModeSection({ settings, onSaved }: { settings: Settings; onSaved: () => ))} + {toLocal && !needsBoot && ( +

+ {T.existingAdmins(loginAdmins.map((a) => a.username).join('、'))} +

+ )} {needsBoot && (
{T.bootstrap}

{T.bootstrapHint}

+ {passwordless.length > 0 && ( +

+ {T.passwordlessAdmins(passwordless.join('、'))} +

+ )}
{(p) => ) } -function UsersSection({ mode }: { mode: AuthMode }) { +function UsersSection({ mode, onChanged }: { mode: AuthMode; onChanged?: () => void }) { const [rows, setRows] = useState(null) const [error, setError] = useState(null) const [creating, setCreating] = useState(false) @@ -167,6 +183,7 @@ function UsersSection({ mode }: { mode: AuthMode }) { try { await api.identity.updateUser(u.id, body) load() + onChanged?.() } catch (e) { toast.error(e) } @@ -219,7 +236,7 @@ function UsersSection({ mode }: { mode: AuthMode }) { ))}
)} - {creating && setCreating(false)} onSaved={() => { setCreating(false); load() }} />} + {creating && setCreating(false)} onSaved={() => { setCreating(false); load(); onChanged?.() }} />} ) }