🔐 认证授权

OAuth2 / JWT / Session / RBAC / ABAC / SSO / 多租户 — SaaS 产品安全基石。谁在访问?能做什么?数据怎么隔离?这些问题的答案决定了你的产品能不能让客户放心。

01 认证 vs 授权:别搞混

这是安全领域最常见的混淆。一句话区分:

🪪 认证 (Authentication)

你是谁?

验证用户身份的过程。密码、指纹、OAuth 登录都属于认证。

英文词根: Greek authentikos — "genuine, principal"

🔑 授权 (Authorization)

你能做什么?

确定已认证用户的权限范围。RBAC、ABAC、权限检查都属于授权。

英文词根: Latin auctoritas — "authority, power"

记忆口诀:认证 = 门卫查身份证;授权 = 门禁卡能开哪些门。先查身份,再定权限。

02 会话管理:Session vs JWT vs Token

认证之后,用户后续请求怎么证明"我已登录"?三种主流方案:

📋 Session-Cookie

用户登录 服务端创建 Session 返回 Session ID (Cookie) 后续请求自动携带 Cookie 服务端查 Session 存储验证
# Express + express-session (内存存储示例)
const session = require('express-session');

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,    // JS 无法读取,防 XSS
    secure: true,      // 仅 HTTPS
    sameSite: 'lax',   // 防 CSRF
    maxAge: 24 * 60 * 60 * 1000  // 24 小时
  },
  store: new RedisStore({ client: redisClient }) // 生产用 Redis
}));

app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await User.findByEmail(email);
  if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  req.session.userId = user.id;
  req.session.role = user.role;
  res.json({ message: 'Logged in' });
});

// 强制登出 — 服务端直接销毁
app.post('/logout', (req, res) => {
  req.session.destroy();
  res.clearCookie('connect.sid');
  res.json({ message: 'Logged out' });
});
Session 优劣

✅ 服务端完全控制,可随时吊销

✅ Cookie 自动携带,前端无需额外处理

✅ 不暴露用户信息在客户端

✅ 对传统 Web 应用最简单

❌ 需要服务端存储(内存/Redis/DB)

❌ 分布式部署需要共享 Session 存储

❌ 跨域/移动端不友好

❌ 水平扩展有状态瓶颈

🔑 JWT (JSON Web Token)

用户登录 服务端签发 JWT 客户端存储 (localStorage/Cookie) 请求时 Authorization: Bearer <token> 服务端验签 + 解析 claims

JWT 由三段 Base64URL 编码组成:Header.Payload.Signature

# FastAPI + PyJWT 完整实现
import jwt
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

JWT_SECRET = "your-256-bit-secret"  # 生产环境用环境变量!
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE = timedelta(minutes=15)
REFRESH_TOKEN_EXPIRE = timedelta(days=7)

security = HTTPBearer()

def create_access_token(data: dict) -> str:
    """签发短期 Access Token"""
    to_encode = data.copy()
    to_encode.update({
        "exp": datetime.now(timezone.utc) + ACCESS_TOKEN_EXPIRE,
        "iat": datetime.now(timezone.utc),
        "type": "access"
    })
    return jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM)

def create_refresh_token(data: dict) -> str:
    """签发长期 Refresh Token"""
    to_encode = data.copy()
    to_encode.update({
        "exp": datetime.now(timezone.utc) + REFRESH_TOKEN_EXPIRE,
        "iat": datetime.now(timezone.utc),
        "type": "refresh",
        "jti": str(uuid4())  # 唯一 ID,用于吊销
    })
    return jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM)

async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(security)
) -> User:
    """从 JWT 提取当前用户"""
    token = credentials.credentials
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
        if payload.get("type") != "access":
            raise HTTPException(401, "Invalid token type")
        # 检查是否在黑名单中(可选,见下方吊销方案)
        if await is_token_revoked(payload.get("jti")):
            raise HTTPException(401, "Token revoked")
        user = await User.get(payload["sub"])
        if not user:
            raise HTTPException(401, "User not found")
        return user
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, "Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(401, "Invalid token")
// 前端 Token 管理 (React 示例)
class TokenManager {
  private refreshPromise: Promise<string> | null = null;

  async getValidToken(): Promise<string> {
    const token = localStorage.getItem('access_token');
    if (!token) throw new Error('Not authenticated');

    // 检查是否即将过期(提前 30 秒刷新)
    const payload = JSON.parse(atob(token.split('.')[1]));
    if (payload.exp * 1000 - Date.now() < 30_000) {
      return this.refreshAccessToken();
    }
    return token;
  }

