Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 118 additions & 1 deletion backend/app/api/v1/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,27 @@
from __future__ import annotations

import logging
import time
from typing import Any
from urllib.parse import urlencode, urlsplit

from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.v1.auth import _set_auth_cookies
from app.api.v1.auth import _set_auth_cookies, get_current_user, get_optional_current_user
from app.core.config import settings
from app.core.database import get_db
from app.core.oauth import ENABLED_PROVIDERS, oauth
from app.core.request_utils import client_ip
from app.services.auth_service import (
OAuthAccountConflictError,
OAuthBindConflictError,
create_session,
get_or_create_oauth_user,
link_oauth_identity_to_user,
verify_password,
)

router = APIRouter(prefix="/auth/oauth", tags=["auth"])
Expand Down Expand Up @@ -97,6 +102,67 @@ async def oauth_login(request: Request, provider: str):
return await client.authorize_redirect(request, redirect_uri)


# ── 显式绑定流程(#64:需要 step-up 的管理员/已登录用户第三方绑定)────


BIND_INTENT_TTL_SECONDS = 600


class OAuthBindStartRequest(BaseModel):
"""发起绑定的 step-up 请求体:重新输入当前账号密码。"""

password: str


def _pop_bind_intent(request: Request, provider: str) -> dict | None:
"""取出并校验绑定意图:provider 匹配且未过期才有效。"""
try:
raw = request.session.pop("oauth_bind", None)
except Exception:
return None
if not isinstance(raw, dict):
return None
if raw.get("provider") != provider or float(raw.get("exp", 0)) < time.time():
return None
return raw


@router.post("/{provider}/bind/start")
async def oauth_bind_start(
provider: str,
payload: OAuthBindStartRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
"""发起第三方账号绑定(#64):step-up 密码验证通过后进入 provider 授权。

绑定意图写入服务端 session(与 OAuth state 同生命周期,TTL 10 分钟),
回调时据此走绑定分支而非登录分支。
"""
if not _is_provider_enabled(provider):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"OAuth provider '{provider}' not enabled")
client = oauth.create_client(provider)
if client is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"OAuth provider '{provider}' not registered")

# step-up:重新验证当前账号密码(未设密码的账号无法走此流程)
if not current_user.password_hash or not verify_password(payload.password, current_user.password_hash):
logger.warning(
"OAuth bind start rejected: step-up password mismatch user_id=%s", getattr(current_user, "id", None)
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="密码验证失败,无法发起绑定")

request.session["oauth_bind"] = {
"user_id": current_user.id,
"provider": provider,
"exp": time.time() + BIND_INTENT_TTL_SECONDS,
}
redirect_uri = _backend_callback_url(request, provider)
resp = await client.authorize_redirect(request, redirect_uri)
return {"authorize_url": resp.headers["location"]}


# ── provider 回调 ─────────────────────────────────────────────────


