oak-book/backups/oauth.md

46 KiB
Raw Blame History

下一步优化方案

1. 实现 PKCE 完整支持

1.1 什么是 PKCE

PKCEProof Key for Code ExchangeRFC 7636是 OAuth 2.0 的安全扩展,主要用于公开客户端(如移动应用、单页应用)防止授权码拦截攻击。

1.2 为什么需要 PKCE

传统授权码流程的安全隐患

sequenceDiagram
    participant App as 移动应用
    participant Browser as 系统浏览器
    participant Attacker as 攻击者
    participant AS as 授权服务器

    App->>Browser: 1. 打开授权页面
    Browser->>AS: 2. 用户授权
    AS->>Browser: 3. 重定向 + 授权码
    Browser->>Attacker: 4. 恶意应用拦截回调
    Attacker->>AS: 5. 使用授权码换取令牌
    Note over Attacker: 攻击成功!

PKCE 解决方案

sequenceDiagram
    participant App as 移动应用
    participant AS as 授权服务器
    participant Attacker as 攻击者

    Note over App: 生成 code_verifier
    App->>App: code_verifier = random(43-128)
    App->>App: code_challenge = SHA256(code_verifier)
    
    App->>AS: 1. 授权请求<br/>(code_challenge, method=S256)
    AS->>App: 2. 返回授权码
    
    alt 正常流程
        App->>AS: 3. 换取令牌<br/>(code, code_verifier)
        AS->>AS: 验证: SHA256(code_verifier) == code_challenge
        AS->>App: 4. 返回令牌
    else 攻击者拦截
        Attacker->>AS: 3. 换取令牌<br/>(code, 无code_verifier)
        AS->>Attacker: 4. 拒绝请求
        Note over Attacker: 攻击失败!
    end

1.3 实施步骤

步骤 1更新数据库结构

实体 OauthAuthorizationCode 已经预留了字段:

// src/entities/OauthAuthorizationCode.ts
export interface Schema extends EntityShape {
    // ...现有字段...
    
    // PKCE 扩展 (RFC 7636) - 已预留
    codeChallenge?: String<128>;
    codeChallengeMethod?: String<10>; // "plain" or "S256"
}

步骤 2修改授权端点

// src/aspects/oauth.ts - authorize()

export async function authorize<ED extends EntityDict>(params: {
    response_type: string;
    client_id: string;
    redirect_uri: string;
    scope?: string;
    state?: string;
    action: 'grant' | 'deny';
    
    // 新增 PKCE 参数
    code_challenge?: string;
    code_challenge_method?: 'plain' | 'S256';
}, context: BRC<ED>) {
    const { 
        response_type, 
        client_id, 
        redirect_uri, 
        scope, 
        state, 
        action,
        code_challenge,
        code_challenge_method 
    } = params;

    // ... 现有验证逻辑 ...

    if (action === 'grant') {
        const code = randomUUID();
        const codeId = await generateNewIdAsync();
        
        // 存储授权码(包含 PKCE 信息)
        await context.operate("oauthAuthorizationCode", {
            id: await generateNewIdAsync(),
            action: 'create',
            data: {
                id: codeId,
                code,
                redirectUri: redirect_uri,
                oauthAppId: oauthApp.id,
                applicationId: context.getApplicationId()!,
                userId: context.getCurrentUserId()!,
                scope: scope === undefined ? [] : [scope],
                expiresAt: Date.now() + 10 * 60 * 1000,
                
                // 存储 PKCE 参数
                codeChallenge: code_challenge,
                codeChallengeMethod: code_challenge_method || 'plain',
            }
        }, {});

        // ... 后续逻辑 ...
    }
}

步骤 3修改令牌端点验证

// src/endpoints/oauth.ts - oauthTokenEndpoint

const oauthTokenEndpoint: Endpoint<EntityDict, BackendRuntimeContext<EntityDict>> = {
    name: "获取OAuth Token",
    params: [],
    method: 'post',
    type: "free",
    fn: async (contextBuilder, params, header, req, body) => {
        const context = await contextBuilder();
        const { 
            client_id, 
            client_secret, 
            grant_type, 
            code, 
            redirect_uri,
            code_verifier  // 新增PKCE 验证器
        } = body as { 
            client_id: string, 
            client_secret: string, 
            grant_type: string, 
            code?: string, 
            redirect_uri?: string,
            code_verifier?: string 
        };

        // ... 现有验证逻辑 ...

        // 找code的记录
        const [authCodeRecord] = await context.select("oauthAuthorizationCode", {
            data: {
                id: 1,
                code: 1,
                redirectUri: 1,
                userId: 1,
                expiresAt: 1,
                usedAt: 1,
                codeChallenge: 1,
                codeChallengeMethod: 1,
            },
            filter: { code }
        }, {});

        if (!authCodeRecord) {
            await context.commit();
            return {
                statusCode: 400,
                data: { 
                    error: "invalid_grant", 
                    error_description: "Invalid authorization code", 
                    success: false 
                }
            };
        }

        // PKCE 验证
        if (authCodeRecord.codeChallenge) {
            if (!code_verifier) {
                await context.commit();
                return {
                    statusCode: 400,
                    data: { 
                        error: "invalid_request", 
                        error_description: "code_verifier is required", 
                        success: false 
                    }
                };
            }

            // 验证 code_verifier
            const isValid = await verifyPKCE(
                code_verifier, 
                authCodeRecord.codeChallenge as string,
                authCodeRecord.codeChallengeMethod as 'plain' | 'S256'
            );

            if (!isValid) {
                await context.commit();
                return {
                    statusCode: 400,
                    data: { 
                        error: "invalid_grant", 
                        error_description: "Invalid code_verifier", 
                        success: false 
                    }
                };
            }
        }

        // ... 后续令牌颁发逻辑 ...
    }
};