  async refreshAccessToken(): Promise<string> {
    // 防并发:多个请求同时发现 token 过期,只刷新一次
    if (this.refreshPromise) return this.refreshPromise;

    this.refreshPromise = (async () => {
      try {
        const refreshToken = localStorage.getItem('refresh_token');
        const res = await fetch('/api/auth/refresh', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ refresh_token: refreshToken })
        });
        if (!res.ok) {
          this.logout();
          throw new Error('Refresh failed');
        }
        const data = await res.json();
        localStorage.setItem('access_token', data.access_token);
        if (data.refresh_token) {
          localStorage.setItem('refresh_token', data.refresh_token);
        }
        return data.access_token;
      } finally {
        this.refreshPromise = null;
      }
    })();

    return this.refreshPromise;
  }

  logout() {
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
    window.location.href = '/login';
  }
}
JWT 优劣

✅ 无状态,服务端不需要存储

✅ 天然支持分布式/微服务

✅ 跨域友好,移动端/SPA 首选

✅ 可携带自定义 claims(角色、租户 ID)

❌ 签发后无法修改,吊销需要额外机制

❌ Token 体积大(Header+Payload+Sig)

❌ Payload 明文可读(不能放敏感信息)

❌ 续期策略复杂(双 Token 方案)

JWT 吊销方案

JWT 最大的争议就是"签了就不能撤"。实际生产中有几种方案:

方案原理适用场景性能影响
短期 Access + 长期 RefreshAccess Token 15 分钟过期,Refresh Token 可服务端吊销大多数 SaaS
Token 黑名单 (Redis)吊销时将 jti 写入 Redis,TTL = Token 剩余有效期需要即时吊销
Token 版本号User 表加 token_version 字段,签发时写入,验证时对比全量吊销(改密码后)
OPA/Gatekeeper策略引擎实时判断 Token 是否有效大型微服务
# Redis Token 黑名单实现 (Python)
import redis

redis_client = redis.Redis()

async def revoke_token(jti: str, exp: int) -> None:
    """将 Token 加入黑名单,TTL 设为剩余有效期"""
    now = int(datetime.now(timezone.utc).timestamp())
    ttl = max(exp - now, 0)
    if ttl > 0:
        await redis_client.setex(f"revoked:{jti}", ttl, "1")

async def is_token_revoked(jti: str | None) -> bool:
    if not jti:
        return False
    return bool(await redis_client.exists(f"revoked:{jti}"))

三种方案对比

维度Session-CookieJWT (双 Token)Opaque Token
状态有状态无状态(+ 黑名单可选)有状态
存储Redis / DB客户端Redis / DB
吊销即时(删 Session)需要额外机制即时(删记录)
跨域需额外配置天然支持需额外配置
体积小(只传 Session ID)大(1-4KB)小(UUID)
移动端不友好友好友好
微服务需共享存储天然支持需共享存储
安全敏感操作✅ 最佳⚠️ 需黑名单✅ 好
实战建议:大多数 SaaS 用 JWT 双 Token(短期 Access + 长期 Refresh)就够了。如果需要即时吊销能力,加一层轻量 Redis 黑名单。纯 Session 只适合传统服务端渲染应用。

03 OAuth2 / OIDC:第三方登录与身份联邦

OAuth2 是授权框架,OpenID Connect (OIDC) 是在 OAuth2 之上加了一层身份认证。90% 的场景你用的是 OIDC,只是习惯叫 OAuth2 登录。

OAuth2 四种授权模式

模式流程安全性适用场景
Authorization Code 重定向 → 授权码 → 换 Token 服务端应用(最常用)
Authorization Code + PKCE 加 code_verifier/challenge 防截获 最高 SPA / 移动端(当前推荐)
Client Credentials 直接用 client_id+secret 换 Token 服务间调用(M2M)
Implicit (已废弃) Token 直接返回在 URL fragment ⚠️ 已被 PKCE 取代
Resource Owner Password 直接传用户名密码换 Token ⚠️ 仅遗留系统

Authorization Code + PKCE 完整流程

① 生成 code_verifier + code_challenge ② 重定向到授权服务器 ③ 用户登录并授权 ④ 回调带 code ⑤ 用 code + verifier 换 Token
// Next.js App Router — Google OAuth2 + PKCE 登录
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

// 步骤 1: 发起 OAuth2 授权请求
export async function GET(req: NextRequest) {
  const state = crypto.randomBytes(16).toString('hex');
  const codeVerifier = crypto.randomBytes(32).toString('base64url');
  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');

  // 存入 cookie(HttpOnly),后续回调时取出
  const res = NextResponse.redirect(
    `https://accounts.google.com/o/oauth2/v2/auth?` + new URLSearchParams({
      client_id: process.env.GOOGLE_CLIENT_ID!,
      redirect_uri: `${process.env.APP_URL}/api/auth/callback`,
      response_type: 'code',
      scope: 'openid email profile',
      state,
      code_challenge: codeChallenge,
      code_challenge_method: 'S256',
    })
  );

  res.cookies.set('oauth_state', state, { httpOnly: true, maxAge: 600 });
  res.cookies.set('code_verifier', codeVerifier, { httpOnly: true, maxAge: 600 });
  return res;
}