Expand Down Expand Up @@ -128,6 +194,19 @@ async def oauth_callback(request: Request, provider: str, db: AsyncSession = Dep
if not email or not provider_user_id:
return _frontend_redirect(error="OAuth 身份信息不完整(缺少邮箱或用户 ID)")

bind_intent = _pop_bind_intent(request, provider)
if bind_intent is not None:
return await _handle_bind_callback(
request,
db,
provider=provider,
intent=bind_intent,
provider_user_id=str(provider_user_id),
email=email,
email_verified=email_verified,
display_name=display_name,
)

try:
user = await get_or_create_oauth_user(
db,
Expand Down Expand Up @@ -158,6 +237,44 @@ async def oauth_callback(request: Request, provider: str, db: AsyncSession = Dep
return response


async def _handle_bind_callback(
request: Request,
db: AsyncSession,
*,
provider: str,
intent: dict,
provider_user_id: str,
email: str,
email_verified: bool,
display_name: str | None,
) -> RedirectResponse:
"""绑定分支回调:会话本人 + 已验证邮箱 + 无冲突 → 落 user_oauth_account。"""
current = await get_optional_current_user(request, authorization=None, db=db)
if current is None or current.id != intent.get("user_id"):
logger.warning(
"OAuth bind rejected: session mismatch provider=%s intent_user=%s", provider, intent.get("user_id")
)
return _frontend_redirect(error="绑定会话已失效或与当前登录不一致,请重新发起绑定")
if not email_verified:
return _frontend_redirect(error="该第三方身份邮箱未验证,无法绑定")
try:
account = await link_oauth_identity_to_user(
db,
user=current,
provider=provider,
provider_user_id=provider_user_id,
email=email,
email_verified=email_verified,
display_name=display_name,
)
except (OAuthBindConflictError, ValueError) as exc:
logger.warning("OAuth bind failed: provider=%s user_id=%s exc=%s", provider, current.id, exc)
return _frontend_redirect(error=str(exc))
logger.info("OAuth identity bound: provider=%s user_id=%d account_id=%d", provider, current.id, account.id)
fragment = urlencode({"provider": provider, "bind": "linked"})
return _frontend_redirect(fragment=fragment)


# ── 已启用 provider 列表(前端据此渲染按钮)──────────────────────────


Expand Down
54 changes: 51 additions & 3 deletions backend/app/services/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ async def create_user(
return user


class OAuthBindConflictError(Exception):
"""显式绑定流程冲突:同 provider 已绑定 / 身份已被其它账号绑定。"""


class OAuthAccountConflictError(Exception):
"""OAuth 登录时邮箱与现有账号冲突但无法自动合并。

Expand All @@ -124,6 +128,52 @@ async def get_oauth_account(db: AsyncSession, *, provider: str, provider_user_id
return result.scalar_one_or_none()


async def link_oauth_identity_to_user(
db: AsyncSession,
*,
user: User,
provider: str,
provider_user_id: str,
email: str,
email_verified: bool,
display_name: str | None = None,
) -> UserOAuthAccount:
"""把已验证的 OAuth 身份显式挂到指定账号(#64,管理员同样适用)。

与 get_or_create_oauth_user 的自动合并互补:调用方(API 层)必须已完成
step-up 验证并确认会话本人;本函数只负责冲突判定与落库,两种冲突一律
抛 OAuthBindConflictError(目标账号已有同 provider 绑定需先解绑、身份
已被其它账号绑定拒绝),防止置换攻击。
"""
if not email_verified:
raise ValueError("该第三方身份邮箱未验证,无法绑定")

own_stmt = select(UserOAuthAccount).where(
UserOAuthAccount.user_id == user.id,
UserOAuthAccount.provider == provider,
)
own = (await db.execute(own_stmt)).scalar_one_or_none()
if own is not None:
raise OAuthBindConflictError("该账号已绑定此登录方式,请先解绑后再重新绑定")

taken = await get_oauth_account(db, provider=provider, provider_user_id=provider_user_id)
if taken is not None:
raise OAuthBindConflictError("该第三方身份已绑定其它账号,无法重复绑定")

account = UserOAuthAccount(
user_id=user.id,
provider=provider,
provider_user_id=provider_user_id,
provider_email=email,
email_verified=True,
display_name=display_name,
)
db.add(account)
await db.commit()
await db.refresh(account)
return account


async def get_or_create_oauth_user(
db: AsyncSession,
*,
Expand Down Expand Up @@ -263,9 +313,7 @@ async def ensure_admin_user(
# promoted to admin merely by matching ADMIN_EMAIL.
if not user.password_hash:
raise ValueError("Admin seed refuses to promote a passwordless existing account")
oauth_link = await db.scalar(
select(UserOAuthAccount.id).where(UserOAuthAccount.user_id == user.id).limit(1)
)
oauth_link = await db.scalar(select(UserOAuthAccount.id).where(UserOAuthAccount.user_id == user.id).limit(1))
if oauth_link is not None:
raise ValueError("Admin seed refuses to promote an OAuth-linked existing account")
if not user:
Expand Down
Loading
Loading