// PKCE 验证函数
async function verifyPKCE(
    verifier: string, 
    challenge: string, 
    method: 'plain' | 'S256'
): Promise<boolean> {
    if (method === 'plain') {
        return verifier === challenge;
    } else if (method === 'S256') {
        const crypto = require('crypto');
        const hash = crypto.createHash('sha256').update(verifier).digest();
        const computed = hash.toString('base64')
            .replace(/\+/g, '-')
            .replace(/\//g, '_')
            .replace(/=/g, '');
        return computed === challenge;
    }
    return false;
}

步骤 4更新应用配置

// src/entities/OauthApplication.ts

export interface Schema extends EntityShape {
    // ... 现有字段 ...
    
    // 新增:是否强制使用 PKCE
    requirePKCE?: Boolean;
}

export const entityDesc: EntityDesc<Schema, Action, '', {
    ableState: AbleState;
}>  = {
    locales: {
        zh_CN: {
            // ...
            attr: {
                // ...
                requirePKCE: '强制使用 PKCE',
            },
        },
    },
};

步骤 5客户端集成示例

// 移动应用或 SPA 客户端
import crypto from 'crypto';

// 1. 生成 code_verifier 和 code_challenge
function generatePKCE() {
    // 生成随机字符串 (43-128 字符)
    const verifier = crypto.randomBytes(32).toString('base64url');
    
    // 计算 challenge
    const challenge = crypto
        .createHash('sha256')
        .update(verifier)
        .digest('base64url');
    
    return { verifier, challenge };
}

// 2. 发起授权请求
const pkce = generatePKCE();
sessionStorage.setItem('code_verifier', pkce.verifier);

const authUrl = new URL('https://your-oak-system.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'your_client_id');
authUrl.searchParams.set('redirect_uri', 'your://app/callback');
authUrl.searchParams.set('code_challenge', pkce.challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', generateState());

window.location.href = authUrl.toString();

// 3. 处理回调并换取令牌
const code = getCodeFromCallback();
const verifier = sessionStorage.getItem('code_verifier');

const tokenResponse = await fetch('https://your-oak-system.com/oauth/access_token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        grant_type: 'authorization_code',
        code: code,
        client_id: 'your_client_id',
        redirect_uri: 'your://app/callback',
        code_verifier: verifier  // 提供验证器
    })
});

1.4 测试清单

  • 公开客户端使用 PKCE 成功获取令牌
  • 保密客户端可选使用 PKCE
  • code_verifier 不匹配时拒绝请求
  • 支持 plainS256 两种方法
  • requirePKCE=true 时强制验证

2. 添加更多内置提供商

2.1 提供商支持矩阵

提供商 状态 优先级 用户信息端点 特殊说明
Oak 已实现 - /oauth/userinfo 自建系统
Gitea 已实现 - /api/v1/user 开源 Git 服务
GitHub 🔲 待实现 /user 最常用
GitLab 🔲 待实现 /api/v4/user 开源替代
Google 🔲 待实现 /oauth2/v2/userinfo 通用登录
Microsoft 🔲 待实现 /v1.0/me 企业场景
微信开放平台 🔲 待实现 /sns/userinfo 移动应用
企业微信 🔲 待实现 /cgi-bin/user/getuserinfo 企业内部
钉钉 🔲 待实现 /user/getuserinfo 企业场景
飞书 🔲 待实现 /open-apis/authen/v1/user_info 企业协作
Apple 🔲 待实现 N/A (ID Token) iOS 应用

2.2 实现步骤

步骤 1创建处理器文件

// src/utils/oauth/handler/github.ts
import { UserInfoHandler } from '../index';

export const githubHandler: UserInfoHandler = (data: any) => {
    // GitHub 用户信息格式
    // {
    //   "id": 12345678,
    //   "login": "octocat",
    //   "name": "The Octocat",
    //   "email": "octocat@github.com",
    //   "avatar_url": "https://avatars.githubusercontent.com/u/12345678",
    //   "bio": "...",
    //   "created_at": "2011-01-25T18:44:36Z"
    // }
    
    return {
        id: String(data.id),
        name: data.login,
        nickname: data.name || data.login,
        gender: undefined,  // GitHub 不提供性别信息
        birth: undefined,
        avatarUrl: data.avatar_url,
    };
};
// src/utils/oauth/handler/gitlab.ts
import { UserInfoHandler } from '../index';

export const gitlabHandler: UserInfoHandler = (data: any) => {
    // GitLab 用户信息格式
    // {
    //   "id": 123,
    //   "username": "user",
    //   "name": "User Name",
    //   "email": "user@example.com",
    //   "avatar_url": "https://...",
    //   "state": "active"
    // }
    
    return {
        id: String(data.id),
        name: data.username,
        nickname: data.name || data.username,
        gender: undefined,
        birth: undefined,
        avatarUrl: data.avatar_url,
    };
};
// src/utils/oauth/handler/google.ts
import { UserInfoHandler } from '../index';

