Files
vps-manager/app/routers/credentials.py
T
gouki d711ad5827 feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管
credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
2026-09-05 15:54:41 +00:00

114 lines
3.9 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.
"""登录凭据(密码库)CRUD 路由
统一入口 /api/credentials,写操作(POST/PUT/DELETE)受 API Key 保护
Tailscale 内网/本机放行,外部来源必须携带 X-API-Key)。
- 明文密码经 /{id}/password 专用接口解密返回(与账号 reveal 语义一致)
- 2FA 动态码由服务端生成(secret 永不出库),/{id}/otp 响应 no-store 防缓存残留
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query, Response
from sqlmodel import Session
from app.core.security import require_api_key
from app.database import get_session
from app.schemas.credential import (
CredentialCreate,
CredentialRead,
CredentialUpdate,
OtpBindRequest,
)
from app.services import credential_service
router = APIRouter(prefix="/api/credentials", tags=["credentials"])
@router.get("", response_model=List[CredentialRead], summary="凭据列表(密码库,支持搜索/过滤)")
def list_credentials(
q: Optional[str] = Query(default=None, description="搜索站点/用户名/备注"),
login_type: Optional[str] = Query(default=None, description="登录方式:password/oauth/other"),
has_otp: Optional[bool] = Query(default=None, description="是否已绑定 2FA"),
session: Session = Depends(get_session),
):
return credential_service.list_credentials(
session, q=q, login_type=login_type, has_otp=has_otp
)
@router.post(
"",
response_model=CredentialRead,
status_code=201,
summary="创建凭据(可同时绑定 2FA,需当前动态码校验)",
dependencies=[Depends(require_api_key)],
)
def create_credential(data: CredentialCreate, session: Session = Depends(get_session)):
return credential_service.create_credential(session, data)
@router.put(
"/{credential_id}",
response_model=CredentialRead,
summary="更新凭据(密码:留空不改,空串清除)",
dependencies=[Depends(require_api_key)],
)
def update_credential(
credential_id: int, data: CredentialUpdate, session: Session = Depends(get_session)
):
return credential_service.update_credential(session, credential_id, data)
@router.delete(
"/{credential_id}",
summary="删除凭据(账号/资产不受影响,仅解除关联)",
dependencies=[Depends(require_api_key)],
)
def delete_credential(credential_id: int, session: Session = Depends(get_session)) -> dict:
return credential_service.delete_credential(session, credential_id)
@router.get(
"/{credential_id}/password",
summary="查看凭据密码明文(解密返回)",
dependencies=[Depends(require_api_key)],
)
def reveal_password(credential_id: int, session: Session = Depends(get_session)) -> dict:
return credential_service.reveal_password(session, credential_id)
@router.get(
"/{credential_id}/otp",
summary="获取当前 2FA 动态码(服务端生成,secret 不出库)",
dependencies=[Depends(require_api_key)],
)
def current_otp(
credential_id: int, response: Response, session: Session = Depends(get_session)
) -> dict:
# 动态码禁止缓存(浏览器/代理),避免有效期内残留
response.headers["Cache-Control"] = "no-store"
return credential_service.current_otp(session, credential_id)
@router.put(
"/{credential_id}/otp",
response_model=CredentialRead,
summary="绑定 2FAsecret + 当前动态码,校验通过才落库)",
dependencies=[Depends(require_api_key)],
)
def bind_otp(
credential_id: int, data: OtpBindRequest, session: Session = Depends(get_session)
):
return credential_service.bind_otp(session, credential_id, data)
@router.delete(
"/{credential_id}/otp",
response_model=CredentialRead,
summary="解绑 2FA",
dependencies=[Depends(require_api_key)],
)
def unbind_otp(credential_id: int, session: Session = Depends(get_session)):
return credential_service.unbind_otp(session, credential_id)