// 步骤 2: 处理回调,换 Token
export async function POST(req: NextRequest) {
  const { code, state } = await req.json();
  const savedState = req.cookies.get('oauth_state')?.value;
  const codeVerifier = req.cookies.get('code_verifier')?.value;

  if (state !== savedState) {
    return NextResponse.json({ error: 'State mismatch' }, { status: 403 });
  }

  // 换 Token
  const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      code,
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      redirect_uri: `${process.env.APP_URL}/api/auth/callback`,
      grant_type: 'authorization_code',
      code_verifier: codeVerifier!,  // PKCE 关键
    }),
  });

  const { id_token, access_token } = await tokenRes.json();

  // 解码 ID Token (OIDC) 获取用户信息
  const claims = JSON.parse(atob(id_token.split('.')[1]));
  // { sub, email, name, picture, ... }

  // 查找或创建本地用户
  let user = await User.findByOAuth('google', claims.sub);
  if (!user) {
    user = await User.createFromOAuth({
      provider: 'google',
      providerId: claims.sub,
      email: claims.email,
      name: claims.name,
      avatar: claims.picture,
    });
  }

  // 签发自己的 JWT
  const appToken = createAccessToken({ sub: user.id, role: user.role });
  return NextResponse.json({ token: appToken, user });
}

OIDC ID Token vs Access Token

🪪 ID Token (身份证明)

JWT 格式,包含用户身份信息(sub, email, name)。

用途:客户端知道"这是谁"

验证:必须验签

消费者:客户端应用

🔑 Access Token (访问令牌)

可以是 JWT 或 Opaque,用于调用资源服务器 API。

用途:访问受保护资源

验证:资源服务器验签或内省

消费者:资源服务器(API)

主流 OAuth2 提供商接入

提供商授权端点Token 端点Scope 示例
Googleaccounts.google.com/o/oauth2/v2/authoauth2.googleapis.com/tokenopenid email profile
GitHubgithub.com/login/oauth/authorizegithub.com/login/oauth/access_tokenread:user user:email
Microsoftlogin.microsoftonline.com/{tenant}/oauth2/v2.0/authorizelogin.microsoftonline.com/{tenant}/oauth2/v2.0/tokenopenid profile User.Read
微信open.weixin.qq.com/connect/qrconnectapi.weixin.qq.com/sns/oauth2/access_tokensnsapi_login
Appleappleid.apple.com/auth/authorizeappleid.apple.com/auth/tokenemail name

04 SSO (单点登录)

企业客户的刚需:登录一次,访问所有子系统。实现 SSO 的核心协议:

SAML 2.0

企业级 SSO 的事实标准。XML-based,2005 年发布,至今仍是大型企业首选。

# Python SAML SP (Service Provider) 配置示例 (python3-saml)
# settings.json

{
  "strict": true,
  "debug": false,
  "sp": {
    "entityId": "https://your-app.com/metadata",
    "assertionConsumerService": {
      "url": "https://your-app.com/saml/acs",
      "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
    },
    "singleLogoutService": {
      "url": "https://your-app.com/saml/sls",
      "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
    },
    "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
    "x509cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
    "privateKey": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
  },
  "idp": {
    "entityId": "https://idp.example.com/metadata",
    "singleSignOnService": {
      "url": "https://idp.example.com/sso",
      "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
    },
    "singleLogoutService": {
      "url": "https://idp.example.com/slo",
      "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
    },
    "x509cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
  }
}

OIDC SSO

现代 SSO 首选协议。JSON-based,比 SAML 轻量,与 OAuth2 无缝衔接。

用户访问 App A 重定向到 IdP 用户登录 (IdP) IdP 签发 ID Token App A 获得身份 用户访问 App B → IdP 已有 Session → 直接签发

SAML vs OIDC 对比

维度SAML 2.0OIDC
数据格式XMLJSON (JWT)
协议复杂度
浏览器 POST 绑定✅ 原生支持通过 redirect
API 友好❌ 不适合✅ 天然支持
企业接受度✅ 极高📈 快速增长
单点登出✅ 原生支持⚠️ 需要前端通道
移动端❌ 不友好✅ 友好
典型客户金融/政府/大型企业科技公司/SaaS
实战建议:面向企业客户,SAML 和 OIDC 都要支持。用 Auth0 / Clerk / WorkOS 这类 Identity Platform 可以同时处理两种协议,不用自己实现。详见下文"自建 vs 买"。

05 授权模型:RBAC / ABAC / ReBAC

认证解决"你是谁",授权解决"你能做什么"。三种主流授权模型:

RBAC — 基于角色的访问控制

最经典、最广泛使用的授权模型。用户 → 角色 → 权限。

# FastAPI + SQLAlchemy RBAC 实现
from enum import Enum
from typing import Optional
from functools import wraps

class Role(str, Enum):
    SUPER_ADMIN = "super_admin"
    ADMIN = "admin"
    EDITOR = "editor"
    VIEWER = "viewer"