export const googleHandler: UserInfoHandler = (data: any) => {
    // Google 用户信息格式
    // {
    //   "id": "123456789",
    //   "email": "user@gmail.com",
    //   "verified_email": true,
    //   "name": "User Name",
    //   "given_name": "User",
    //   "family_name": "Name",
    //   "picture": "https://...",
    //   "locale": "zh-CN"
    // }
    
    return {
        id: data.id,
        name: data.email.split('@')[0],
        nickname: data.name,
        gender: undefined,  // 需额外权限
        birth: undefined,   // 需额外权限
        avatarUrl: data.picture,
    };
};
// src/utils/oauth/handler/weixin.ts
import { UserInfoHandler } from '../index';

export const weixinHandler: UserInfoHandler = (data: any) => {
    // 微信用户信息格式
    // {
    //   "openid": "OPENID",
    //   "nickname": "NICKNAME",
    //   "sex": 1,
    //   "province": "PROVINCE",
    //   "city": "CITY",
    //   "country": "COUNTRY",
    //   "headimgurl": "https://...",
    //   "unionid": "UNIONID"
    // }
    
    return {
        id: data.unionid || data.openid,  // 优先使用 unionid
        name: data.nickname,
        nickname: data.nickname,
        gender: data.sex === 1 ? 'male' : data.sex === 2 ? 'female' : undefined,
        birth: undefined,
        avatarUrl: data.headimgurl,
    };
};
// src/utils/oauth/handler/microsoft.ts
import { UserInfoHandler } from '../index';

export const microsoftHandler: UserInfoHandler = (data: any) => {
    // Microsoft 用户信息格式
    // {
    //   "id": "12345678-1234-1234-1234-123456789012",
    //   "userPrincipalName": "user@example.com",
    //   "displayName": "User Name",
    //   "givenName": "User",
    //   "surname": "Name",
    //   "mail": "user@example.com"
    // }
    
    return {
        id: data.id,
        name: data.userPrincipalName.split('@')[0],
        nickname: data.displayName,
        gender: undefined,
        birth: undefined,
        avatarUrl: null,  // 需要额外的 Graph API 调用
    };
};

步骤 2注册所有处理器

// src/utils/oauth/handler/index.ts
import { giteaHandler } from "./gitea";
import { oakHandler } from "./oak";
import { githubHandler } from "./github";
import { gitlabHandler } from "./gitlab";
import { googleHandler } from "./google";
import { weixinHandler } from "./weixin";
import { microsoftHandler } from "./microsoft";

export const getDefaultHandlers = () => {
    return {
        oak: oakHandler,
        gitea: giteaHandler,
        github: githubHandler,
        gitlab: gitlabHandler,
        google: googleHandler,
        weixin: weixinHandler,
        microsoft: microsoftHandler,
        // 更多提供商...
    }
}

步骤 3更新实体类型定义

// src/entities/OauthProvider.ts
export interface Schema extends EntityShape {
    // ...
    type: 
        | "oak" 
        | "gitea" 
        | "github" 
        | "gitlab" 
        | "google" 
        | "microsoft" 
        | "apple" 
        | "weixin"      // 微信开放平台
        | "wecom"       // 企业微信
        | "dingtalk"    // 钉钉
        | "feishu"      // 飞书
        | "custom";
    // ...
}

步骤 4提供配置模板

// src/config/oauthProviderTemplates.ts

export const providerTemplates = {
    github: {
        name: 'GitHub',
        type: 'github',
        authorizationEndpoint: 'https://github.com/login/oauth/authorize',
        tokenEndpoint: 'https://github.com/login/oauth/access_token',
        userInfoEndpoint: 'https://api.github.com/user',
        scopes: ['user:email'],
        logo: 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png',
    },
    gitlab: {
        name: 'GitLab',
        type: 'gitlab',
        authorizationEndpoint: 'https://gitlab.com/oauth/authorize',
        tokenEndpoint: 'https://gitlab.com/oauth/token',
        userInfoEndpoint: 'https://gitlab.com/api/v4/user',
        scopes: ['read_user'],
        logo: 'https://about.gitlab.com/images/press/logo/png/gitlab-logo-500.png',
    },
    google: {
        name: 'Google',
        type: 'google',
        authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
        tokenEndpoint: 'https://oauth2.googleapis.com/token',
        userInfoEndpoint: 'https://www.googleapis.com/oauth2/v2/userinfo',
        scopes: ['openid', 'profile', 'email'],
        logo: 'https://www.gstatic.com/images/branding/product/1x/googleg_512dp.png',
    },
    // ... 更多模板
};

3. 实现 OpenID Connect

3.1 什么是 OpenID Connect

OpenID Connect (OIDC) 是基于 OAuth 2.0 的身份认证层,添加了标准化的用户信息获取方式。

核心概念

graph LR
    OAuth[OAuth 2.0] -->|授权| Token[Access Token]
    OIDC[OpenID Connect] -->|认证| IDToken[ID Token]
    OIDC -->|继承| OAuth
    
    IDToken -->|包含| UserInfo[用户信息]
    IDToken -->|格式| JWT[JWT 格式]

