Files
server-core/app/api/routers/auth.py
T

322 lines
12 KiB
Python
Raw Normal View History

# -*- 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,
}