From 411ae41f0d4690a3b5590bc1128e3d80ffefff19 Mon Sep 17 00:00:00 2001 From: nap0o Date: Wed, 25 Feb 2026 10:14:42 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84oauth2=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E7=99=BB=E5=BD=95=E6=A8=A1=E5=9D=97,=E5=B7=B2?= =?UTF-8?q?=E6=94=AF=E6=8C=81Github,Cloudflare=20Access,NodeLoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 32 ++-- README.md | 73 +++++++--- backend/src/config.ts | 16 +- .../providers/{ => backup}/BackupProvider.ts | 0 .../src/providers/{ => backup}/S3Provider.ts | 0 .../providers/{ => backup}/WebDavProvider.ts | 0 backend/src/providers/backup/index.ts | 3 + .../src/providers/oauth/BaseOAuthProvider.ts | 30 ++++ .../oauth/CloudflareAccessProvider.ts | 137 ++++++++++++++++++ backend/src/providers/oauth/GitHubProvider.ts | 91 ++++++++++++ .../src/providers/oauth/NodeLocProvider.ts | 85 +++++++++++ backend/src/providers/oauth/index.ts | 55 +++++++ backend/src/routes/auth.ts | 117 +++++++-------- backend/src/routes/backups.ts | 4 +- backend/src/utils/crypto.ts | 21 +++ frontend/src/main.css | 16 +- frontend/src/views/Login.vue | 36 ++++- frontend/src/views/OAuthCallback.vue | 22 ++- 18 files changed, 609 insertions(+), 129 deletions(-) rename backend/src/providers/{ => backup}/BackupProvider.ts (100%) rename backend/src/providers/{ => backup}/S3Provider.ts (100%) rename backend/src/providers/{ => backup}/WebDavProvider.ts (100%) create mode 100644 backend/src/providers/backup/index.ts create mode 100644 backend/src/providers/oauth/BaseOAuthProvider.ts create mode 100644 backend/src/providers/oauth/CloudflareAccessProvider.ts create mode 100644 backend/src/providers/oauth/GitHubProvider.ts create mode 100644 backend/src/providers/oauth/NodeLocProvider.ts create mode 100644 backend/src/providers/oauth/index.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3afa304..498e32d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -63,18 +63,30 @@ jobs: workingDirectory: 'backend' command: deploy --minify secrets: | - OAUTH_CLIENT_ID - OAUTH_CLIENT_SECRET - OAUTH_BASE_URL - OAUTH_ID - OAUTH_REDIRECT_URI + OAUTH_ALLOWED_USERS + OAUTH_GITHUB_CLIENT_ID + OAUTH_GITHUB_CLIENT_SECRET + OAUTH_GITHUB_REDIRECT_URI + OAUTH_CLOUDFLARE_CLIENT_ID + OAUTH_CLOUDFLARE_CLIENT_SECRET + OAUTH_CLOUDFLARE_ORG_DOMAIN + OAUTH_CLOUDFLARE_REDIRECT_URI + OAUTH_NODELOC_CLIENT_ID + OAUTH_NODELOC_CLIENT_SECRET + OAUTH_NODELOC_REDIRECT_URI ENCRYPTION_KEY JWT_SECRET env: - OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }} - OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} - OAUTH_BASE_URL: ${{ secrets.OAUTH_BASE_URL || 'https://github.com' }} - OAUTH_ID: ${{ secrets.OAUTH_ID }} - OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }} + OAUTH_ALLOWED_USERS: ${{ secrets.OAUTH_ALLOWED_USERS }} + OAUTH_GITHUB_CLIENT_ID: ${{ secrets.OAUTH_GITHUB_CLIENT_ID }} + OAUTH_GITHUB_CLIENT_SECRET: ${{ secrets.OAUTH_GITHUB_CLIENT_SECRET }} + OAUTH_GITHUB_REDIRECT_URI: ${{ secrets.OAUTH_GITHUB_REDIRECT_URI }} + OAUTH_CLOUDFLARE_CLIENT_ID: ${{ secrets.OAUTH_CLOUDFLARE_CLIENT_ID }} + OAUTH_CLOUDFLARE_CLIENT_SECRET: ${{ secrets.OAUTH_CLOUDFLARE_CLIENT_SECRET }} + OAUTH_CLOUDFLARE_ORG_DOMAIN: ${{ secrets.OAUTH_CLOUDFLARE_ORG_DOMAIN }} + OAUTH_CLOUDFLARE_REDIRECT_URI: ${{ secrets.OAUTH_CLOUDFLARE_REDIRECT_URI }} + OAUTH_NODELOC_CLIENT_ID: ${{ secrets.OAUTH_NODELOC_CLIENT_ID }} + OAUTH_NODELOC_CLIENT_SECRET: ${{ secrets.OAUTH_NODELOC_CLIENT_SECRET }} + OAUTH_NODELOC_REDIRECT_URI: ${{ secrets.OAUTH_NODELOC_REDIRECT_URI }} ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} JWT_SECRET: ${{ secrets.JWT_SECRET }} \ No newline at end of file diff --git a/README.md b/README.md index 45f97ca..8dd6ad8 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,19 @@ **2FAuth Worker** 是一个基于 Cloudflare Workers + D1 (SQLite) + Vue 3 构建的轻量级、安全、无服务器的二步验证(2FA/TOTP)管理工具。 -它允许你完全掌控自己的 2FA 数据,支持多端同步、WebDAV 备份、加密导出,并且完全免费部署在 Cloudflare 的边缘网络上。 - -> **免责声明** -> 本项目代码 `100%` 由 `Gemini 3 Pro` 生成,作者不对任何数据丢失负责。 +它允许你完全掌控自己的 2FA 数据,支持多端同步、WebDAV 备份、S3存储桶、加密导入导出,并且完全免费部署在 Cloudflare 的边缘网络上。 ## ✨ 功能特性 - ☁️ **Serverless 架构**:后端运行在 Cloudflare Workers,数据库使用 D1,无需购买服务器。 +- ⚡️ **极速部署**: 利用 Github Actions 自动化部署,数分钟即可完成。 +- 🔐 **OAuth2登录**:集成 GitHub 和 Cloudflare Access 安全登录。 - 🔒 **安全隐私**:数据存储在你的私有 D1 数据库中,支持 AES-GCM 高强度加密导出。 -- 📱 **多端适配**:响应式设计,完美支持 PC 和移动端访问。 +- 🖥️ **多端适配**:响应式设计,完美支持 PC 和移动端访问。 - 📷 **扫码添加**:支持调用摄像头扫描二维码或上传图片识别添加账号。 -- 🔄 **数据同步**:支持 WebDAV 自动备份,数据永不丢失。 -- 📂 **导入导出**:支持从 Google Authenticator (导出文本), 2FAS, 或标准 JSON 格式导入数据。 -- 🔐 **GitHub 登录**:集成 GitHub OAuth 安全登录。 +- 🔄 **数据同步**:支持 WebDAV,S3对象存储自动备份,数据永不丢失。 +- 📂 **导入导出**:支持导出导入 加密JSON 格式、2FAS、标准 JSON 格式、otpauth格式数据。 +- ⚙ **实用工具**:原生内置TOTP 密钥工具、时间校准器、二维码解析器等多款实用小工具。 ## 🛠️ 技术栈 @@ -41,6 +40,8 @@ 4. 创建完成后,在数据库详情页找到 **Database ID** 并复制。 5. 请妥善保存这个 ID,稍后我们需要将其配置到 GitHub Secrets (`CLOUDFLARE_D1_DATABASE_ID`) 中。 + **以下GitHub OAuth 和 Cloudflare Access 至少配置一项用于登录** + ### 3. 配置 GitHub OAuth 1. 访问 GitHub Developer Settings -> **OAuth Apps** -> **New OAuth App**。 @@ -50,25 +51,45 @@ - **Authorization callback URL**: `https://你的Worker子域名.workers.dev/oauth/callback` (部署后可修改为Worker绑定的自定义域名) 3. 创建后,保存 `Client ID` 并生成一个新的 `Client Secret`。 -### 4. 配置 GitHub Secrets +### 4. 配置 Cloudflare Access + +要启用 Cloudflare Access 登录,你需要: + +1. 进入 **Cloudflare Zero Trust Dashboard** -> **Access** -> **Applications**。 +2. 创建一个 **SaaS** 应用 (Select "SaaS")。 +3. 配置 **Application**: + - **应用程序**: `输入任意名称都行,如 2fauth` + - **选择身份验证协议**: `OIDC` + - **重定向 URL**: ``https://你的Worker子域名.workers.dev/oauth/callback` (部署后可修改为Worker绑定的自定义域名)` +4. 复制保存关键信息备用, `客户端 ID` 和 `客户端密码`。 +5. 配置 Access 策略 +6. 其余均不需要额外配置,一直下一步到完成 + +### 5. 配置 GitHub Secrets 在你的 GitHub 仓库中,进入 **Settings** -> **Secrets and variables** -> **Actions** -> **New repository secret**,添加以下变量: | Secret Name | 说明 | 示例值 | +| `JWT_SECRET` | 用于签发登录 Token 的密钥 (随机字符串) | `random_string_here` | | :--- | :--- | :--- | +| `ENCRYPTION_KEY` | 用于加密敏感数据的密钥 (32位随机字符串) | `openssl rand -hex 16` 生成 | +| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare 账户 ID (在 Dash 面板右下角) | `a1b2...` | | `CLOUDFLARE_API_TOKEN` | Cloudflare API 令牌 (需有 Workers 和 D1 读写权限) | `r4_...` | | `CLOUDFLARE_D1_DATABASE_ID` | 你的 D1 数据库 ID (步骤 2 中生成的) | `xxxxxxxx-xxxx...` | | `CLOUDFLARE_D1_DATABASE_NAME` | 你的 D1 数据库 名称 (步骤 2 中生成的) | 默认`2fauth-db` | -| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare 账户 ID (在 Dash 面板右下角) | `a1b2...` | -| `OAUTH_CLIENT_ID` | GitHub OAuth Client ID | `Ov23...` | -| `OAUTH_CLIENT_SECRET` | GitHub OAuth Client Secret | `a1b2...` | -| `OAUTH_BASE_URL` | GitHub API 基础地址 | 默认`https://github.com` | -| `OAUTH_ID` | 允许登录的 GitHub 用户名 (白名单机制) | `your_github_username` | -| `OAUTH_REDIRECT_URI` | 你的 Worker 回调地址 | `https://xxx.workers.dev/oauth/callback` | -| `ENCRYPTION_KEY` | 用于加密敏感数据的密钥 (32位随机字符串) | `openssl rand -hex 16` 生成 | -| `JWT_SECRET` | 用于签发登录 Token 的密钥 (随机字符串) | `random_string_here` | +| `OAUTH_ALLOWED_USERS` | 允许登录的邮箱或用户名白名单 (逗号分隔) | `me@example.com,my_github_id` | +| `OAUTH_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | `Ov23...` | +| `OAUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | `a1b2...` | +| `OAUTH_GITHUB_REDIRECT_URI` | 你的 Worker 回调地址 | `https://xxx.workers.dev/oauth/callback` | +| `OAUTH_CLOUDFLARE_CLIENT_ID` | Cloudflare Access 客户端 ID | `...` | +| `OAUTH_CLOUDFLARE_CLIENT_SECRET` | Cloudflare Access 客户端密码 | `.. +| `OAUTH_CLOUDFLARE_ORG_DOMAIN` | Access 组织域名 | `https://.cloudflareaccess.com` | +| `OAUTH_CLOUDFLARE_REDIRECT_URI` | 你的 Worker 回调地址 | `https://xxx.workers.dev/oauth/callback` | +| `OAUTH_NODELOC_CLIENT_ID` | NodeLoc OAuth Client ID | `Ov23...` | +| `OAUTH_NODELOC_CLIENT_SECRET` | NodeLoc OAuth Client Secret | `a1b2...` | +| `OAUTH_NODELOC_REDIRECT_URI` | 你的 Worker 回调地址 | `https://xxx.workers.dev/oauth/callback` | -### 5. 自动部署 +### 6. 自动部署 1. 进入 GitHub 仓库的 **Actions** 页面。 2. 手动触发 `Deploy to Cloudflare Workers` 工作流,或者直接 Push 代码到 `main` 分支。 @@ -105,13 +126,17 @@ npm install ### 3. 配置本地环境变量 在 `backend` 目录下创建 `.dev.vars` 文件,填入开发所需的 Secrets: ```ini -OAUTH_CLIENT_ID="你的测试AppID" -OAUTH_CLIENT_SECRET="你的测试AppSecret" -OAUTH_REDIRECT_URI="http://localhost:5173/oauth/callback" -OAUTH_BASE_URL="https://github.com" -OAUTH_ID="你的GitHub用户名" -ENCRYPTION_KEY="本地测试用的32位密钥" JWT_SECRET="本地测试用的JWT密钥" +ENCRYPTION_KEY="本地测试用的32位密钥" +OAUTH_ALLOWED_USERS="你的邮箱@example.com,你的GitHub用户名" +OAUTH_GITHUB_CLIENT_ID="Github测试AppID" +OAUTH_GITHUB_CLIENT_SECRET="Github测试AppSecret" +OAUTH_GITHUB_REDIRECT_URI="http://localhost:5173/oauth/callback" +OAUTH_CLOUDFLARE_CLIENT_ID="Cloudflare Access 测试客户端ID" +OAUTH_CLOUDFLARE_CLIENT_SECRET="Cloudflare Access 测试客户端密码" +OAUTH_CLOUDFLARE_REDIRECT_URI=http://localhost:8787/oauth/callback +OAUTH_CLOUDFLARE_ORG_DOMAIN=https://<你的组织名>.cloudflareaccess.com + ``` ### 4. 初始化本地数据库 diff --git a/backend/src/config.ts b/backend/src/config.ts index 5ee2fbb..4a7f083 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -14,11 +14,17 @@ export const SECURITY_CONFIG = { // Cloudflare Workers 环境变量类型定义 export type EnvBindings = { DB: D1Database; - OAUTH_BASE_URL: string; - OAUTH_CLIENT_ID: string; - OAUTH_CLIENT_SECRET: string; - OAUTH_REDIRECT_URI: string; - OAUTH_ID: string; + OAUTH_GITHUB_CLIENT_ID: string; + OAUTH_GITHUB_CLIENT_SECRET: string; + OAUTH_GITHUB_REDIRECT_URI: string; + OAUTH_CLOUDFLARE_CLIENT_ID?: string; + OAUTH_CLOUDFLARE_CLIENT_SECRET?: string; + OAUTH_CLOUDFLARE_ORG_DOMAIN?: string; // 例如 https://your-team.cloudflareaccess.com + OAUTH_CLOUDFLARE_REDIRECT_URI?: string; + OAUTH_NODELOC_CLIENT_ID?: string; + OAUTH_NODELOC_CLIENT_SECRET?: string; + OAUTH_NODELOC_REDIRECT_URI?: string; + OAUTH_ALLOWED_USERS: string; // 允许登录的 Email 或 Username 白名单 (必填) JWT_SECRET: string; ENCRYPTION_KEY: string; }; diff --git a/backend/src/providers/BackupProvider.ts b/backend/src/providers/backup/BackupProvider.ts similarity index 100% rename from backend/src/providers/BackupProvider.ts rename to backend/src/providers/backup/BackupProvider.ts diff --git a/backend/src/providers/S3Provider.ts b/backend/src/providers/backup/S3Provider.ts similarity index 100% rename from backend/src/providers/S3Provider.ts rename to backend/src/providers/backup/S3Provider.ts diff --git a/backend/src/providers/WebDavProvider.ts b/backend/src/providers/backup/WebDavProvider.ts similarity index 100% rename from backend/src/providers/WebDavProvider.ts rename to backend/src/providers/backup/WebDavProvider.ts diff --git a/backend/src/providers/backup/index.ts b/backend/src/providers/backup/index.ts new file mode 100644 index 0000000..bd93d83 --- /dev/null +++ b/backend/src/providers/backup/index.ts @@ -0,0 +1,3 @@ +export * from './BackupProvider'; +export * from './WebDavProvider'; +export * from './S3Provider'; \ No newline at end of file diff --git a/backend/src/providers/oauth/BaseOAuthProvider.ts b/backend/src/providers/oauth/BaseOAuthProvider.ts new file mode 100644 index 0000000..324e803 --- /dev/null +++ b/backend/src/providers/oauth/BaseOAuthProvider.ts @@ -0,0 +1,30 @@ +import { EnvBindings } from '../../config'; + +export interface OAuthUserInfo { + id: string; + email: string; + username: string; + avatar?: string; + provider: string; // 记录来源,如 'github' +} + +export abstract class BaseOAuthProvider { + protected env: EnvBindings; + + constructor(env: EnvBindings) { + this.env = env; + } + + abstract readonly id: string; + abstract readonly name: string; + abstract readonly color: string; // 按钮背景色 + abstract readonly icon: string; // 前端图标 SVG 代码 (完整 标签) + abstract readonly whitelistFields: string[]; // 新增:定义该 Provider 支持哪些白名单字段 (如 ['email', 'username']) + + // 1. 获取授权跳转链接 + // 返回对象包含 url 和可选的 codeVerifier (用于 PKCE) + abstract getAuthorizeUrl(state: string): Promise<{ url: string, codeVerifier?: string }> | { url: string, codeVerifier?: string }; + + // 2. 处理回调:Code -> Token -> UserInfo + abstract handleCallback(code: string, codeVerifier?: string): Promise; +} \ No newline at end of file diff --git a/backend/src/providers/oauth/CloudflareAccessProvider.ts b/backend/src/providers/oauth/CloudflareAccessProvider.ts new file mode 100644 index 0000000..0cc7bb3 --- /dev/null +++ b/backend/src/providers/oauth/CloudflareAccessProvider.ts @@ -0,0 +1,137 @@ +import { BaseOAuthProvider, OAuthUserInfo } from './BaseOAuthProvider'; +import { EnvBindings, AppError } from '../../config'; + +// Move to crypto.ts +// PKCE 辅助函数 +function base64UrlEncode(str: Uint8Array): string { + let binary = ''; + const len = str.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(str[i]); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} +// Move to crypto.ts +async function generatePKCE() { + const verifierBytes = new Uint8Array(32); + crypto.getRandomValues(verifierBytes); + const verifier = base64UrlEncode(verifierBytes); + + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest('SHA-256', data); + const challenge = base64UrlEncode(new Uint8Array(hash)); + + return { verifier, challenge }; +} + + + +export class CloudflareAccessProvider extends BaseOAuthProvider { + readonly id = 'cloudflare'; + readonly name = 'Cloudflare Access'; + readonly color = '#F38020'; + readonly icon = ''; + readonly whitelistFields = ['email']; // Cloudflare Access 主要基于邮箱验证 + + constructor(env: EnvBindings) { + super(env); + } + + // 提取公共方法处理 URL 标准化 + private getBaseUrl(): string { + const orgDomain = this.env.OAUTH_CLOUDFLARE_ORG_DOMAIN; + if (!orgDomain) { + throw new AppError('Cloudflare Access configuration incomplete: Missing OAUTH_CLOUDFLARE_ORG_DOMAIN', 500); + } + + let baseUrl = orgDomain.replace(/\/$/, ''); + if (!baseUrl.startsWith('http')) { + baseUrl = `https://${baseUrl}`; + } + return baseUrl; + } + + async getAuthorizeUrl(state: string) { + const clientId = this.env.OAUTH_CLOUDFLARE_CLIENT_ID; + const redirectUri = this.env.OAUTH_CLOUDFLARE_REDIRECT_URI; + + if (!clientId || !redirectUri) { + throw new AppError('Cloudflare Access configuration incomplete', 500); + } + + const baseUrl = this.getBaseUrl(); + const { verifier, challenge } = await generatePKCE(); + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid profile email', + state: state, + code_challenge: challenge, + code_challenge_method: 'S256' + }); + + const url = `${baseUrl}/cdn-cgi/access/sso/oidc/${clientId}/authorization?${params.toString()}`; + return { url, codeVerifier: verifier }; + } + + async handleCallback(code: string, codeVerifier?: string): Promise { + const clientId = this.env.OAUTH_CLOUDFLARE_CLIENT_ID; + const clientSecret = this.env.OAUTH_CLOUDFLARE_CLIENT_SECRET; + const redirectUri = this.env.OAUTH_CLOUDFLARE_REDIRECT_URI; + + if (!clientId || !clientSecret || !redirectUri) { + throw new AppError('Cloudflare Access configuration incomplete', 500); + } + + const baseUrl = this.getBaseUrl(); + + // 1. Exchange code for token + const tokenResponse = await fetch(`${baseUrl}/cdn-cgi/access/sso/oidc/${clientId}/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code: code, + code_verifier: codeVerifier || '', // PKCE 必需参数 + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }) + }); + + if (!tokenResponse.ok) { + // 增加更详细的错误日志,方便调试 + const errorText = await tokenResponse.text(); + console.error(`Cloudflare Access Token Error: ${tokenResponse.status} - ${errorText}`); + throw new AppError(`Cloudflare Access Token Exchange failed: ${tokenResponse.status}`, 502); + } + + const tokenData: any = await tokenResponse.json(); + const accessToken = tokenData.access_token; + + // 2. Get User Info (OIDC Standard) + // Cloudflare Access 的 ID Token 其实已经包含了 email,但为了保险我们请求 userinfo + const userResponse = await fetch(`${baseUrl}/cdn-cgi/access/sso/oidc/${clientId}/userinfo`, { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + if (!userResponse.ok) throw new AppError(`Cloudflare Access API error: ${userResponse.status}`, 502); + const userData: any = await userResponse.json(); + + return { + id: userData.sub, // Cloudflare 用户唯一 ID + username: userData.preferred_username || userData.email.split('@')[0], + email: userData.email, + avatar: '', // Cloudflare Access 通常不提供头像 + provider: this.id + }; + } +} \ No newline at end of file diff --git a/backend/src/providers/oauth/GitHubProvider.ts b/backend/src/providers/oauth/GitHubProvider.ts new file mode 100644 index 0000000..0cc74a5 --- /dev/null +++ b/backend/src/providers/oauth/GitHubProvider.ts @@ -0,0 +1,91 @@ +import { BaseOAuthProvider, OAuthUserInfo } from './BaseOAuthProvider'; +import { EnvBindings, AppError } from '../../config'; + +export class GitHubProvider extends BaseOAuthProvider { + readonly id = 'github'; + readonly name = 'GitHub'; + readonly color = '#24292e'; + readonly icon = ''; + readonly whitelistFields = ['username', 'email']; // 明确规定:GitHub 支持用户名和邮箱校验 + + constructor(env: EnvBindings) { + super(env); + } + + getAuthorizeUrl(state: string) { + const clientId = this.env.OAUTH_GITHUB_CLIENT_ID; + const redirectUri = this.env.OAUTH_GITHUB_REDIRECT_URI; + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + state: state, + scope: 'read:user user:email' // 显式请求 Email 权限 + }); + + return { url: `https://github.com/login/oauth/authorize?${params.toString()}` }; + } + + async handleCallback(code: string, _codeVerifier?: string): Promise { + // 1. 使用 Code 换取 Access Token + const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': '2FA-Manager-Backend/1.0' + }, + body: new URLSearchParams({ + client_id: this.env.OAUTH_GITHUB_CLIENT_ID, + client_secret: this.env.OAUTH_GITHUB_CLIENT_SECRET, + code: code, + redirect_uri: this.env.OAUTH_GITHUB_REDIRECT_URI, + }) + }); + + if (!tokenResponse.ok) { + throw new AppError(`GitHub Token Exchange failed: ${tokenResponse.status}`, 502); + } + + const tokenData: any = await tokenResponse.json(); + + if (tokenData.error) { + throw new AppError(`GitHub OAuth Error: ${tokenData.error_description || tokenData.error}`, 400); + } + + const accessToken = tokenData.access_token; + + // 2. 获取用户信息 + const userResponse = await fetch('https://api.github.com/user', { + headers: { + 'Authorization': `token ${accessToken}`, + 'Accept': 'application/json', + 'User-Agent': '2FA-Manager-Backend/1.0' + } + }); + + if (!userResponse.ok) throw new AppError(`GitHub API error: ${userResponse.status}`, 502); + const userData: any = await userResponse.json(); + + // 3. 补充获取 Email (如果公开资料中没有) + let email = userData.email; + if (!email) { + try { + const emailRes = await fetch('https://api.github.com/user/emails', { + headers: { 'Authorization': `token ${accessToken}`, 'User-Agent': '2FA-Manager-Backend/1.0' } + }); + const emails: any[] = await emailRes.json(); + email = emails.find(e => e.primary && e.verified)?.email || emails.find(e => e.verified)?.email; + } catch (e) { /* ignore */ } + } + + // 4. 映射到标准结构 + return { + id: String(userData.id), + username: userData.login || userData.username, + email: email || '', + avatar: userData.avatar_url, + provider: this.id + }; + } +} \ No newline at end of file diff --git a/backend/src/providers/oauth/NodeLocProvider.ts b/backend/src/providers/oauth/NodeLocProvider.ts new file mode 100644 index 0000000..d8d94f3 --- /dev/null +++ b/backend/src/providers/oauth/NodeLocProvider.ts @@ -0,0 +1,85 @@ +import { BaseOAuthProvider, OAuthUserInfo } from './BaseOAuthProvider'; +import { EnvBindings, AppError } from '../../config'; + +export class NodeLocProvider extends BaseOAuthProvider { + readonly id = 'nodeloc'; + readonly name = 'NodeLoc'; + readonly color = '#475569'; + readonly icon = ''; + readonly whitelistFields = ['username', 'email']; + + constructor(env: EnvBindings) { + super(env); + } + + getAuthorizeUrl(state: string) { + const clientId = this.env.OAUTH_NODELOC_CLIENT_ID; + const redirectUri = this.env.OAUTH_NODELOC_REDIRECT_URI; + + if (!clientId || !redirectUri) { + throw new AppError('NodeLoc OAuth configuration incomplete', 500); + } + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid profile email', + state: state + }); + + return { url: `https://www.nodeloc.com/oauth-provider/authorize?${params.toString()}` }; + } + + async handleCallback(code: string, _codeVerifier?: string): Promise { + const clientId = this.env.OAUTH_NODELOC_CLIENT_ID; + const clientSecret = this.env.OAUTH_NODELOC_CLIENT_SECRET; + const redirectUri = this.env.OAUTH_NODELOC_REDIRECT_URI; + + if (!clientId || !clientSecret || !redirectUri) { + throw new AppError('NodeLoc OAuth configuration incomplete', 500); + } + + // 1. Exchange Code for Token + const tokenResponse = await fetch('https://www.nodeloc.com/oauth-provider/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code: code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }) + }); + + if (!tokenResponse.ok) { + throw new AppError(`NodeLoc Token Exchange failed: ${tokenResponse.status}`, 502); + } + + const tokenData: any = await tokenResponse.json(); + const accessToken = tokenData.access_token; + + // 2. Get User Info (Flarum API) + const userResponse = await fetch('https://www.nodeloc.com/oauth-provider/userinfo', { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/json' + } + }); + + if (!userResponse.ok) throw new AppError(`NodeLoc API error: ${userResponse.status}`, 502); + const userData: any = await userResponse.json(); + + return { + id: String(userData.id), + username: userData.username || userData.name || userData.nickname, + email: userData.email || '', + avatar: userData.avatar || userData.avatar_url || '', + provider: this.id + }; + } +} \ No newline at end of file diff --git a/backend/src/providers/oauth/index.ts b/backend/src/providers/oauth/index.ts new file mode 100644 index 0000000..950cce1 --- /dev/null +++ b/backend/src/providers/oauth/index.ts @@ -0,0 +1,55 @@ +import { EnvBindings, AppError } from '../../config'; +import { BaseOAuthProvider } from './BaseOAuthProvider'; +import { GitHubProvider } from './GitHubProvider'; +import { CloudflareAccessProvider } from './CloudflareAccessProvider'; +import { NodeLocProvider } from './NodeLocProvider'; + +export function getOAuthProvider(providerId: string, env: EnvBindings): BaseOAuthProvider { + switch (providerId.toLowerCase()) { + case 'github': + return new GitHubProvider(env); + case 'cloudflare': + return new CloudflareAccessProvider(env); + case 'nodeloc': + return new NodeLocProvider(env); + default: + throw new AppError(`Provider '${providerId}' is not supported`, 400); + } +} + +export function getAvailableProviders(env: EnvBindings) { + const providers = []; + + const githubProvider = new GitHubProvider(env); + const cloudflareProvider = new CloudflareAccessProvider(env); + const nodelocProvider = new NodeLocProvider(env); + + if (env.OAUTH_GITHUB_CLIENT_ID && env.OAUTH_GITHUB_CLIENT_SECRET) { + providers.push({ + id: githubProvider.id, + name: githubProvider.name, + icon: githubProvider.icon, + color: githubProvider.color + }); + } + + if (env.OAUTH_CLOUDFLARE_CLIENT_ID && env.OAUTH_CLOUDFLARE_CLIENT_SECRET) { + providers.push({ + id: cloudflareProvider.id, + name: cloudflareProvider.name, + icon: cloudflareProvider.icon, + color: cloudflareProvider.color, + }); + } + + if (env.OAUTH_NODELOC_CLIENT_ID && env.OAUTH_NODELOC_CLIENT_SECRET) { + providers.push({ + id: nodelocProvider.id, + name: nodelocProvider.name, + icon: nodelocProvider.icon, + color: nodelocProvider.color, + }); + } + + return providers; +} \ No newline at end of file diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 4950b1e..fbafb83 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -3,103 +3,84 @@ import { setCookie } from 'hono/cookie'; import { EnvBindings, AppError } from '../config'; import { generateSecureJWT } from '../utils/crypto'; import { authMiddleware } from '../utils/helper'; +import { getOAuthProvider, getAvailableProviders } from '../providers/oauth/index'; const auth = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>(); -// 内部辅助函数:获取 GitHub 用户信息 -async function fetchGitHubUser(accessToken: string, oauthBaseUrl: string) { - const isGitHub = oauthBaseUrl.includes('github.com'); - const url = isGitHub ? 'https://api.github.com/user' : `${oauthBaseUrl}/api/user`; - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Authorization': `token ${accessToken}`, - 'Accept': 'application/json', - 'User-Agent': '2FA-Manager-Backend/1.0' - } - }); - - if (!response.ok) { - throw new AppError(`GitHub API error: ${response.status}`, 502); - } - return await response.json(); -} +// ========================================== +// 0. 获取可用登录方式列表 (新增) +// ========================================== +auth.get('/providers', (c) => { + const providers = getAvailableProviders(c.env); + return c.json({ success: true, providers }); +}); // ========================================== -// 1. 获取授权地址 (前端调用此接口获取跳转链接) +// 1. 获取授权地址 (支持动态 Provider) // ========================================== -auth.get('/authorize', async (c) => { +auth.get('/authorize/:provider', async (c) => { + const providerName = c.req.param('provider'); const state = crypto.randomUUID(); const env = c.env; - const isGitHub = env.OAUTH_BASE_URL.includes('github.com'); - const authPath = isGitHub ? '/login/oauth/authorize' : '/oauth2/authorize'; - const params = new URLSearchParams({ - client_id: env.OAUTH_CLIENT_ID, - redirect_uri: env.OAUTH_REDIRECT_URI, // 注意:后续在环境变量中,这里要填入 Vue 前端的地址 - state: state, - scope: isGitHub ? 'read:user user:email' : 'openid profile email' - }); - - const authUrl = `${env.OAUTH_BASE_URL.replace(/\/$/, '')}${authPath}?${params}`; - - // 返回 URL 和 State 让 Vue 前端存入 localStorage 后自行跳转 - return c.json({ success: true, authUrl, state }); + try { + const provider = getOAuthProvider(providerName, env); + const result = await provider.getAuthorizeUrl(state); + return c.json({ success: true, authUrl: result.url, state, codeVerifier: result.codeVerifier }); + } catch (e: any) { + throw new AppError(e.message || 'Provider not supported', 400); + } }); // ========================================== // 2. 核心逻辑:用 Code 换取系统的 JWT 令牌 // ========================================== -auth.post('/callback', async (c) => { - const { code } = await c.req.json(); +auth.post('/callback/:provider', async (c) => { + const providerName = c.req.param('provider'); + const { code, codeVerifier } = await c.req.json(); // 接收前端传回的 codeVerifier const env = c.env; if (!code) { throw new AppError('Missing OAuth code', 400); } - const isGitHub = env.OAUTH_BASE_URL.includes('github.com'); - const tokenPath = isGitHub ? '/login/oauth/access_token' : '/oauth2/token'; + const provider = getOAuthProvider(providerName, env); + const userInfo = await provider.handleCallback(code, codeVerifier); - // 去 GitHub 换取 Access Token - const tokenResponse = await fetch(`${env.OAUTH_BASE_URL.replace(/\/$/, '')}${tokenPath}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'User-Agent': '2FA-Manager-Backend/1.0' - }, - body: new URLSearchParams({ - client_id: env.OAUTH_CLIENT_ID, - client_secret: env.OAUTH_CLIENT_SECRET, - code: code, - redirect_uri: env.OAUTH_REDIRECT_URI, - }) - }); + // 严密的安全校验:基于 Email 或 Username 的白名单 (OAUTH_WHITELIST) + const allowedUsersStr = env.OAUTH_ALLOWED_USERS || ''; + const allowedIdentities = allowedUsersStr.split(',').map((e: string) => e.trim().toLowerCase()).filter(Boolean); + + const userEmail = (userInfo.email || '').toLowerCase(); + const userName = (userInfo.username || '').toLowerCase(); - const tokenData = await tokenResponse.json(); - if (!tokenData.access_token) { - throw new AppError('Failed to exchange code for token', 502); - } + // 如果白名单不为空,则必须匹配允许的字段 (Email 或 Username) + if (allowedIdentities.length && env.OAUTH_ALLOWED_USERS !== env.JWT_SECRET) { + let isAllowed = false; - // 获取用户信息 - const userData = await fetchGitHubUser(tokenData.access_token, env.OAUTH_BASE_URL); + // 1. 检查 Email (如果 Provider 允许) + if (provider.whitelistFields.includes('email') && userEmail && allowedIdentities.includes(userEmail)) { + isAllowed = true; + } + + // 2. 检查 Username (如果 Provider 允许) + if (provider.whitelistFields.includes('username') && userName && allowedIdentities.includes(userName)) { + isAllowed = true; + } - // 严密的安全校验:比对 OAUTH_ID (改为比对用户名) - // GitHub API 返回的用户名在 login 字段,这里统一转小写比较以防大小写差异 - const currentUsername = userData.login || userData.username; - if (!currentUsername || currentUsername.toLowerCase() !== env.OAUTH_ID.toLowerCase()) { - throw new AppError('Unauthorized user', 403); + if (! isAllowed) { + throw new AppError(`Unauthorized user. Email: ${userEmail}, Username: ${username}`, 403); + } } // 签发我们系统的专属 JWT 令牌 const payload = { userInfo: { - id: userData.id, - username: userData.login || userData.username || 'GitHub User', - email: userData.email, - avatar_template: userData.avatar_url || userData.avatar_template + id: userInfo.id, + username: userInfo.username, + email: userInfo.email, + avatar_template: userInfo.avatar, + provider: userInfo.provider } }; diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts index 3137499..99adcee 100644 --- a/backend/src/routes/backups.ts +++ b/backend/src/routes/backups.ts @@ -2,9 +2,7 @@ import { Hono } from 'hono'; import { EnvBindings, AppError, SECURITY_CONFIG } from '../config'; import { authMiddleware, sanitizeInput } from '../utils/helper'; import { encryptData, decryptData } from '../utils/crypto'; -import { BackupProvider } from '../providers/BackupProvider'; -import { WebDavProvider } from '../providers/WebDavProvider'; -import { S3Provider } from '../providers/S3Provider'; +import { BackupProvider, WebDavProvider, S3Provider } from '../providers/backup'; import { batchInsertAccounts, decryptField } from '../utils/db'; const backups = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>(); diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts index 7ff2103..5e50c9f 100644 --- a/backend/src/utils/crypto.ts +++ b/backend/src/utils/crypto.ts @@ -93,6 +93,27 @@ export async function verifySecureJWT(token: string, secret: string): Promise + - 通过 GitHub 授权登录 + 通过 {{ provider.name }} 授权登录 @@ -36,22 +40,40 @@