与 OAuth 2.0 的区别

特性 OAuth 2.0 OpenID Connect
目的 授权Authorization 认证Authentication
获取用户信息 调用 /userinfo 端点 解析 ID TokenJWT
标准化 灵活,各家实现不同 标准化用户信息字段
使用场景 第三方应用访问资源 用户登录

3.2 实施步骤

步骤 1添加新实体

// src/entities/OauthIdToken.ts
import { String, Text, Datetime } from 'oak-domain/lib/types/DataType';
import { Schema as User } from './User';
import { Schema as OauthApplication } from './OauthApplication';
import { EntityShape } from 'oak-domain/lib/types/Entity';
import { EntityDesc } from 'oak-domain/lib/types/EntityDesc';

// OpenID Connect ID Token
export interface Schema extends EntityShape {
    // JWT 标准声明 (RFC 7519)
    iss: String<512>;     // Issuer (发行者)
    sub: String<256>;     // Subject (用户唯一标识)
    aud: String<256>;     // Audience (接收方,即 client_id)
    exp: Datetime;        // Expiration Time (过期时间)
    iat: Datetime;        // Issued At (签发时间)
    
    // OIDC 标准声明
    auth_time?: Datetime; // 认证时间
    nonce?: String<128>;  // 防重放攻击
    
    // 关联关系
    user: User;
    application: OauthApplication;
    
    // JWT 原文
    rawToken: Text;
}

export const entityDesc: EntityDesc<Schema, '', '', {}> = {
    locales: {
        zh_CN: {
            name: 'OpenID Connect ID Token',
            attr: {
                iss: '发行者',
                sub: '用户标识',
                aud: '接收方',
                exp: '过期时间',
                iat: '签发时间',
                auth_time: '认证时间',
                nonce: '随机数',
                user: '用户',
                application: '应用',
                rawToken: 'Token 原文',
            },
        },
    },
};

步骤 2修改令牌端点返回 ID Token

// src/endpoints/oauth.ts

import jwt from 'jsonwebtoken';

const oauthTokenEndpoint: Endpoint<EntityDict, BackendRuntimeContext<EntityDict>> = {
    name: "获取OAuth Token",
    params: [],
    method: 'post',
    type: "free",
    fn: async (contextBuilder, params, header, req, body) => {
        const context = await contextBuilder();
        
        // ... 现有验证逻辑 ...

        // 获取用户信息
        const [user] = await context.select("user", {
            data: {
                id: 1,
                name: 1,
                nickname: 1,
                email: 1,
                phone: 1,
            },
            filter: { id: authCodeRecord.userId }
        }, {});

        // 生成 Access Token
        const accessToken = randomUUID();
        const refreshToken = randomUUID();

        // 生成 ID Token (JWT)
        const idTokenPayload = {
            iss: context.getApplicationId(),  // 发行者
            sub: user.id,                      // 用户ID
            aud: client_id,                    // 客户端ID
            exp: Math.floor(Date.now() / 1000) + 3600,  // 1小时后过期
            iat: Math.floor(Date.now() / 1000),
            
            // 标准用户信息
            name: user.name,
            nickname: user.nickname,
            email: user.email,
            phone: user.phone,
            
            // 自定义声明
            preferred_username: user.name,
            email_verified: !!user.email,
            phone_number_verified: !!user.phone,
        };

        // 签名 ID Token
        const jwtSecret = process.env.JWT_SECRET || 'your-secret-key';
        const idToken = jwt.sign(idTokenPayload, jwtSecret, {
            algorithm: 'HS256'
        });

        // 存储 ID Token
        await context.operate("oauthIdToken", {
            id: await generateNewIdAsync(),
            action: 'create',
            data: {
                id: await generateNewIdAsync(),
                iss: idTokenPayload.iss,
                sub: idTokenPayload.sub,
                aud: idTokenPayload.aud,
                exp: idTokenPayload.exp * 1000,
                iat: idTokenPayload.iat * 1000,
                userId: user.id,
                applicationId: client_id,
                rawToken: idToken,
            }
        }, {});

        // ... 创建 Access Token 和 Refresh Token ...

        await context.commit();
        return {
            statusCode: 200,
            data: {
                access_token: accessToken,
                token_type: "Bearer",
                expires_in: 3600,
                refresh_token: refreshToken,
                refresh_expires_in: 2592000,
                
                // 新增:返回 ID Token
                id_token: idToken,
                
                success: true,
            }
        };
    }
};

步骤 3实现 OIDC Discovery 端点

// src/endpoints/oidc.ts

const oidcDiscoveryEndpoint: Endpoint<EntityDict, BackendRuntimeContext<EntityDict>> = {
    name: "OpenID Connect Discovery",
    params: [],
    method: 'get',
    type: "free",
    fn: async (contextBuilder, params, header, req, body) => {
        const context = await contextBuilder();
        const baseUrl = `${req.protocol}://${req.get('host')}`;
        
        return {
            statusCode: 200,
            data: {
                issuer: baseUrl,
                authorization_endpoint: `${baseUrl}/authorize`,
                token_endpoint: `${baseUrl}/oauth/access_token`,
                userinfo_endpoint: `${baseUrl}/oauth/userinfo`,
                jwks_uri: `${baseUrl}/.well-known/jwks.json`,
                
                // 支持的功能
                response_types_supported: ["code"],
                grant_types_supported: ["authorization_code", "refresh_token"],
                subject_types_supported: ["public"],
                id_token_signing_alg_values_supported: ["HS256", "RS256"],
                
                // 支持的声明
                claims_supported: [
                    "sub", "iss", "aud", "exp", "iat",
                    "name", "nickname", "email", "phone",
                    "preferred_username", "email_verified", "phone_number_verified"
                ],
                
                // 支持的范围
                scopes_supported: ["openid", "profile", "email", "phone"],
            }
        };
    }
};

