115 lines
4.3 KiB
Python
115 lines
4.3 KiB
Python
"""身份源插件共享逻辑:绑定查找、账号 find-or-create、平台 token 签发。
|
|||
|
|
|
||
|
|
账号落点:
|
||
|
|
- 已有绑定 → 直接映射到既有 users 账号(老用户换身份源登录也是同一账号体系)
|
||
|
|
- 无绑定 → 新建 users 账号(密码为随机值,本插件身份源不走密码登录)+ 绑定记录
|
||
|
|
签发:
|
||
|
|
- chongming_auth.issue_token —— claims 契约与网关内置登录完全一致,
|
||
|
|
网关验签 / user_info 透传 / admin 判超管全部无缝生效
|
||
|
|
"""
|
||
|
|
import logging
|
||
|
|
import secrets
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
from chongming_auth import LoginPostResponse, Users, issue_token
|
||
|
|
from chongming_config import get_config
|
||
|
|
from chongming_database import Database
|
||
|
|
from chongming_password import hash_password
|
||
|
|
from sqlalchemy import select
|
||
|
|
from sqlalchemy.exc import IntegrityError
|
||
|
|
import time
|
||
|
|
|
||
|
|
from .models import IdentityBinding
|
||
|
|
|
||
|
|
logger = logging.getLogger("plugins.auth_oauth")
|
||
|
|
|
||
|
|
# 演示身份源建号使用的占位密码(随机、不可用于密码登录)
|
||
|
|
_UNUSABLE_PASSWORD_BYTES = 32
|
||
|
|
|
||
|
|
|
||
|
|
async def _lookup_user(db: Database, provider: str,
|
||
|
|
provider_uid: str) -> Users | None:
|
||
|
|
"""按绑定关系定位既有账号;无绑定或账号已消失返回 None。"""
|
||
|
|
async with db.get_session() as session:
|
||
|
|
binding = (
|
||
|
|
await session.execute(
|
||
|
|
select(IdentityBinding).where(
|
||
|
|
IdentityBinding.provider == provider, # type: ignore
|
||
|
|
IdentityBinding.provider_uid == provider_uid, # type: ignore
|
||
|
|
)
|
||
|
|
)
|
||
|
|
).scalar_one_or_none()
|
||
|
|
if binding is None:
|
||
|
|
return None
|
||
|
|
user = (
|
||
|
|
await session.execute(select(Users).where(Users.id == binding.user_id)) # type: ignore
|
||
|
|
).scalar_one_or_none()
|
||
|
|
return user
|
||
|
|
|
||
|
|
|
||
|
|
async def _create_user_with_binding(
|
||
|
|
db: Database, provider: str, provider_uid: str, profile: dict
|
||
|
|
) -> None:
|
||
|
|
"""新建 users 账号 + 绑定记录(同一事务)。
|
||
|
|
|
||
|
|
profile: {username, email, full_name?, avatar_url?}
|
||
|
|
"""
|
||
|
|
async with db.get_session() as session:
|
||
|
|
user = Users(
|
||
|
|
username=str(profile.get("username", ""))[:50],
|
||
|
|
email=str(profile["email"]),
|
||
|
|
hashed_password=hash_password(secrets.token_urlsafe(_UNUSABLE_PASSWORD_BYTES)),
|
||
|
|
full_name=profile.get("full_name"),
|
||
|
|
avatar_url=profile.get("avatar_url"),
|
||
|
|
created_at=datetime.now(),
|
||
|
|
updated_at=datetime.now(),
|
||
|
|
is_active=True,
|
||
|
|
)
|
||
|
|
session.add(user)
|
||
|
|
await session.flush() # 取 user.id 供绑定外键
|
||
|
|
session.add(IdentityBinding(
|
||
|
|
user_id=user.id, provider=provider, provider_uid=provider_uid,
|
||
|
|
)) # type: ignore
|
||
|
|
await session.commit()
|
||
|
|
|
||
|
|
|
||
|
|
async def find_or_create_user(
|
||
|
|
db: Database, provider: str, provider_uid: str, profile: dict
|
||
|
|
) -> tuple[Users | None, str]:
|
||
|
|
"""按身份源 find-or-create 账号,返回 (user, message)。
|
||
|
|
|
||
|
|
user 非 None 即成功;唯一约束冲突(并发首登 / username·email 被占)时
|
||
|
|
回退到重查绑定,仍无则返回失败原因。
|
||
|
|
"""
|
||
|
|
user = await _lookup_user(db, provider, provider_uid)
|
||
|
|
if user is not None:
|
||
|
|
return user, ""
|
||
|
|
|
||
|
|
try:
|
||
|
|
await _create_user_with_binding(db, provider, provider_uid, profile)
|
||
|
|
except IntegrityError as e:
|
||
|
|
logger.warning("[%s] 建号冲突(%s),尝试复用既有绑定: %s", provider, provider_uid, e)
|
||
|
|
|
||
|
|
user = await _lookup_user(db, provider, provider_uid)
|
||
|
|
if user is None:
|
||
|
|
return None, "创建用户失败(用户名或邮箱已被占用),请改用其他方式登录"
|
||
|
|
return user, ""
|
||
|
|
|
||
|
|
|
||
|
|
def issue_login_response(user: Users) -> LoginPostResponse:
|
||
|
|
"""按平台契约签发 token 并组装与内置登录同 shape 的响应。"""
|
||
|
|
security = get_config()["security"]
|
||
|
|
token = issue_token(
|
||
|
|
user, security["secret_key"], security["algorithm"],
|
||
|
|
)
|
||
|
|
return LoginPostResponse(
|
||
|
|
result="success", token=token, message="登录成功", timestamp=time.time()
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def fail_response(message: str) -> LoginPostResponse:
|
||
|
|
"""业务失败(不抛异常):result=fail + 原文案,保持与内置登录同 shape。"""
|
||
|
|
return LoginPostResponse(
|
||
|
|
result="fail", token="", message=message, timestamp=time.time()
|
||
|
|
)
|