integrate sub2api as upstream for auth/keys/usage via FastAPI BFF
Preserve local user table for superDream-specific features while syncing user lifecycle, API key CRUD and usage queries through sub2api. Admin token handles reads and user lifecycle; per-user tokens (Fernet-encrypted in DB) handle key writes that admin endpoints do not expose. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,41 @@
|
||||
"""Auth service: local user table + sub2api sync.
|
||||
|
||||
Registration and login keep two systems in sync:
|
||||
- superDream local User row (owns password hash, superDream JWT)
|
||||
- sub2api User (owns API keys, usage, balance)
|
||||
|
||||
On registration, sub2api user is created via admin API; then we login to sub2api
|
||||
with the same password to obtain and store a refresh_token. Any sub2api failure
|
||||
rolls back the local insert so the two systems never drift apart silently.
|
||||
|
||||
On login, after local password verification we also refresh the stored sub2api
|
||||
tokens (by re-logging in to sub2api) so that subsequent proxied calls have a
|
||||
valid refresh_token without ever persisting the plaintext password.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import User
|
||||
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
||||
from app.core.exceptions import BadRequestError, UnauthorizedError
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
create_reset_token,
|
||||
decode_token,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from app.integrations.sub2api import admin as sub2api_admin
|
||||
from app.integrations.sub2api import user as sub2api_user
|
||||
from app.integrations.sub2api.client import Sub2APIError, Sub2APITransportError
|
||||
from app.models import User
|
||||
from app.services.sub2api_session import store_tokens
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_password(password: str) -> None:
|
||||
@@ -17,8 +47,14 @@ def _validate_password(password: str) -> None:
|
||||
raise BadRequestError("密码需同时包含字母和数字")
|
||||
|
||||
|
||||
class AuthService:
|
||||
def _upstream_failure(action: str, exc: Exception) -> BadRequestError:
|
||||
logger.error("sub2api %s failed: %s", action, exc)
|
||||
if isinstance(exc, Sub2APIError):
|
||||
return BadRequestError(f"上游同步失败:{exc.message or exc.reason or 'unknown'}")
|
||||
return BadRequestError("上游服务不可用,请稍后重试")
|
||||
|
||||
|
||||
class AuthService:
|
||||
@staticmethod
|
||||
async def register(db: AsyncSession, email: str, password: str) -> User:
|
||||
_validate_password(password)
|
||||
@@ -27,10 +63,44 @@ class AuthService:
|
||||
if existing.scalar_one_or_none():
|
||||
raise BadRequestError("Email already registered")
|
||||
|
||||
user = User(email=email, password_hash=hash_password(password))
|
||||
# 1. Create the sub2api user first (so a conflict on their side halts us
|
||||
# before we touch our DB).
|
||||
try:
|
||||
remote = await sub2api_admin.create_user(email=email, password=password)
|
||||
except (Sub2APIError, Sub2APITransportError) as exc:
|
||||
raise _upstream_failure("create_user", exc) from exc
|
||||
|
||||
remote_user_id = int(remote.get("id"))
|
||||
|
||||
# 2. Grab a token pair by logging in as the new user.
|
||||
try:
|
||||
tokens = await sub2api_user.login(email, password)
|
||||
except (Sub2APIError, Sub2APITransportError) as exc:
|
||||
# Roll back the remote user so re-registration works.
|
||||
try:
|
||||
await sub2api_admin.delete_user(remote_user_id)
|
||||
except Exception: # pragma: no cover - best-effort cleanup
|
||||
logger.exception("failed to roll back sub2api user %s", remote_user_id)
|
||||
raise _upstream_failure("login_after_register", exc) from exc
|
||||
|
||||
# 3. Persist the local user with sub2api references.
|
||||
user = User(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
sub2api_user_id=remote_user_id,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
try:
|
||||
await db.flush()
|
||||
await store_tokens(db, user, tokens)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
try:
|
||||
await sub2api_admin.delete_user(remote_user_id)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("failed to roll back sub2api user %s", remote_user_id)
|
||||
raise
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
@@ -42,6 +112,18 @@ class AuthService:
|
||||
if user.status != "active":
|
||||
raise UnauthorizedError("Account is disabled")
|
||||
|
||||
# Refresh stored sub2api tokens with the same password. If sub2api is
|
||||
# down we still allow local login; proxied calls will surface 503 later.
|
||||
try:
|
||||
tokens = await sub2api_user.login(email, password)
|
||||
if not user.sub2api_user_id and tokens.get("user", {}).get("id"):
|
||||
user.sub2api_user_id = int(tokens["user"]["id"])
|
||||
await store_tokens(db, user, tokens)
|
||||
except Sub2APIError as exc:
|
||||
logger.warning("sub2api login failed for %s: %s", email, exc)
|
||||
except Sub2APITransportError as exc:
|
||||
logger.warning("sub2api unreachable during login: %s", exc)
|
||||
|
||||
return {
|
||||
"access_token": create_access_token(user.id),
|
||||
"refresh_token": create_refresh_token(user.id),
|
||||
@@ -65,14 +147,10 @@ class AuthService:
|
||||
|
||||
@staticmethod
|
||||
async def forgot_password(db: AsyncSession, email: str) -> str:
|
||||
"""Generate a password reset token. MVP: returns the token directly (production: send via email)."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
# Don't reveal whether email exists
|
||||
return ""
|
||||
|
||||
from app.core.security import create_reset_token
|
||||
return create_reset_token(user.id)
|
||||
|
||||
@staticmethod
|
||||
@@ -88,4 +166,16 @@ class AuthService:
|
||||
raise BadRequestError("User not found")
|
||||
|
||||
user.password_hash = hash_password(new_password)
|
||||
await db.commit()
|
||||
|
||||
# Sync the new password to sub2api so subsequent proxied calls continue
|
||||
# to work. If this fails the user can still log in locally but won't be
|
||||
# able to mutate keys until the systems re-converge.
|
||||
if user.sub2api_user_id:
|
||||
try:
|
||||
await sub2api_admin.update_user(user.sub2api_user_id, password=new_password)
|
||||
tokens = await sub2api_user.login(user.email, new_password)
|
||||
await store_tokens(db, user, tokens)
|
||||
except (Sub2APIError, Sub2APITransportError) as exc:
|
||||
logger.error("sub2api password sync failed for user %s: %s", user.id, exc)
|
||||
|
||||
await db.commit()
|
||||
Reference in New Issue
Block a user