const endpoints: Record<string, Endpoint<EntityDict, BRC<EntityDict>>> = {
    '.well-known/openid-configuration': oidcDiscoveryEndpoint,
    // ... 其他端点
};

export default endpoints;

步骤 4客户端集成示例

// 使用 OIDC 客户端库
import { Issuer, generators } from 'openid-client';

// 1. 自动发现配置
const issuer = await Issuer.discover('https://your-oak-system.com');
console.log('Discovered issuer:', issuer.metadata);

// 2. 创建客户端
const client = new issuer.Client({
    client_id: 'your_client_id',
    client_secret: 'your_client_secret',
    redirect_uris: ['https://your-app.com/callback'],
    response_types: ['code'],
});

// 3. 生成授权 URL
const nonce = generators.nonce();
const authUrl = client.authorizationUrl({
    scope: 'openid profile email',
    nonce: nonce,
});

// 4. 处理回调
const params = client.callbackParams(req);
const tokenSet = await client.callback('https://your-app.com/callback', params, {
    nonce: nonce
});

// 5. 验证并解析 ID Token
const claims = tokenSet.claims();
console.log('User ID:', claims.sub);
console.log('User Name:', claims.name);
console.log('Email:', claims.email);

3.3 OIDC 用户信息标准化

// src/types/oidc.ts

export interface OIDCStandardClaims {
    // 标准声明 (OpenID Connect Core 1.0 §5.1)
    sub: string;                    // Subject - 用户唯一标识
    name?: string;                  // 全名
    given_name?: string;            // 名
    family_name?: string;           // 姓
    middle_name?: string;           // 中间名
    nickname?: string;              // 昵称
    preferred_username?: string;    // 首选用户名
    profile?: string;               // 个人主页URL
    picture?: string;               // 头像URL
    website?: string;               // 网站
    email?: string;                 // 邮箱
    email_verified?: boolean;       // 邮箱已验证
    gender?: string;                // 性别
    birthdate?: string;             // 生日 (YYYY-MM-DD)
    zoneinfo?: string;              // 时区
    locale?: string;                // 语言环境
    phone_number?: string;          // 电话
    phone_number_verified?: boolean; // 电话已验证
    address?: {                     // 地址
        formatted?: string;
        street_address?: string;
        locality?: string;
        region?: string;
        postal_code?: string;
        country?: string;
    };
    updated_at?: number;            // 信息更新时间
}

4. 添加管理后台

4.1 管理后台架构

graph TB
    subgraph "OAuth 管理后台"
        Dashboard[控制面板]
        
        subgraph "应用管理"
            AppList[应用列表]
            AppCreate[创建应用]
            AppEdit[编辑应用]
            AppSecret[密钥管理]
        end
        
        subgraph "提供商管理"
            ProviderList[提供商列表]
            ProviderCreate[创建提供商]
            ProviderEdit[编辑提供商]
        end
        
        subgraph "授权管理"
            AuthList[授权记录]
            AuthRevoke[撤销授权]
            AuthStats[授权统计]
        end
        
        subgraph "令牌管理"
            TokenList[令牌列表]
            TokenRevoke[撤销令牌]
            TokenStats[令牌统计]
        end
        
        subgraph "审计日志"
            AuditLog[操作日志]
            SecurityLog[安全事件]
        end
        
        Dashboard --> AppList
        Dashboard --> ProviderList
        Dashboard --> AuthList
        Dashboard --> TokenList
        Dashboard --> AuditLog
    end

4.2 实现步骤

步骤 1创建控制面板组件

// src/components/oauth/dashboard/web.pc.tsx

import React from 'react';
import { Card, Row, Col, Statistic } from 'antd';
import { 
    AppstoreOutlined, 
    TeamOutlined, 
    KeyOutlined,
    CheckCircleOutlined 
} from '@ant-design/icons';

const OAuthDashboard = (props: any) => {
    const { statistics } = props.data;
    
    return (
        <div>
            <Row gutter={16}>
                <Col span={6}>
                    <Card>
                        <Statistic
                            title="OAuth 应用"
                            value={statistics.totalApps}
                            prefix={<AppstoreOutlined />}
                        />
                    </Card>
                </Col>
                <Col span={6}>
                    <Card>
                        <Statistic
                            title="授权用户"
                            value={statistics.totalUsers}
                            prefix={<TeamOutlined />}
                        />
                    </Card>
                </Col>
                <Col span={6}>
                    <Card>
                        <Statistic
                            title="活跃令牌"
                            value={statistics.activeTokens}
                            prefix={<KeyOutlined />}
                        />
                    </Card>
                </Col>
                <Col span={6}>
                    <Card>
                        <Statistic
                            title="今日授权"
                            value={statistics.todayAuths}
                            prefix={<CheckCircleOutlined />}
                        />
                    </Card>
                </Col>
            </Row>
            
            {/* 图表区域 */}
            <Row gutter={16} style={{ marginTop: 24 }}>
                <Col span={12}>
                    <Card title="授权趋势">
                        {/* 时间序列图表 */}
                    </Card>
                </Col>
                <Col span={12}>
                    <Card title="应用使用排行">
                        {/* 柱状图 */}
                    </Card>
                </Col>
            </Row>
        </div>
    );
};