# 权限定义 (角色 → 权限集合)
ROLE_PERMISSIONS: dict[Role, set[str]] = {
    Role.SUPER_ADMIN: {"*"},  # 所有权限
    Role.ADMIN: {
        "users.read", "users.write", "users.delete",
        "projects.read", "projects.write", "projects.delete",
        "settings.read", "settings.write",
    },
    Role.EDITOR: {
        "projects.read", "projects.write",
        "settings.read",
    },
    Role.VIEWER: {
        "projects.read",
        "settings.read",
    },
}

def has_permission(user: User, permission: str) -> bool:
    """检查用户是否有某权限"""
    perms = ROLE_PERMISSIONS.get(user.role, set())
    return "*" in perms or permission in perms

def require_permission(permission: str):
    """装饰器:要求特定权限"""
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, current_user: User = Depends(get_current_user), **kwargs):
            if not has_permission(current_user, permission):
                raise HTTPException(
                    status_code=403,
                    detail=f"Requires permission: {permission}"
                )
            return await func(*args, current_user=current_user, **kwargs)
        return wrapper
    return decorator

# 使用
@app.get("/api/users")
@require_permission("users.read")
async def list_users(current_user: User = Depends(get_current_user)):
    return await User.all()

@app.delete("/api/users/{user_id}")
@require_permission("users.delete")
async def delete_user(user_id: str, current_user: User = Depends(get_current_user)):
    return await User.delete(user_id)
RBAC 优劣

✅ 简单直观,易于理解和实现

✅ 审计清晰:谁有什么角色

✅ 主流框架都有支持

✅ 适合 90% 的 SaaS 场景

❌ 角色爆炸:细粒度控制 → 角色组合爆炸

❌ 无法表达上下文关系("文档作者可编辑")

❌ 跨租户角色管理困难

❌ 动态权限变更需重新分配角色

ABAC — 基于属性的访问控制

根据用户属性、资源属性、环境属性、操作属性的组合做决策。更灵活,也更复杂。

# ABAC 策略引擎 (Python 示例)
from dataclasses import dataclass
from datetime import datetime

@dataclass
class AccessRequest:
    """访问请求上下文"""
    user_attrs: dict      # { role, department, clearance_level, ... }
    resource_attrs: dict  # { owner_id, classification, project_id, ... }
    action: str           # read, write, delete, share, ...
    env_attrs: dict       # { time, ip, device_type, ... }

class ABACEngine:
    def __init__(self, policies: list[dict]):
        self.policies = policies

    def evaluate(self, request: AccessRequest) -> bool:
        """评估所有策略,返回是否允许"""
        for policy in self.policies:
            if self._matches(policy.condition, request):
                return policy.effect == "allow"
        return False  # Default deny

    def _matches(self, condition: dict, request: AccessRequest) -> bool:
        """递归评估条件表达式"""
        if "and" in condition:
            return all(self._matches(c, request) for c in condition["and"])
        if "or" in condition:
            return any(self._matches(c, request) for c in condition["or"])
        if "not" in condition:
            return not self._matches(condition["not"], request)

        # 原子条件: { field, operator, value }
        field_value = self._resolve_field(condition["field"], request)
        return self._compare(field_value, condition["operator"], condition["value"])

# 策略定义示例
POLICIES = [
    {
        "name": "文档编辑权限",
        "condition": {
            "and": [
                {"field": "action", "operator": "eq", "value": "write"},
                {"or": [
                    {"field": "user.role", "operator": "eq", "value": "admin"},
                    {"field": "resource.owner_id", "operator": "eq", "field_ref": "user.id"},
                ]},
                {"field": "resource.classification", "operator": "lte",
                 "field_ref": "user.clearance_level"},
                {"field": "env.time", "operator": "in_range",
                 "value": ["09:00", "18:00"]},
            ]
        },
        "effect": "allow"
    },
    {
        "name": "下班后只读",
        "condition": {
            "and": [
                {"field": "env.time", "operator": "not_in_range",
                 "value": ["09:00", "18:00"]},
                {"field": "action", "operator": "eq", "value": "read"},
            ]
        },
        "effect": "allow"
    }
]

ReBAC — 基于关系的访问控制

Google Zanzibar 论文提出的模型。用关系元组定义权限:"用户 U 是文档 D 的 owner","owner 可以 editor","editor 可以 viewer"。

# OpenFGA (开源 Zanzibar) 关系元组定义
# schema.fga

model
  # 用户
  type user

  # 组织
  type organization
    relations
      define owner: [user]
      define admin: [user] or owner
      define member: [user] or admin
      define can_create_project: admin
      define can_manage_billing: owner

  # 项目
  type project
    relations
      define organization: [organization]
      define owner: [user] or owner from organization
      define editor: [user] or owner
      define viewer: [user] or editor
      define can_edit: editor
      define can_view: viewer
      define can_delete: owner

  # 文档
  type document
    relations
      define project: [project]
      define owner: [user] or owner from project
      define editor: [user] or owner
      define viewer: [user] or editor
      define can_read: viewer
      define can_write: editor
      define can_delete: owner
      define can_share: editor

