Files
server-core/app/routers/auth.py
T
Pine 26fa546f67 feat: 核心服务端基础框架(身份/平台 API)
- 七端口 RBAC、select-identity、JWT、审计
- FastAPI + SQLAlchemy + SQLite,/auth /opc /admin /agents 等路由
2026-08-23 22:35:59 +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 ..models import (
AuthStatusResponse,
IdentityInfo,
LoginRequest,
LoginResponse,
ProfileResponse,
RegisterRequest,
RevokeTokenRequest,
SelectIdentityRequest,
UpdateProfileRequest,
VerifyResponse,
)
from ..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
]
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 = db.roles.permissions_for(role, sub_role)
scope_ids = db.regions.visible_region_ids(region_id)
scope_level = 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 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,
)
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"] = db.roles.permissions_for(role, sub_role)
profile["scope_region_ids"] = 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 = db.users.get_by_username(req.username)
if user is None or not 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 = db.identities.list_for_user(user["id"], active_only=True)
identity = identities[0] if len(identities) == 1 else None
token_record = _issue_token(db, user, req.expires_in, identity=identity)
profile = _profile_for(user, identity, db)
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 = 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 = _issue_token(db, user, None, identity=ident)
profile = _profile_for(user, ident, db)
identities = 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 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 = db.users.create(req.username, req.password)
identity = db.identities.create(
user["id"], port="opc", role="opc_member",
sub_role="independent", name="独立OPC",
)
token_record = _issue_token(db, user, req.expires_in, identity=identity)
profile = _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=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 = 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 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 db.users.verify_password(user, req.current_password):
raise HTTPException(status_code=401, detail="Current password is incorrect")
if profile_updates:
db.users.update_profile(user_id, profile_updates)
issued_token = ""
if changing_credentials:
db.users.update_credentials(user_id, new_username, new_password)
db.tokens.revoke_all(user_id)
fresh_user = db.users.get_by_id(user_id)
token_record = _issue_token(db, fresh_user, req.expires_in)
issued_token = token_record["token"]
fresh_user = db.users.get_by_id(user_id)
fresh_user["permissions"] = db.roles.permissions_for(
fresh_user.get("role", "opc_member"), fresh_user.get("sub_role"),
)
fresh_user["scope_region_ids"] = db.regions.visible_region_ids(fresh_user.get("region_id"))
return ProfileResponse(
token=issued_token,
**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 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),
):
"""吊销所有令牌,所有会话需重新登录。"""
db.tokens.revoke_all()
return {
"message": "All tokens have been revoked. Please login again.",
"revoked": True,
}