步骤 2添加统计查询端点

// src/endpoints/oauthStats.ts

const oauthStatsEndpoint: Endpoint<EntityDict, BackendRuntimeContext<EntityDict>> = {
    name: "获取OAuth统计信息",
    params: [],
    method: 'get',
    type: "protected",  // 需要管理员权限
    fn: async (contextBuilder, params, header, req, body) => {
        const context = await contextBuilder();
        
        // 统计应用数量
        const totalApps = await context.count("oauthApplication", {
            filter: { ableState: 'enabled' }
        });
        
        // 统计授权用户数
        const totalUsers = await context.count("oauthUserAuthorization", {
            filter: { 
                usageState: 'granted',
                token: {
                    revokedAt: { $exists: false }
                }
            }
        });
        
        // 统计活跃令牌
        const activeTokens = await context.count("oauthToken", {
            filter: {
                accessExpiresAt: { $gt: Date.now() },
                revokedAt: { $exists: false }
            }
        });
        
        // 统计今日授权
        const todayStart = new Date();
        todayStart.setHours(0, 0, 0, 0);
        
        const todayAuths = await context.count("oauthUserAuthorization", {
            filter: {
                authorizedAt: { $gte: todayStart.getTime() }
            }
        });
        
        // 获取授权趋势最近7天
        const authTrend = await getAuthTrend(context, 7);
        
        // 获取应用使用排行
        const appRanking = await getAppRanking(context, 10);
        
        await context.commit();
        return {
            statusCode: 200,
            data: {
                totalApps,
                totalUsers,
                activeTokens,
                todayAuths,
                authTrend,
                appRanking,
            }
        };
    }
};

// 辅助函数:获取授权趋势
async function getAuthTrend(context: any, days: number) {
    const trend = [];
    for (let i = days - 1; i >= 0; i--) {
        const date = new Date();
        date.setDate(date.getDate() - i);
        date.setHours(0, 0, 0, 0);
        
        const nextDate = new Date(date);
        nextDate.setDate(nextDate.getDate() + 1);
        
        const count = await context.count("oauthUserAuthorization", {
            filter: {
                authorizedAt: {
                    $gte: date.getTime(),
                    $lt: nextDate.getTime()
                }
            }
        });
        
        trend.push({
            date: date.toISOString().split('T')[0],
            count
        });
    }
    return trend;
}

// 辅助函数:获取应用使用排行
async function getAppRanking(context: any, limit: number) {
    // 这里需要使用聚合查询,简化示例
    const apps = await context.select("oauthApplication", {
        data: {
            id: 1,
            name: 1,
            // 关联统计授权数量
        },
        filter: { ableState: 'enabled' }
    }, {});
    
    // 手动统计每个应用的授权数
    const ranking = [];
    for (const app of apps.slice(0, limit)) {
        const count = await context.count("oauthUserAuthorization", {
            filter: {
                applicationId: app.id,
                usageState: 'granted'
            }
        });
        ranking.push({ name: app.name, count });
    }
    
    ranking.sort((a, b) => b.count - a.count);
    return ranking;
}

步骤 3令牌管理界面

// src/components/oauth/tokenManagement/web.pc.tsx

import React from 'react';
import { Table, Button, Modal, Tag } from 'antd';

const TokenManagement = (props: any) => {
    const { tokens } = props.data;
    const { revokeToken } = props.methods;
    
    const columns = [
        {
            title: 'Token ID',
            dataIndex: 'id',
            key: 'id',
            render: (id: string) => id.substring(0, 8) + '...',
        },
        {
            title: '用户',
            dataIndex: ['user', 'name'],
            key: 'user',
        },
        {
            title: '应用',
            dataIndex: ['code', 'application', 'name'],
            key: 'app',
        },
        {
            title: '状态',
            key: 'status',
            render: (record: any) => {
                if (record.revokedAt) {
                    return <Tag color="red">已撤销</Tag>;
                }
                if (record.accessExpiresAt < Date.now()) {
                    return <Tag color="orange">已过期</Tag>;
                }
                return <Tag color="green">活跃</Tag>;
            },
        },
        {
            title: '过期时间',
            dataIndex: 'accessExpiresAt',
            key: 'expires',
            render: (time: number) => new Date(time).toLocaleString(),
        },
        {
            title: '最后使用',
            dataIndex: 'lastUsedAt',
            key: 'lastUsed',
            render: (time?: number) => 
                time ? new Date(time).toLocaleString() : '-',
        },
        {
            title: '操作',
            key: 'action',
            render: (record: any) => {
                if (record.revokedAt) return null;
                return (
                    <Button 
                        danger 
                        size="small"
                        onClick={() => handleRevoke(record)}
                    >
                        撤销
                    </Button>
                );
            },
        },
    ];
    
    const handleRevoke = (token: any) => {
        Modal.confirm({
            title: '确认撤销令牌?',
            content: '撤销后,使用此令牌的应用将无法访问资源',
            onOk: () => revokeToken(token.id),
        });
    };
    
    return (
        <Table 
            columns={columns} 
            dataSource={tokens} 
            rowKey="id"
        />
    );
};