# 使用示例 (TypeScript)
import { OpenFgaClient } from '@openfga/sdk';

const fga = new OpenFgaClient({ apiUrl: 'http://localhost:8080' });

// 写入关系元组
await fga.write({
  writes: [
    // Alice 是 organization:acme 的 owner
    { user: 'user:alice', relation: 'owner', object: 'organization:acme' },
    // Bob 是 project:backend 的 editor
    { user: 'user:bob', relation: 'editor', object: 'project:backend' },
    // project:backend 属于 organization:acme
    { user: 'organization:acme', relation: 'organization', object: 'project:backend' },
  ],
});

// 检查权限:Bob 能编辑 project:backend 吗?
const { allowed } = await fga.check({
  user: 'user:bob',
  relation: 'can_edit',
  object: 'project:backend',
});
// → allowed: true (因为 Bob 是 editor,editor 可以 can_edit)

// 检查权限:Bob 能删除 project:backend 吗?
const { allowed: canDelete } = await fga.check({
  user: 'user:bob',
  relation: 'can_delete',
  object: 'project:backend',
});
// → allowed: false (只有 owner 可以 can_delete,而且 owner 从 organization 继承)
// 但 Alice 是 organization:acme 的 owner,所以 project:backend 的 owner 从 organization 继承
// Alice 可以 can_delete

三种授权模型对比

维度RBACABACReBAC
核心概念角色 → 权限属性 → 策略关系 → 推导
灵活度⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
实现复杂度⭐⭐⭐⭐⭐⭐⭐
性能🟢 快🟡 中等🟡 需要缓存
上下文感知
层级继承⚠️ 需自实现⚠️ 需策略✅ 原生支持
典型场景后台管理合规/安全文档协作/B2B
代表产品自建OPA/CedarOpenFGA/SpiceDB
实战建议:起步用 RBAC,80% 的 SaaS 够用。当出现"只能编辑自己创建的"这种需求时,加一层简单的所有权检查(不一定是完整 ABAC)。当你做 B2B 协作平台、需要复杂的组织架构权限时,上 ReBAC (OpenFGA/SpiceDB)。

06 密码安全

密码哈希:bcrypt / argon2 / scrypt

# Python 密码处理最佳实践
from passlib.context import CryptContext

# 推荐:argon2 为主,bcrypt 兼容
pwd_context = CryptContext(
    schemes=["argon2", "bcrypt"],  # 优先 argon2,兼容 bcrypt
    deprecated="auto",              # 自动迁移旧哈希到新算法
    argon2__memory_cost=65536,     # 64 MB 内存
    argon2__time_cost=3,           # 3 轮迭代
    argon2__parallelism=4,         # 4 线程
    bcrypt__rounds=12,             # bcrypt 12 轮
)

def hash_password(password: str) -> str:
    """哈希密码 — 注册时调用"""
    return pwd_context.hash(password)

def verify_password(plain: str, hashed: str) -> bool:
    """验证密码 — 登录时调用"""
    return pwd_context.verify(plain, hashed)

def needs_rehash(hashed: str) -> bool:
    """检查是否需要重新哈希 — 登录成功后调用"""
    return pwd_context.needs_update(hashed)

# 使用示例
@app.post("/register")
async def register(email: str, password: str):
    hashed = hash_password(password)
    user = await User.create(email=email, password_hash=hashed)
    return {"id": user.id}

@app.post("/login")
async def login(email: str, password: str):
    user = await User.findByEmail(email)
    if not user or not verify_password(password, user.password_hash):
        raise HTTPException(401, "Invalid credentials")

    # 自动升级旧哈希(如从 bcrypt → argon2)
    if needs_rehash(user.password_hash):
        user.password_hash = hash_password(password)
        await user.save()

    return {"token": create_access_token({"sub": user.id})}
算法抗 GPU抗 ASIC内存消耗推荐度
bcrypt⚠️低 (~4KB)⭐⭐⭐ 仍可用
scrypt✅✅可调⭐⭐⭐ 好
argon2id✅✅✅✅可调 (推荐 64MB+)⭐⭐⭐⭐⭐ 最佳
MD5/SHA*🚫 绝对不要

密码策略

✅ 推荐的密码策略
⚠️ 过时的密码策略

07 MFA (多因素认证)

认证因素的三个维度:你知道的(密码)、你拥有的(手机/Key)、你是什么(指纹)。MFA = 至少两个维度。

TOTP (Time-based One-Time Password)

最常见的 MFA 方案,Google Authenticator / Authy 等 App 都支持。

# Python TOTP 实现
import pyotp
import qrcode
import io
import base64

