Files
hair/gateway/client_auth.py
T

156 lines
5.0 KiB
Python

"""Expiring client credentials for the protected gateway interfaces."""
import base64
import binascii
import hashlib
import hmac
import json
import os
import secrets
import time
from uuid import uuid4
from fastapi import APIRouter, Depends, Security
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from gateway.config import get_config
ACCESS_TTL = 15 * 60
REFRESH_TTL = 7 * 24 * 60 * 60
_USERNAME = "yonghe"
_PASSWORD = "yonghe1999"
router = APIRouter(prefix="/api/v1/auth", tags=["客户端鉴权"])
_bearer = HTTPBearer(auto_error=False, scheme_name="ClientBearer")
class LoginRequest(BaseModel):
username: str = Field(max_length=128)
password: str = Field(max_length=256)
class RefreshRequest(BaseModel):
refresh_token: str = Field(max_length=1024)
def _response(code, message, *, status=200, data=None, challenge=False):
headers = {"Cache-Control": "no-store"}
if challenge:
headers["WWW-Authenticate"] = "Bearer"
return JSONResponse(status_code=status, headers=headers, content={
"code": code, "message": message, "request_id": f"gw-{uuid4().hex[:8]}", "data": data,
})
def _secret():
value = os.getenv("HAIR_API_TOKEN_SECRET")
if value is None:
try:
value = get_config().get("api_token_secret")
except FileNotFoundError:
return None
if not isinstance(value, str) or len(value.encode("utf-8")) < 32:
return None
return value.encode("utf-8")
def _encode(raw):
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _decode(value):
return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
def _issue(secret, kind, ttl):
payload = _encode(json.dumps({
"sub": _USERNAME,
"kind": kind,
"exp": int(time.time()) + ttl,
"nonce": secrets.token_urlsafe(16),
}, separators=(",", ":")).encode())
signature = hmac.digest(secret, payload.encode("ascii"), hashlib.sha256)
return payload + "." + _encode(signature)
def _valid(token, secret, kind):
if not token or len(token) > 1024:
return False
try:
payload, signature = token.split(".")
expected = _encode(hmac.digest(secret, payload.encode("ascii"), hashlib.sha256))
if not hmac.compare_digest(signature.encode("ascii"), expected.encode("ascii")):
return False
claims = json.loads(_decode(payload))
return (
isinstance(claims, dict)
and claims.get("sub") == _USERNAME
and claims.get("kind") == kind
and type(claims.get("exp")) is int
and time.time() < claims["exp"]
)
except (ValueError, UnicodeError, binascii.Error):
return False
def _unconfigured():
return _response(1007, "客户端令牌服务未配置", status=503)
def _unauthorized(message):
return _response(1009, message, status=401, challenge=True)
def _access_data(secret):
return {
"access_token": _issue(secret, "access", ACCESS_TTL),
"token_type": "Bearer",
"expires_in": ACCESS_TTL,
}
@router.post("/login", summary="用户名和密码换取访问及刷新令牌")
async def login(body: LoginRequest):
username_ok = hmac.compare_digest(body.username.encode("utf-8"), _USERNAME.encode("utf-8"))
password_ok = hmac.compare_digest(body.password.encode("utf-8"), _PASSWORD.encode("utf-8"))
if not (username_ok and password_ok):
return _unauthorized("用户名或密码错误")
secret = _secret()
if secret is None:
return _unconfigured()
data = _access_data(secret)
data.update(refresh_token=_issue(secret, "refresh", REFRESH_TTL), refresh_expires_in=REFRESH_TTL)
return _response(0, "success", data=data)
@router.post("/refresh", summary="刷新访问令牌")
async def refresh(body: RefreshRequest):
secret = _secret()
if secret is None:
return _unconfigured()
if not _valid(body.refresh_token, secret, "refresh"):
return _unauthorized("刷新令牌无效或已过期,请重新登录")
return _response(0, "success", data=_access_data(secret))
async def client_auth_error(credentials: HTTPAuthorizationCredentials | None = Security(_bearer)):
"""Return an error response for protected handlers before local work or dispatch."""
if credentials is None:
return _unauthorized("请提供 Authorization: Bearer 访问令牌")
secret = _secret()
if secret is None:
return _unconfigured()
if not _valid(credentials.credentials, secret, "access"):
return _unauthorized("访问令牌无效或已过期")
return None
@router.get("/check", summary="验证当前访问令牌")
async def check(auth_error=Depends(client_auth_error)):
"""检查与 42、52、72 相同的 Bearer 令牌,无需提交图片。"""
if auth_error is not None:
return auth_error
return _response(0, "访问令牌有效")