import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from models.database import get_db from models.models import User from .account_limits import ( UNLIMITED_ACCOUNTS, count_user_account_breakdown, normalize_max_accounts, ) from .account_quota import default_stop_worker, sync_user_account_quota from .dependencies import get_current_user, require_role_manager, require_user_manager from .email_service import ( build_password_reset_link, build_verification_link, send_password_reset_email, send_verification_email, ) from .email_verification import create_verification_token, mask_email, verify_email_token from .password_reset import create_password_reset_token, verify_password_reset_token from .jwt_utils import create_access_token from .passwords import hash_password, verify_password from .permissions import permission_catalog from .role_service import ( count_users_with_role, create_role, delete_role, ensure_role_assignable, guard_last_admin_change, list_roles as list_role_records, update_role, user_permission_payload, ) from .roles import ROLE_OPERATOR, is_admin from .scopes import ensure_user_manageable, users_for_manager from .schemas import ( LoginRequest, MessageResponse, ForgotPasswordRequest, ForgotPasswordResponse, PermissionCatalogResponse, RegisterRequest, RegisterResponse, ResendVerificationRequest, ResetPasswordRequest, RoleCreate, RoleUpdate, RolesResponse, RoleInfo, TokenResponse, UserCreate, UserResponse, UserUpdate, VerifyEmailRequest, ) from .system_settings import SystemSettingsData, load_settings logger = logging.getLogger("auth.router") router = APIRouter(prefix="/api/auth", tags=["auth"]) async def _build_user_response(db: AsyncSession, user: User, with_count: bool = False) -> UserResponse: payload = UserResponse.model_validate(user) perm = user_permission_payload(user.role) payload.role_label = perm["role_label"] payload.is_admin = perm["is_admin"] payload.permissions = perm["permissions"] if with_count: breakdown = await count_user_account_breakdown(db, user.id) payload.account_count = breakdown["total"] payload.active_account_count = breakdown["active"] payload.disabled_account_count = breakdown["disabled"] return payload async def _ensure_email_available( db: AsyncSession, email: str | None, exclude_user_id: int | None = None, ) -> str | None: value = (str(email).strip().lower() if email else "") or None if not value: return None stmt = select(User).where(User.email == value) if exclude_user_id: stmt = stmt.where(User.id != exclude_user_id) exists = await db.execute(stmt) if exists.scalar_one_or_none(): raise HTTPException(status_code=400, detail="邮箱已被其他用户使用") return value def _apply_email_verified(user: User, verified: bool | None) -> None: if verified is None: return user.email_verified = verified if verified: if not user.email_verified_at: user.email_verified_at = datetime.utcnow() else: user.email_verified_at = None def _email_not_verified_detail(user: User) -> dict: return { "code": "email_not_verified", "message": "邮箱尚未验证,请先完成邮箱验证后再登录", "email": mask_email(user.email or ""), } def _email_not_bound_detail() -> dict: return { "code": "email_not_bound", "message": "该账号未绑定邮箱,请联系管理员绑定邮箱后再登录", } def _require_email_access(user: User, settings: SystemSettingsData) -> None: if is_admin(user.role): return if settings.email_binding_required and not user.email: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=_email_not_bound_detail(), ) if not settings.email_verification_required: return if user.email_verified: return if user.email: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=_email_not_verified_detail(user), ) async def _issue_token(user: User) -> TokenResponse: token = create_access_token(str(user.id), {"role": user.role, "username": user.username}) return TokenResponse(access_token=token) async def _send_user_verification( db: AsyncSession, user: User, settings: SystemSettingsData, ) -> tuple[bool, str | None]: if not user.email: raise HTTPException(status_code=400, detail="该账号未绑定邮箱") token = await create_verification_token( db, user, expire_hours=settings.email_verify_token_hours ) await db.commit() link = build_verification_link(token, settings) if not settings.smtp_configured(): logger.warning( "SMTP 未配置,验证链接: user=%s email=%s link=%s", user.username, user.email, link, ) dev_url = link if settings.debug_show_verify_link else None return False, dev_url try: await send_verification_email(user.email, user.username, token, settings) return True, None except Exception as exc: logger.exception("Send verification email failed for user=%s", user.username) dev_url = link if settings.debug_show_verify_link else None if dev_url: return False, dev_url raise HTTPException(status_code=400, detail=f"邮件发送失败:{exc}") from exc @router.post("/login", response_model=TokenResponse) async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)): settings = await load_settings(db) result = await db.execute(select(User).where(User.username == body.username.strip())) user = result.scalar_one_or_none() if not user or not verify_password(body.password, user.password_hash): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") if not user.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用") _require_email_access(user, settings) return await _issue_token(user) @router.post("/register", response_model=RegisterResponse) async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)): settings = await load_settings(db) if not settings.registration_enabled: raise HTTPException(status_code=403, detail="当前未开放用户注册") username = body.username.strip() email = str(body.email).strip().lower() exists = await db.execute(select(User).where(User.username == username)) if exists.scalar_one_or_none(): raise HTTPException(status_code=400, detail="用户名已存在") exists = await db.execute(select(User).where(User.email == email)) if exists.scalar_one_or_none(): raise HTTPException(status_code=400, detail="邮箱已被注册") verification_required = settings.email_verification_required user = User( username=username, email=email, password_hash=hash_password(body.password), display_name=body.display_name or username, role=ROLE_OPERATOR, is_active=True, email_verified=not verification_required, email_verified_at=datetime.utcnow() if not verification_required else None, max_accounts=max(0, int(settings.default_register_max_accounts or 3)), ) db.add(user) await db.flush() if not verification_required: await db.commit() await db.refresh(user) return RegisterResponse( message="注册成功,可直接登录", email=mask_email(user.email), verification_sent=False, verification_required=False, ) verification_sent, dev_url = await _send_user_verification(db, user, settings) await db.refresh(user) message = "注册成功,验证邮件已发送,请查收并完成验证后再登录" if not verification_sent: message = ( "注册成功。验证邮件未能发出,请使用下方链接或联系管理员检查 SMTP 配置" if dev_url else "注册成功。邮件服务未配置或发送失败,请联系管理员" ) return RegisterResponse( message=message, email=mask_email(user.email), verification_sent=verification_sent, verification_required=True, dev_verify_url=dev_url, ) @router.post("/verify-email", response_model=MessageResponse) async def verify_email(body: VerifyEmailRequest, db: AsyncSession = Depends(get_db)): user = await verify_email_token(db, body.token.strip()) if not user: raise HTTPException(status_code=400, detail="验证链接无效或已过期") await db.commit() return MessageResponse(message="邮箱验证成功,现在可以登录了") @router.get("/verify-email", response_model=MessageResponse) async def verify_email_get(token: str = Query(..., min_length=8), db: AsyncSession = Depends(get_db)): user = await verify_email_token(db, token.strip()) if not user: raise HTTPException(status_code=400, detail="验证链接无效或已过期") await db.commit() return MessageResponse(message="邮箱验证成功,现在可以登录了") @router.post("/forgot-password", response_model=ForgotPasswordResponse) async def forgot_password(body: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)): settings = await load_settings(db) if not settings.smtp_configured(): raise HTTPException( status_code=400, detail="邮件服务未配置,请联系管理员重置密码", ) email = str(body.email).strip().lower() if body.email else None username = body.username.strip() if body.username else None if not email and not username: raise HTTPException(status_code=400, detail="请提供邮箱或用户名") user = None if email: result = await db.execute(select(User).where(User.email == email)) user = result.scalar_one_or_none() if not user and username: result = await db.execute(select(User).where(User.username == username)) user = result.scalar_one_or_none() generic = ForgotPasswordResponse( message="若账号存在且已绑定邮箱,重置邮件将发送到注册邮箱", email=mask_email(user.email if user and user.email else (email or "")), reset_sent=True, ) if not user or not user.email or not user.is_active: return generic token = await create_password_reset_token( db, user, expire_hours=settings.email_verify_token_hours ) await db.commit() link = build_password_reset_link(token, settings) try: await send_password_reset_email(user.email, user.username, token, settings) except Exception as exc: logger.exception("Send password reset email failed for user=%s", user.username) dev_url = link if settings.debug_show_verify_link else None if dev_url: return ForgotPasswordResponse( message="邮件发送失败,请使用下方开发重置链接", email=mask_email(user.email), reset_sent=False, dev_reset_url=dev_url, ) raise HTTPException(status_code=400, detail=f"邮件发送失败:{exc}") from exc return ForgotPasswordResponse( message="重置邮件已发送,请查收并按邮件说明设置新密码", email=mask_email(user.email), reset_sent=True, dev_reset_url=None, ) @router.post("/reset-password", response_model=MessageResponse) async def reset_password(body: ResetPasswordRequest, db: AsyncSession = Depends(get_db)): user = await verify_password_reset_token(db, body.token.strip()) if not user: raise HTTPException(status_code=400, detail="重置链接无效或已过期") if not user.is_active: raise HTTPException(status_code=400, detail="账号已禁用,请联系管理员") user.password_hash = hash_password(body.password) await db.commit() return MessageResponse(message="密码已重置,请使用新密码登录") @router.post("/resend-verification", response_model=RegisterResponse) async def resend_verification(body: ResendVerificationRequest, db: AsyncSession = Depends(get_db)): settings = await load_settings(db) if not settings.email_verification_required: raise HTTPException(status_code=400, detail="当前系统未开启邮箱验证") email = str(body.email).strip().lower() if body.email else None username = body.username.strip() if body.username else None if not email and not username: raise HTTPException(status_code=400, detail="请提供邮箱或用户名") user = None if email: result = await db.execute(select(User).where(User.email == email)) user = result.scalar_one_or_none() if not user and username: result = await db.execute(select(User).where(User.username == username)) user = result.scalar_one_or_none() if not user: return RegisterResponse( message="若账号存在且未验证,验证邮件将发送到注册邮箱", email=mask_email(email or ""), verification_sent=True, verification_required=True, ) if user.email_verified: raise HTTPException(status_code=400, detail="该账号邮箱已验证,可直接登录") verification_sent, dev_url = await _send_user_verification(db, user, settings) message = ( "验证邮件已重新发送,请查收" if verification_sent else "邮件未能发出,请使用下方验证链接或联系管理员检查 SMTP 配置" ) return RegisterResponse( message=message, email=mask_email(user.email or ""), verification_sent=verification_sent, verification_required=True, dev_verify_url=dev_url, ) @router.get("/me", response_model=UserResponse) async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): return await _build_user_response(db, user, with_count=True) @router.get("/roles", response_model=RolesResponse) async def list_auth_roles( db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): """Lightweight role list for dropdowns (any logged-in user). Intentionally omits permission arrays to avoid leaking the full ACL map. """ records = await list_role_records(db) return RolesResponse( roles=[ RoleInfo( value=item.code, label=item.label, description=None, is_system=item.is_system, is_admin=item.is_admin, permissions=[], ) for item in records ] ) users_router = APIRouter(prefix="/api/users", tags=["users"]) roles_router = APIRouter(prefix="/api/roles", tags=["roles"]) @users_router.get("", response_model=list[UserResponse]) async def list_users( db: AsyncSession = Depends(get_db), current: User = Depends(require_user_manager), ): result = await db.execute(users_for_manager(current).order_by(User.id.asc())) users = result.scalars().all() responses = [] for user in users: responses.append(await _build_user_response(db, user, with_count=True)) return responses @users_router.post("", response_model=UserResponse) async def create_user( body: UserCreate, db: AsyncSession = Depends(get_db), current: User = Depends(require_user_manager), ): settings = await load_settings(db) exists = await db.execute(select(User).where(User.username == body.username)) if exists.scalar_one_or_none(): raise HTTPException(status_code=400, detail="用户名已存在") role = await ensure_role_assignable(db, body.role) if is_admin(role) and not is_admin(current.role): raise HTTPException(status_code=403, detail="只有管理员可以分配管理员角色") email = await _ensure_email_available(db, str(body.email) if body.email else None) if settings.email_binding_required and not is_admin(role) and not email: raise HTTPException(status_code=400, detail="系统已开启「登录必须绑定邮箱」,请填写邮箱") if email: email_verified = body.email_verified else: email_verified = not settings.email_binding_required user = User( username=body.username.strip(), password_hash=hash_password(body.password), display_name=body.display_name or body.username, role=role, is_active=True, email=email, email_verified=email_verified, email_verified_at=datetime.utcnow() if email and email_verified else None, max_accounts=normalize_max_accounts(body.max_accounts, role), created_by=current.id, ) db.add(user) await db.commit() await db.refresh(user) return await _build_user_response(db, user, with_count=True) @users_router.put("/{user_id}", response_model=UserResponse) async def update_user( user_id: int, body: UserUpdate, db: AsyncSession = Depends(get_db), current: User = Depends(require_user_manager), ): result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="用户不存在") ensure_user_manageable(current, user) settings = await load_settings(db) if user.id == current.id and body.is_active is False: raise HTTPException(status_code=400, detail="不能禁用当前登录账号") updates = body.model_dump(exclude_unset=True) if body.is_active is False: await guard_last_admin_change(db, user=user, deactivating=True) if body.role is not None: new_role = await ensure_role_assignable(db, body.role) if is_admin(new_role) and not is_admin(current.role): raise HTTPException(status_code=403, detail="只有管理员可以分配管理员角色") await guard_last_admin_change(db, user=user, new_role=new_role) if body.display_name is not None: user.display_name = body.display_name if body.role is not None: prev_role = user.role user.role = await ensure_role_assignable(db, body.role) if is_admin(user.role): user.max_accounts = UNLIMITED_ACCOUNTS await sync_user_account_quota(db, user, stop_worker=default_stop_worker) elif is_admin(prev_role) and not is_admin(user.role): settings = await load_settings(db) user.max_accounts = max(0, int(settings.default_register_max_accounts or 3)) await sync_user_account_quota(db, user, stop_worker=default_stop_worker) if body.is_active is not None: user.is_active = body.is_active if body.password: user.password_hash = hash_password(body.password) if "max_accounts" in updates and not is_admin(user.role): user.max_accounts = normalize_max_accounts(updates["max_accounts"], user.role) await sync_user_account_quota(db, user, stop_worker=default_stop_worker) if "email" in updates: raw_email = updates.get("email") user.email = await _ensure_email_available( db, str(raw_email) if raw_email else None, exclude_user_id=user.id, ) if not user.email: user.email_verified = not ( settings.email_binding_required and not is_admin(user.role) ) user.email_verified_at = None if "email_verified" in updates: if not user.email: raise HTTPException(status_code=400, detail="未绑定邮箱时无法设置验证状态") _apply_email_verified(user, updates["email_verified"]) if settings.email_binding_required and not is_admin(user.role) and not user.email: raise HTTPException(status_code=400, detail="系统已开启「登录必须绑定邮箱」,该用户需绑定邮箱") await db.commit() await db.refresh(user) return await _build_user_response(db, user, with_count=True) @users_router.delete("/{user_id}") async def delete_user( user_id: int, db: AsyncSession = Depends(get_db), current: User = Depends(require_user_manager), ): if user_id == current.id: raise HTTPException(status_code=400, detail="不能删除当前登录账号") result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="用户不存在") ensure_user_manageable(current, user) await guard_last_admin_change(db, user=user, deleting=True) await db.delete(user) await db.commit() return {"message": "用户已删除"} @roles_router.get("", response_model=RolesResponse) async def admin_list_roles( db: AsyncSession = Depends(get_db), _: User = Depends(require_role_manager), ): records = await list_role_records(db) roles = [] for item in records: roles.append( RoleInfo( value=item.code, label=item.label, description=item.description or None, is_system=item.is_system, is_admin=item.is_admin, permissions=list(item.permissions), user_count=await count_users_with_role(db, item.code), ) ) return RolesResponse(roles=roles) @roles_router.get("/catalog", response_model=PermissionCatalogResponse) async def get_permission_catalog(_: User = Depends(require_role_manager)): return PermissionCatalogResponse(**permission_catalog()) @roles_router.post("", response_model=RoleInfo) async def create_custom_role( body: RoleCreate, db: AsyncSession = Depends(get_db), _: User = Depends(require_role_manager), ): record = await create_role( db, code=body.code, label=body.label, description=body.description, permissions=body.permissions, ) return RoleInfo( value=record.code, label=record.label, description=record.description or None, is_system=record.is_system, is_admin=record.is_admin, permissions=list(record.permissions), user_count=0, ) @roles_router.put("/{code}", response_model=RoleInfo) async def update_custom_role( code: str, body: RoleUpdate, db: AsyncSession = Depends(get_db), _: User = Depends(require_role_manager), ): record = await update_role( db, code, label=body.label, description=body.description, permissions=body.permissions, ) return RoleInfo( value=record.code, label=record.label, description=record.description or None, is_system=record.is_system, is_admin=record.is_admin, permissions=list(record.permissions), user_count=await count_users_with_role(db, record.code), ) @roles_router.delete("/{code}") async def delete_custom_role( code: str, db: AsyncSession = Depends(get_db), _: User = Depends(require_role_manager), ): await delete_role(db, code) return {"message": "角色已删除"}