Files
server-core/app/api/routers/auth.py
T
Pine a869b2934a test: 平台测试套件适配异步四层,66 全绿
- conftest 改为 monkeypatch DATABASE_URL + lifespan 建库(解决异步引擎跨事件循环)
- 修复 seed_data 辅助函数 await、_to_dict 链式 await、get_current_user await
- 修复 Database.initialize 建表于本实例引擎
- 平台 19 路由全部经异步接口回归通过
2026-08-24 00:34:28 +08:00

322 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""认证路由:登录 / 注册 / 状态 / 校验 / 资料 / 令牌管理。
这是供 PineAgents 主后端 ``src/pineagents/app/routers/auth.py`` 转发调用的
"演示 FastAPI"DEMO_API_BASE_URL,默认 http://127.0.0.1:8090)。
路由前缀 ``/auth``,字段与主后端转发模型完全一致,保证透传不丢字段。
"""
from __future__ import annotations
import re
from fastapi import APIRouter, Depends, HTTPException, Request
from ... import config
from ..dependencies import get_current_user, get_db, extract_bearer_token
from ...infrastructure.models import (
AuthStatusResponse,
IdentityInfo,
LoginRequest,
LoginResponse,
ProfileResponse,
RegisterRequest,
RevokeTokenRequest,
SelectIdentityRequest,
UpdateProfileRequest,
VerifyResponse,
)
from ...infrastructure.repositories import Database
router = APIRouter(prefix="/auth", tags=["auth"])
# 账号统一使用手机号作为登录账号(11 位,1 开头)
_PHONE_RE = re.compile(r"^1\d{10}$")
def _is_phone(account: str) -> bool:
return bool(_PHONE_RE.match(account.strip()))
# 账户管理页可编辑的资料字段(username 作为登录名走 update_credentials,单独处理)
_PROFILE_FIELDS = (
"nickname",
"account",
"room",
"avatar",
"company_avatar",
)
def _identity_summaries(identities: list[dict]) -> list[IdentityInfo]:
"""把 user_identities 记录裁剪成对外身份摘要。"""
return [
IdentityInfo(
id=i["id"], port=i["port"], role=i["role"], sub_role=i.get("sub_role"),
name=i.get("name", ""), org_id=i.get("org_id"), region_id=i.get("region_id"),
org_name=i.get("org_name"), region_name=i.get("region_name"),
status=i.get("status", "active"),
)
for i in identities
]
async def _issue_token(
db: Database,
user: dict,
expires_in: int | None,
identity: dict | None = None,
) -> dict:
"""为指定用户签发 JWT(可按端口身份作用域),返回 token 记录。
``identity`` 非空时按该身份解析 role/sub_role/org/region 与权限;
否则回退到 ``users.role``(单角色/中性令牌)。
"""
role = (identity or user).get("role", "opc_member")
sub_role = (identity or user).get("sub_role")
org_id = (identity or user).get("org_id")
region_id = (identity or user).get("region_id")
identity_id = identity.get("id") if identity else None
perms = await db.roles.permissions_for(role, sub_role)
scope_ids = await db.regions.visible_region_ids(region_id)
scope_level = await db.regions.level(region_id)
token_user = dict(user)
token_user.update(role=role, sub_role=sub_role, org_id=org_id, region_id=region_id)
return await db.tokens.create(
token_user,
permissions=perms,
scope_region_ids=scope_ids,
scope_level=scope_level,
expiry_seconds=config.resolve_token_expiry(expires_in),
identity_id=identity_id,
)
async def _profile_for(
user: dict,
identity: dict | None,
db: Database,
) -> dict:
"""按身份(或回退用户单角色)构建对外资料字典。"""
role = (identity or user).get("role", "opc_member")
sub_role = (identity or user).get("sub_role")
org_id = (identity or user).get("org_id")
region_id = (identity or user).get("region_id")
profile = {f: user.get(f, "") for f in _PROFILE_FIELDS}
profile.update(username=user.get("username", ""))
profile["role"] = role
profile["sub_role"] = sub_role
profile["org_id"] = org_id
profile["region_id"] = region_id
profile["permissions"] = await db.roles.permissions_for(role, sub_role)
profile["scope_region_ids"] = await db.regions.visible_region_ids(region_id)
if identity:
profile["identity_id"] = identity["id"]
profile["port"] = identity["port"]
profile["identity_name"] = identity.get("name", "")
return profile
@router.post("/login", response_model=LoginResponse, summary="登录")
async def login(req: LoginRequest, db: Database = Depends(get_db)):
"""校验用户名密码,返回账号绑定的全部端口身份 + 令牌。
- 仅一个身份:令牌直接绑定该身份(自动登录)。
- 多个身份:签发中性账号令牌,前端展示身份选择,经
``/auth/select-identity`` 切换到指定身份后进入对应端口。
"""
user = await db.users.get_by_username(req.username)
if user is None or not await db.users.verify_password(user, req.password):
raise HTTPException(status_code=401, detail="Invalid username or password")
if user.get("status") != "active":
raise HTTPException(status_code=403, detail="Account is disabled")
identities = await db.identities.list_for_user(user["id"], active_only=True)
identity = identities[0] if len(identities) == 1 else None
token_record = await _issue_token(db, user, req.expires_in, identity=identity)
profile = await _profile_for(user, identity, db)
await db.audit.add(
action="login", resource="auth", resource_id=user["id"],
detail=f"login {user['username']} ({len(identities)} identities)",
user_id=user["id"],
)
return LoginResponse(
token=token_record["token"],
identities=_identity_summaries(identities),
**profile,
)
@router.post("/select-identity", response_model=LoginResponse, summary="切换端口身份")
async def select_identity(
req: SelectIdentityRequest,
user: dict = Depends(get_current_user),
db: Database = Depends(get_db),
):
"""把当前令牌切换到指定端口身份(该身份须属于当前账号且为启用状态)。"""
ident = await db.identities.get_for_user(req.identity_id, user["id"])
if ident is None or ident.get("status") != "active":
raise HTTPException(status_code=404, detail="Identity not found or disabled")
token_record = await _issue_token(db, user, None, identity=ident)
profile = await _profile_for(user, ident, db)
identities = await db.identities.list_for_user(user["id"], active_only=True)
return LoginResponse(
token=token_record["token"],
identities=_identity_summaries(identities),
**profile,
)
@router.post("/register", response_model=LoginResponse, summary="注册")
async def register(req: RegisterRequest, db: Database = Depends(get_db)):
"""注册唯一账户(演示端已存在 pine,故返回 403)。"""
if not _is_phone(req.username):
raise HTTPException(
status_code=400,
detail="账号需为 11 位手机号(如 138xxxx1234",
)
if not config.AUTH_ENABLED:
raise HTTPException(status_code=403, detail="Authentication is not enabled")
if await db.users.has_users():
raise HTTPException(status_code=403, detail="User already registered")
if not req.username.strip() or not req.password.strip():
raise HTTPException(
status_code=400,
detail="Username and password are required",
)
user = await db.users.create(req.username, req.password)
identity = await db.identities.create(
user["id"], port="opc", role="opc_member",
sub_role="independent", name="独立OPC",
)
token_record = await _issue_token(db, user, req.expires_in, identity=identity)
profile = await _profile_for(user, identity, db)
return LoginResponse(
token=token_record["token"],
identities=_identity_summaries([identity]),
**profile,
)
@router.get("/status", response_model=AuthStatusResponse, summary="认证状态")
async def auth_status(db: Database = Depends(get_db)):
"""前端登录页据此判断是否展示登录表单。"""
return AuthStatusResponse(
enabled=config.AUTH_ENABLED,
has_users=await db.users.has_users(),
)
@router.get("/verify", response_model=VerifyResponse, summary="校验令牌")
async def verify(request: Request, db: Database = Depends(get_db)):
"""校验调用方 Bearer 令牌;无效令牌由依赖层直接返回 401。"""
if not config.AUTH_ENABLED:
return VerifyResponse(valid=True, username="")
user = await get_current_user(request, db)
return VerifyResponse(valid=True, username=user["username"])
@router.get("/me", response_model=dict, summary="当前用户资料")
async def me(
user: dict = Depends(get_current_user),
db: Database = Depends(get_db),
):
"""返回当前登录用户的完整资料(含角色/组织/区域/权限/数据范围)。"""
return await db.users.to_profile(user)
@router.post("/update-profile", response_model=ProfileResponse, summary="更新资料/凭据")
async def update_profile(
req: UpdateProfileRequest,
user: dict = Depends(get_current_user),
db: Database = Depends(get_db),
):
"""更新演示资料与/或用户名、密码,返回更新后的完整资料。
- 修改用户名/密码时必须提供正确的 ``current_password``
- 修改凭据后吊销该用户其余会话并签发新令牌随响应返回;
- 仅改资料字段时不重签令牌。
"""
payload = req.model_dump(exclude_none=True)
user_id = user["id"]
new_username = payload.get("new_username")
new_password = payload.get("new_password")
if new_username is not None and not new_username.strip():
raise HTTPException(status_code=400, detail="Username cannot be empty")
if new_password is not None and not new_password.strip():
raise HTTPException(status_code=400, detail="Password cannot be empty")
profile_updates = {k: payload[k] for k in _PROFILE_FIELDS if k in payload}
changing_credentials = new_username is not None or new_password is not None
if not profile_updates and not changing_credentials:
raise HTTPException(status_code=400, detail="Nothing to update")
if changing_credentials and not await db.users.verify_password(user, req.current_password):
raise HTTPException(status_code=401, detail="Current password is incorrect")
if profile_updates:
await db.users.update_profile(user_id, profile_updates)
issued_token = ""
if changing_credentials:
await db.users.update_credentials(user_id, new_username, new_password)
await db.tokens.revoke_all(user_id)
fresh_user = await db.users.get_by_id(user_id)
token_record = await _issue_token(db, fresh_user, req.expires_in)
issued_token = token_record["token"]
fresh_user = await db.users.get_by_id(user_id)
fresh_user["permissions"] = await db.roles.permissions_for(
fresh_user.get("role", "opc_member"), fresh_user.get("sub_role"),
)
fresh_user["scope_region_ids"] = await db.regions.visible_region_ids(fresh_user.get("region_id"))
return ProfileResponse(
token=issued_token,
**await db.users.to_profile(fresh_user),
)
@router.post("/revoke-token", summary="吊销单个令牌")
async def revoke_single_token(
req: RevokeTokenRequest,
request: Request,
user: dict = Depends(get_current_user),
db: Database = Depends(get_db),
):
"""吊销指定令牌(省略则吊销当前令牌)。"""
caller_token = extract_bearer_token(request)
token_to_revoke = req.token or caller_token
is_current = token_to_revoke == caller_token
if not await db.tokens.revoke(token_to_revoke):
raise HTTPException(status_code=500, detail="Failed to revoke token")
message = (
"Current token has been revoked. Please login again."
if is_current
else "Specified token has been revoked."
)
return {
"message": message,
"revoked": True,
"revoked_current_token": is_current,
}
@router.post("/revoke-all-tokens", summary="吊销全部令牌")
async def revoke_all_sessions(
user: dict = Depends(get_current_user),
db: Database = Depends(get_db),
):
"""吊销所有令牌,所有会话需重新登录。"""
await db.tokens.revoke_all()
return {
"message": "All tokens have been revoked. Please login again.",
"revoked": True,
}