步骤 4审计日志

// src/entities/OauthAuditLog.ts

import { String, Text, Datetime } from 'oak-domain/lib/types/DataType';
import { Schema as User } from './User';
import { EntityShape } from 'oak-domain/lib/types/Entity';

export interface Schema extends EntityShape {
    action: 
        | 'app_create' 
        | 'app_update' 
        | 'app_delete' 
        | 'secret_reset'
        | 'auth_grant'
        | 'auth_deny'
        | 'auth_revoke'
        | 'token_issue'
        | 'token_refresh'
        | 'token_revoke';
    
    actor?: User;           // 操作者
    targetType: String<64>; // 目标类型 (app/token/auth)
    targetId: String<64>;   // 目标ID
    details: Object;        // 详细信息
    ipAddress?: String<45>; // IP地址
    userAgent?: Text;       // User Agent
    timestamp: Datetime;    // 时间戳
}
// 在关键操作中记录日志
await context.operate("oauthAuditLog", {
    id: await generateNewIdAsync(),
    action: 'create',
    data: {
        id: await generateNewIdAsync(),
        action: 'token_issue',
        actorId: currentUserId,
        targetType: 'token',
        targetId: tokenId,
        details: {
            applicationId: app.id,
            scopes: scopes,
        },
        ipAddress: req.ip,
        userAgent: req.get('User-Agent'),
        timestamp: Date.now(),
    }
}, {});

5. 完善监控和审计

5.1 监控指标体系

graph TB
    subgraph "OAuth 监控指标"
        subgraph "业务指标"
            M1[授权成功率]
            M2[令牌颁发量]
            M3[令牌刷新量]
            M4[用户活跃度]
        end
        
        subgraph "性能指标"
            P1[授权响应时间]
            P2[令牌颁发延迟]
            P3[用户信息查询时间]
        end
        
        subgraph "安全指标"
            S1[失败授权次数]
            S2[异常令牌使用]
            S3[撤销令牌数量]
            S4[CSRF攻击尝试]
        end
        
        subgraph "资源指标"
            R1[活跃令牌数]
            R2[过期令牌清理]
            R3[数据库连接数]
        end
    end

5.2 实施步骤

步骤 1定义监控指标实体

// src/entities/OauthMetrics.ts

export interface Schema extends EntityShape {
    metricType: 
        | 'auth_success'
        | 'auth_failure'
        | 'token_issue'
        | 'token_refresh'
        | 'token_revoke'
        | 'api_call';
    
    value: Number;          // 指标值
    timestamp: Datetime;    // 时间戳
    dimensions: Object;     // 维度信息应用ID、用户ID等
}

步骤 2埋点收集

// src/utils/metrics.ts

export async function recordMetric<ED extends EntityDict>(
    context: BRC<ED>,
    type: string,
    value: number,
    dimensions: Record<string, any>
) {
    await context.operate("oauthMetrics", {
        id: await generateNewIdAsync(),
        action: 'create',
        data: {
            id: await generateNewIdAsync(),
            metricType: type,
            value,
            timestamp: Date.now(),
            dimensions,
        }
    }, {});
}

// 在关键流程中使用
await recordMetric(context, 'auth_success', 1, {
    applicationId: app.id,
    userId: user.id,
});

步骤 3实时告警规则

// src/watchers/oauthSecurityWatcher.ts

import { Watcher } from 'oak-domain/lib/types';

const securityWatcher: Watcher<EntityDict, BRC<EntityDict>> = {
    name: "OAuth 安全监控",
    cron: "*/5 * * * *",  // 每5分钟执行
    fn: async (context) => {
        const now = Date.now();
        const fiveMinutesAgo = now - 5 * 60 * 1000;
        
        // 检查失败授权次数
        const failedAuths = await context.count("oauthAuditLog", {
            filter: {
                action: 'auth_deny',
                timestamp: { $gte: fiveMinutesAgo }
            }
        });
        
        if (failedAuths > 100) {
            // 发送告警
            await sendAlert({
                level: 'warning',
                message: `检测到大量授权失败:${failedAuths}次`,
                timestamp: now,
            });
        }
        
        // 检查异常令牌使用
        const revokedTokenUse = await context.select("oauthAuditLog", {
            data: { id: 1, targetId: 1 },
            filter: {
                action: 'api_call',
                timestamp: { $gte: fiveMinutesAgo },
                'details.tokenRevoked': true,
            }
        }, {});
        
        if (revokedTokenUse.length > 0) {
            await sendAlert({
                level: 'critical',
                message: `检测到已撤销令牌被使用`,
                details: revokedTokenUse,
            });
        }
        
        // 清理过期数据
        await context.operate("oauthAuthorizationCode", {
            id: await generateNewIdAsync(),
            action: 'remove',
            data: {},
            filter: {
                expiresAt: { $lt: now - 24 * 60 * 60 * 1000 }  // 删除24小时前过期的
            }
        }, {});
    }
};