class TOTPService:
    @staticmethod
    def generate_secret() -> str:
        """为用户生成 TOTP 密钥"""
        return pyotp.random_base32()

    @staticmethod
    def get_provisioning_uri(secret: str, email: str, issuer: str = "MyApp") -> str:
        """生成 otpauth:// URI"""
        totp = pyotp.TOTP(secret)
        return totp.provisioning_uri(name=email, issuer_name=issuer)

    @staticmethod
    def generate_qr_code(secret: str, email: str) -> str:
        """生成 QR Code 的 base64 图片"""
        uri = TOTPService.get_provisioning_uri(secret, email)
        qr = qrcode.QRCode(version=1, box_size=10, border=5)
        qr.add_data(uri)
        qr.make(fit=True)
        img = qr.make_image(fill_color="black", back_color="white")

        buf = io.BytesIO()
        img.save(buf, format="PNG")
        return base64.b64encode(buf.getvalue()).decode()

    @staticmethod
    def verify(secret: str, code: str, valid_window: int = 1) -> bool:
        """验证 TOTP 码 — valid_window=1 允许前后 30 秒"""
        totp = pyotp.TOTP(secret)
        return totp.verify(code, valid_window=valid_window)

# 注册 MFA 流程
@app.post("/api/mfa/setup")
async def setup_mfa(current_user: User = Depends(get_current_user)):
    secret = TOTPService.generate_secret()
    # 先存为 pending 状态,验证通过才激活
    await current_user.update(mfa_secret_pending=secret)
    qr_base64 = TOTPService.generate_qr_code(secret, current_user.email)
    return {"qr_code": qr_base64, "secret": secret}

@app.post("/api/mfa/verify-setup")
async def verify_mfa_setup(
    code: str,
    current_user: User = Depends(get_current_user)
):
    secret = current_user.mfa_secret_pending
    if not secret or not TOTPService.verify(secret, code):
        raise HTTPException(400, "Invalid code")
    # 激活 MFA
    await current_user.update(
        mfa_secret=secret,
        mfa_secret_pending=None,
        mfa_enabled=True
    )
    # 生成恢复码
    recovery_codes = [secrets.token_hex(4) for _ in range(8)]
    await current_user.update(recovery_codes=recovery_codes)
    return {"recovery_codes": recovery_codes}

# 登录时验证 MFA
@app.post("/api/auth/login-step2")
async def login_mfa(email: str, code: str):
    user = await User.findByEmail(email)
    if not user or not user.mfa_enabled:
        raise HTTPException(400, "MFA not enabled")

    # 先验证 TOTP
    if TOTPService.verify(user.mfa_secret, code):
        return {"token": create_access_token({"sub": user.id})}

    # 再验证恢复码(一次性)
    if code in user.recovery_codes:
        user.recovery_codes.remove(code)
        await user.save()
        return {"token": create_access_token({"sub": user.id})}

    raise HTTPException(401, "Invalid MFA code")

WebAuthn / Passkeys

FIDO2 标准,基于公钥加密,无需密码。苹果/Google/微软都在推 Passkeys。

// 前端 WebAuthn 注册 (浏览器 API)
async function registerPasskey(userId: string) {
  // 1. 从服务端获取注册选项
  const options = await fetch('/api/webauthn/register-options', {
    method: 'POST',
    body: JSON.stringify({ userId }),
  }).then(r => r.json());

  // 2. 浏览器弹出安全密钥/指纹对话框
  const credential = await navigator.credentials.create({
    publicKey: {
      ...options,
      challenge: Uint8Array.from(atob(options.challenge), c => c.charCodeAt(0)),
      user: {
        ...options.user,
        id: Uint8Array.from(atob(options.user.id), c => c.charCodeAt(0)),
      },
      excludeCredentials: options.excludeCredentials?.map(c => ({
        ...c,
        id: Uint8Array.from(atob(c.id), c => c.charCodeAt(0)),
      })),
    },
  });

  // 3. 将凭证发送到服务端验证存储
  await fetch('/api/webauthn/register-verify', {
    method: 'POST',
    body: JSON.stringify({
      id: credential.id,
      rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))),
      response: {
        attestationObject: btoa(String.fromCharCode(
          ...new Uint8Array(credential.response.attestationObject)
        )),
        clientDataJSON: btoa(String.fromCharCode(
          ...new Uint8Array(credential.response.clientDataJSON)
        )),
      },
      type: credential.type,
    }),
  });
}

MFA 方案对比

方案安全性易用性成本钓鱼攻击
SMS OTP⚠️ 低⭐⭐⭐⭐⭐💰 每条短信❌ 可被 SIM Swap
Email OTP⚠️ 中⭐⭐⭐⭐🟢 低❌ 可被钓鱼
TOTP App✅ 高⭐⭐⭐🟢 低❌ 可被钓鱼
WebAuthn/Passkeys✅✅ 最高⭐⭐⭐⭐🟢 低✅ 抗钓鱼
硬件 Key (YubiKey)✅✅ 最高⭐⭐💰 $25-70/个✅ 抗钓鱼

08 多租户认证与授权

链接 → 多租户架构 深入讨论数据隔离,这里聚焦认证授权层面的多租户问题。

租户识别策略

# FastAPI 多租户中间件
from starlette.middleware.base import BaseHTTPMiddleware

class TenantMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        tenant_id = self._resolve_tenant(request)
        if not tenant_id:
            return JSONResponse(
                status_code=400,
                content={"error": "Tenant not identified"}
            )

        # 注入到请求上下文
        request.state.tenant_id = tenant_id

        # 验证用户是否属于该租户
        user = getattr(request.state, 'user', None)
        if user and not self._user_in_tenant(user, tenant_id):
            return JSONResponse(
                status_code=403,
                content={"error": "Access denied to this tenant"}
            )

        response = await call_next(request)
        return response

    def _resolve_tenant(self, request) -> str | None:
        """三种租户识别方式,按优先级尝试"""
        # 1. 子域名: tenant1.myapp.com
        host = request.headers.get("host", "")
        if "." in host and not host.startswith("www."):
            subdomain = host.split(".")[0]
            if subdomain in TENANT_CACHE:
                return subdomain

        # 2. 自定义域名: app.tenant.com
        if host in DOMAIN_TENANT_MAP:
            return DOMAIN_TENANT_MAP[host]

        # 3. Header: X-Tenant-ID (API 调用)
        tenant_header = request.headers.get("X-Tenant-ID")
        if tenant_header:
            return tenant_header

        # 4. URL 路径: /t/{tenant_id}/... (最不推荐)
        # ...

        return None

    def _user_in_tenant(self, user: User, tenant_id: str) -> bool:
        return tenant_id in user.tenant_ids

租户级角色 vs 全局角色

常见坑:用户在不同租户有不同角色。Alice 在 Tenant A 是 Admin,在 Tenant B 只是 Viewer。必须用 (user_id, tenant_id) → role 映射,而不是 user_id → role
# 租户感知的 RBAC
class TenantRole:
    """用户在某租户的角色"""
    user_id: str
    tenant_id: str
    role: Role

# 数据模型
class User(Base):
    id: str
    email: str
    tenant_roles: list[TenantRole]  # 一对多

class TenantRole(Base):
    user_id: str
    tenant_id: str
    role: Role
    __table_args__ = (
        UniqueConstraint('user_id', 'tenant_id'),  # 每租户一个角色
    )

# 查询当前租户角色
def get_tenant_role(user: User, tenant_id: str) -> Role | None:
    for tr in user.tenant_roles:
        if tr.tenant_id == tenant_id:
            return tr.role
    return None

# 权限检查装饰器
def require_tenant_permission(permission: str):
    def decorator(func):
        @wraps(func)
        async def wrapper(
            *args,
            current_user: User = Depends(get_current_user),
            tenant_id: str = Depends(get_tenant_id),
            **kwargs
        ):
            role = get_tenant_role(current_user, tenant_id)
            if not role or not has_permission_by_role(role, permission):
                raise HTTPException(403, "Insufficient permissions")
            return await func(*args, current_user=current_user, tenant_id=tenant_id, **kwargs)
        return wrapper
    return decorator

09 自建 vs 买:Identity Platform 对比

认证系统是 SaaS 中最容易低估复杂度的模块。自建还是用现成服务?

维度自建ClerkAuth0Supabase AuthWorkOS
定价模式服务器成本MAU 计费MAU 计费免费额度大MAU 计费
免费额度N/A10K MAU7.5K MAU50K MAU1M MAU
社交登录🔧 自接✅ 20+✅ 30+✅ 10+✅ 20+
SAML SSO🔧 自建💰 付费💰 付费✅ 免费包含
MFA🔧 自建✅ TOTP
Passkeys🔧 自建
多租户🔧 自建✅ Organizations⚠️ 基础✅ Organizations
自定义 UI✅ 完全控制✅ 高度可定制⚠️ 有限✅ 自建 UI⚠️ 有限
数据所有权✅ 完全拥有⚠️ Clerk 持有⚠️ Auth0 持有✅ 自托管可拥有⚠️ WorkOS 持有
实现时间2-8 周1-2 天1-3 天1-2 天1-3 天

选型决策树

你的 SaaS 需要认证授权 →
  │
  ├─ MVP / 早期阶段?
  │   └─ ✅ Clerk 或 Supabase Auth(最快上线)
  │
  ├─ 需要企业 SSO (SAML)?
  │   └─ ✅ WorkOS(免费包含 SAML)或 Auth0 Enterprise
  │
  ├─ 需要完全数据控制?
  │   └─ Supabase Auth (可自托管) 或 自建
  │
  ├─ 已有 Next.js 项目?
  │   └─ ✅ Clerk(Next.js 深度集成)
  │
  └─ 特殊安全/合规要求?
      └─ ✅ 自建(完全控制审计、加密、存储)