async function sendAlert(alert: any) {
    // 集成告警渠道(邮件、钉钉、企业微信等)
    console.error('[OAuth Alert]', alert);
}

步骤 4监控面板

// src/components/oauth/monitoring/web.pc.tsx

import React, { useEffect, useState } from 'react';
import { Card, Row, Col, Alert } from 'antd';
import { Line, Column } from '@ant-design/charts';

const OAuthMonitoring = () => {
    const [metrics, setMetrics] = useState<any>(null);
    const [alerts, setAlerts] = useState<any[]>([]);
    
    useEffect(() => {
        // 定时刷新数据
        const interval = setInterval(fetchMetrics, 10000);
        return () => clearInterval(interval);
    }, []);
    
    const fetchMetrics = async () => {
        const response = await fetch('/api/oauth/metrics');
        const data = await response.json();
        setMetrics(data);
    };
    
    return (
        <div>
            {/* 告警区域 */}
            {alerts.map(alert => (
                <Alert
                    key={alert.id}
                    type={alert.level}
                    message={alert.message}
                    closable
                    style={{ marginBottom: 16 }}
                />
            ))}
            
            {/* 实时指标 */}
            <Row gutter={16}>
                <Col span={12}>
                    <Card title="授权成功率">
                        <Line
                            data={metrics?.authSuccessRate || []}
                            xField="time"
                            yField="rate"
                            smooth
                        />
                    </Card>
                </Col>
                <Col span={12}>
                    <Card title="令牌颁发量">
                        <Column
                            data={metrics?.tokenIssue || []}
                            xField="time"
                            yField="count"
                        />
                    </Card>
                </Col>
            </Row>
            
            {/* 性能监控 */}
            <Row gutter={16} style={{ marginTop: 16 }}>
                <Col span={24}>
                    <Card title="响应时间分布">
                        {/* P50, P90, P99 百分位图表 */}
                    </Card>
                </Col>
            </Row>
        </div>
    );
};

步骤 5审计报告生成

// src/routines/oauthAuditReport.ts

import { Routine } from 'oak-domain/lib/types';

const generateAuditReport: Routine<EntityDict, BRC<EntityDict>> = {
    name: "生成OAuth审计报告",
    cron: "0 0 * * 0",  // 每周日0点执行
    fn: async (context) => {
        const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
        
        // 统计本周数据
        const report = {
            period: {
                start: new Date(weekAgo).toISOString(),
                end: new Date().toISOString(),
            },
            summary: {
                totalAuths: 0,
                successAuths: 0,
                deniedAuths: 0,
                revokedAuths: 0,
                newApps: 0,
                tokensIssued: 0,
                tokensRevoked: 0,
            },
            topApps: [],
            securityEvents: [],
        };
        
        // 查询授权统计
        report.summary.totalAuths = await context.count("oauthUserAuthorization", {
            filter: { authorizedAt: { $gte: weekAgo } }
        });
        
        report.summary.successAuths = await context.count("oauthUserAuthorization", {
            filter: { 
                authorizedAt: { $gte: weekAgo },
                usageState: 'granted'
            }
        });
        
        // ... 更多统计 ...
        
        // 查询安全事件
        const securityLogs = await context.select("oauthAuditLog", {
            data: {
                id: 1,
                action: 1,
                timestamp: 1,
                details: 1,
            },
            filter: {
                timestamp: { $gte: weekAgo },
                action: { $in: ['auth_deny', 'token_revoke'] }
            }
        }, {});
        
        report.securityEvents = securityLogs;
        
        // 生成报告文件
        await generateReportFile(report);
        
        // 发送邮件通知
        await sendReportEmail(report);
    }
};

async function generateReportFile(report: any) {
    // 生成 PDF 或 HTML 报告
}

async function sendReportEmail(report: any) {
    // 发送给管理员
}

5.3 监控指标说明

指标类型 指标名称 计算方式 告警阈值
业务指标 授权成功率 成功授权数 / 总授权数 < 95%
业务指标 令牌颁发量 每小时颁发数 突增 > 200%
性能指标 授权响应时间 P99 响应时间 > 3秒
性能指标 令牌颁发延迟 平均处理时间 > 500ms
安全指标 失败授权次数 5分钟内失败数 > 100次
安全指标 异常令牌使用 已撤销令牌调用 > 0
资源指标 活跃令牌数 未过期未撤销数量 > 100万
资源指标 过期令牌数 过期未清理数量 > 10万

总结

以上五个优化方向的具体实施方案涵盖了:

  1. PKCE 支持:完整的代码验证器流程,提升移动应用安全性
  2. 内置提供商7+ 常用平台的用户信息解析器和配置模板
  3. OpenID Connect:标准化的身份认证层,支持 ID Token 和自动发现
  4. 管理后台:全面的应用、令牌、授权管理界面和统计面板
  5. 监控审计:多维度指标体系、实时告警和审计报告

这些优化将使 OAuth 系统更加:

  • 🔒 安全PKCE 防护 + 实时监控
  • 🔌 易用:丰富的提供商支持 + 配置模板
  • 📊 可观测:全面的监控指标 + 审计日志
  • 🎯 标准化:符合 OIDC 标准