自建认证的成本警示:表面看只是一个登录页,实际要处理:密码重置、邮箱验证、MFA、社交登录、Session 管理、Token 刷新、CSRF 防护、暴力破解防护、审计日志、SAML/OIDC、多租户、合规(GDPR/SOC2)…… 估计 200+ 小时开发量。MVP 阶段别自建。

10 安全攻击与防护

常见认证攻击

攻击类型原理防护措施
🔒 CSRF 恶意网站冒充用户发请求 SameSite Cookie + CSRF Token + Origin 检查
🔓 XSS → Token 盗取 注入脚本读取 localStorage HttpOnly Cookie + CSP + 输入过滤
🔑 暴力破解 自动化尝试密码 指数退避 + CAPTCHA + 账户锁定
📧 凭证填充 用泄露的账号密码批量尝试 HIBP 检查 + 异地登录告警 + MFA
🎣 钓鱼攻击 伪造登录页窃取凭证 WebAuthn/Passkeys(抗钓鱼)
🔄 中间人攻击 截获通信 HTTPS (HSTS) + Certificate Pinning
⏱ 时序攻击 通过响应时间推断信息 恒定时间比较函数
🎲 JWT 算法混淆 将 RS256 改为 HS256 用公钥做密钥 强制指定算法 + 库级别防护
# 关键安全防护代码

# 1. 恒定时间比较 (防时序攻击)
import hmac

def safe_compare(a: str, b: str) -> bool:
    """恒定时间字符串比较,防止时序攻击"""
    return hmac.compare_digest(a.encode(), b.encode())

# 2. 登录限流 (指数退避)
from datetime import datetime, timedelta

async def check_login_rate_limit(email: str, ip: str) -> None:
    key = f"login_attempts:{email}:{ip}"
    attempts = await redis.incr(key)

    if attempts == 1:
        await redis.expire(key, 3600)  # 1 小时窗口

    if attempts > 5:
        # 指数退避:5次后每次等 2^n 秒
        wait_seconds = min(2 ** (attempts - 5), 3600)
        last_attempt = await redis.get(f"last_attempt:{email}")
        if last_attempt:
            elapsed = datetime.now() - datetime.fromisoformat(last_attempt)
            if elapsed.total_seconds() < wait_seconds:
                raise HTTPException(
                    429,
                    f"Too many attempts. Retry after {wait_seconds}s"
                )

    await redis.set(f"last_attempt:{email}", datetime.now().isoformat())

# 3. CSP Header (防 XSS)
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware

class CSPMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        response = await call_next(request)
        response.headers["Content-Security-Policy"] = (
            "default-src 'self'; "
            "script-src 'self' 'nonce-{nonce}'; "
            "style-src 'self' 'unsafe-inline'; "
            "frame-ancestors 'none'; "
            "base-uri 'self'"
        )
        response.headers["X-Frame-Options"] = "DENY"
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
        return response

11 Agent 系统中的认证

链接回 Agent 架构研究,Agent 系统的认证有特殊挑战:

Agent 的认证问题

🤖 Agent ≠ 人类用户

Agent 代用户执行操作,需要:

OAuth2 for Agents (RFC 9728)

# Agent Token 方案
# 给 Agent 签发受限 Token,而非直接使用用户 Token

def create_agent_token(user_id: str, agent_id: str, permissions: list[str]) -> str:
    """为 Agent 签发受限 Token"""
    payload = {
        "sub": user_id,               # 代表的用户
        "agent_id": agent_id,          # 执行的 Agent
        "permissions": permissions,    # Agent 被授权的操作列表
        "type": "agent",               # 区分人类 Token 和 Agent Token
        "exp": datetime.now(timezone.utc) + timedelta(hours=1),
        "jti": str(uuid4()),           # 可吊销
    }
    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)

# Agent Token 权限检查
async def check_agent_permission(token_payload: dict, required: str) -> bool:
    if token_payload.get("type") == "agent":
        # Agent Token 只能访问明确授权的权限
        allowed = token_payload.get("permissions", [])
        return required in allowed or "*" in allowed
    else:
        # 人类 Token 走正常 RBAC
        return has_permission_by_role(token_payload["role"], required)

OpenClaw 的 Policy Pipeline

OpenClaw 的研究中,它实现了完整的 Policy Pipeline

这是 Agent 认证授权的最佳实践参考——最小权限 + 人工审批 + 完整审计。

12 实战清单

🚀 MVP 阶段 (Day 1-3)

📈 增长阶段 (1-3 月)

🏢 企业阶段 (3+ 月)

⚠️ 安全自查清单

13 推荐资源

🛠️ 工具
关联知识: 多租户架构 — 数据隔离 | API 设计 — 认证 Header 约定 | OpenClaw — Agent Policy Pipeline | Hermes — 威胁扫描