refactor: 重构oauth2授权登录模块,已支持Github,Cloudflare Access,NodeLoc
This commit is contained in:
1 parent
d47b73c743
commit
411ae41f0d
18 files changed
+609
-129
No files matched your search
@@ -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 }}
|
||||
@@ -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://<team>.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. 初始化本地数据库
|
||||
|
||||
+11
-5
@@ -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;
|
||||
};
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,3 @@
|
||||
export * from './BackupProvider';
|
||||
export * from './WebDavProvider';
|
||||
export * from './S3Provider';
|
||||
@@ -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 代码 (完整 <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<OAuthUserInfo>;
|
||||
}
|
||||
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 48 48" fill="currentColor"><path d="M36.8 16.6C36.2 11.2 31.7 7 26.2 7c-4.8 0-8.9 3.2-10.2 7.6-5.3.6-9.4 5.1-9.4 10.5 0 5.8 4.7 10.5 10.5 10.5h19.7c4.8 0 8.7-3.9 8.7-8.7 0-4.6-3.6-8.4-8.1-8.7-.2-1.1-.4-2.1-.6-3.1z"/></svg>';
|
||||
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<OAuthUserInfo> {
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" /></svg>';
|
||||
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<OAuthUserInfo> {
|
||||
// 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" viewBox="0 0 102 66" fill="none"><path d="M51.504 19.32C51.504 23.224 51.312 28.888 50.928 36.312C50.544 43.608 50.352 49.144 50.352 52.92C50.352 54.008 50.416 55.64 50.544 57.816C50.672 59.992 50.736 61.688 50.736 62.904C50.608 63.288 49.36 63.64 46.992 63.96C44.624 64.28 42.992 64.44 42.096 64.44C41.648 64.44 40.944 63.832 39.984 62.616C39.024 61.4 37.808 59.704 36.336 57.528C33.904 54.072 32.368 52.088 31.728 51.576C31.728 51.576 31.216 50.808 30.192 49.272C29.104 47.544 27.76 45.496 26.16 43.128C24.624 40.76 23.856 39.544 23.856 39.48L15.504 29.592C15.44 29.464 15.28 29.112 15.024 28.536C14.768 27.96 14.416 27.672 13.968 27.672C13.776 27.672 13.584 27.864 13.392 28.248C13.264 28.568 13.2 28.92 13.2 29.304C13.2 36.024 13.264 41.304 13.392 45.144C13.456 46.04 13.488 47.16 13.488 48.504C13.488 48.696 13.456 48.92 13.392 49.176C13.328 49.368 13.264 49.528 13.2 49.656V64.44L12.432 65.208L10.32 65.304L0.528 64.536C0.528 62.744 0.592 60.632 0.72 58.2C0.912 55.768 1.008 54.328 1.008 53.88C1.008 52.728 0.944 51.128 0.816 49.08C0.688 46.776 0.624001 44.792 0.624001 43.128L0.816 39.864C1.008 36.216 1.104 34.2 1.104 33.816C1.104 32.28 1.04 29.976 0.912 26.904C0.784 23.832 0.72 21.56 0.72 20.088C0.72 19.192 0.848 17.912 1.104 16.248C1.36 14.712 1.488 13.592 1.488 12.888C1.488 12.696 1.36 11.736 1.104 10.008C0.848 8.216 0.72 6.68 0.72 5.4L0.336 4.72799C0.464 4.088 0.688 3.672 1.008 3.48C1.456 3.416 2.672 3.224 4.656 2.904C6.64 2.52 8.24 2.328 9.456 2.328C10.544 2.328 11.28 2.456 11.664 2.712C12.112 3.096 12.816 3.768 13.776 4.72799C14.736 5.688 15.184 6.264 15.12 6.456L20.016 14.808C20.016 14.872 20.912 16.152 22.704 18.648L24.24 20.856C27.44 25.592 30.256 29.656 32.688 33.048C35.12 36.376 36.688 38.04 37.392 38.04C37.84 38.04 38.096 37.88 38.16 37.56C38.16 36.536 38.096 35.128 37.968 33.336L37.872 29.592C37.872 28.44 37.936 26.648 38.064 24.216L38.16 18.84C38.16 17.368 38.096 15.16 37.968 12.216C37.84 9.4 37.776 7.224 37.776 5.688C37.776 4.152 37.872 3.064 38.064 2.424C38.32 1.784 38.8 1.336 39.504 1.08C40.208 0.759996 41.36 0.599997 42.96 0.599997C46.16 0.599997 48.144 0.759996 48.912 1.08C49.68 1.336 50.096 1.816 50.16 2.51999C50.224 3.16 50.416 3.704 50.736 4.152V10.2L51.024 11.064L50.928 14.328C50.928 16.76 51.12 18.424 51.504 19.32ZM39.696 6.168C39.952 6.168 40.912 5.72 42.576 4.824C44.24 3.864 45.072 3.288 45.072 3.096V2.616C44.304 2.616 43.216 2.712 41.808 2.904C40.4 3.032 39.696 3.224 39.696 3.48V6.168ZM49.2 20.856C49.2 18.424 49.104 16.024 48.912 13.656C48.784 10.584 48.72 8.28 48.72 6.744L48.816 4.632L49.2 4.152C49.008 4.024 48.784 3.736 48.528 3.288C48.272 2.84 47.92 2.616 47.472 2.616C46.96 2.616 45.68 3.32 43.632 4.72799C41.648 6.072 40.656 6.936 40.656 7.32C41.232 7.32 41.808 7.16 42.384 6.84C43.024 6.52 43.408 6.328 43.536 6.264C44.688 5.624 45.552 5.304 46.128 5.304C46.832 5.304 47.344 5.432 47.664 5.688C47.216 6.456 46.48 7.256 45.456 8.088C44.496 8.856 43.472 9.592 42.384 10.296C41.296 11 40.528 11.512 40.08 11.832C40.528 11.832 41.072 11.768 41.712 11.64C42.416 11.512 42.896 11.416 43.152 11.352C44.688 10.968 45.84 10.776 46.608 10.776C46.608 10.904 46.64 11.096 46.704 11.352C46.832 11.608 46.896 11.832 46.896 12.024C46.896 12.408 46.672 12.824 46.224 13.272C45.776 13.656 45.104 14.136 44.208 14.712C42.608 15.736 41.488 16.536 40.848 17.112C41.616 17.112 42.512 16.76 43.536 16.056C44.688 15.416 45.552 15.096 46.128 15.096C46.384 15.096 46.512 15.256 46.512 15.576C46.512 16.024 46.352 16.44 46.032 16.824C45.712 17.144 45.168 17.624 44.4 18.264C43.056 19.224 42 20.248 41.232 21.336C42.128 21.336 43.28 21.048 44.688 20.472C46.352 19.832 47.696 19.512 48.72 19.512C48.72 20.28 48.368 20.984 47.664 21.624C47.024 22.264 46.096 22.968 44.88 23.736C43.856 24.44 43.12 24.984 42.672 25.368C42.224 25.752 42 26.168 42 26.616C42.832 26.168 44.144 25.688 45.936 25.176C47.216 24.856 48.08 24.472 48.528 24.024C48.976 23.512 49.2 22.456 49.2 20.856ZM4.848 21.624C6 21.048 7.088 20.536 8.112 20.088C9.2 19.576 10 19.192 10.512 18.936C12.368 18.232 14.672 17.112 17.424 15.576C17.424 15.576 17.456 15.544 17.52 15.48C17.648 15.352 17.712 15.256 17.712 15.192C17.712 15.128 17.648 15.032 17.52 14.904C17.456 14.712 17.424 14.552 17.424 14.424C17.104 14.104 16.752 13.592 16.368 12.888C16.048 12.12 15.824 11.608 15.696 11.352C14.736 9.24 13.808 7.576 12.912 6.36C12.016 5.144 10.896 4.536 9.552 4.536C8.72 4.536 7.984 4.728 7.344 5.112C6.704 5.496 5.936 6.072 5.04 6.84C4.144 7.608 3.472 8.12 3.024 8.376C3.024 8.44 3.056 8.536 3.12 8.664C3.184 8.728 3.248 8.76 3.312 8.76C4.144 8.76 5.328 8.376 6.864 7.608C8.4 6.84 9.584 6.456 10.416 6.456C10.8 6.456 10.992 6.712 10.992 7.224C10.992 7.8 10.256 8.664 8.784 9.816C7.376 10.904 6.32 11.448 5.616 11.448H5.232L3.024 13.368V13.848C3.6 13.848 4.208 13.656 4.848 13.272C5.552 12.888 6.448 12.312 7.536 11.544C9.328 10.264 10.576 9.56 11.28 9.432L13.488 9.62399V10.2C13.488 10.52 13.136 11 12.432 11.64C11.728 12.216 10.Line truncated
|
||||
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<OAuthUserInfo> {
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+49
-68
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 } }>();
|
||||
|
||||
@@ -93,6 +93,27 @@ export async function verifySecureJWT(token: string, secret: string): Promise<an
|
||||
}
|
||||
}
|
||||
|
||||
// PKCE 辅助函数
|
||||
export 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(/=+$/, '');
|
||||
}
|
||||
|
||||
export async function generatePKCE() {
|
||||
const verifierBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(verifierBytes);
|
||||
const verifier = base64UrlEncode(verifierBytes);
|
||||
const challenge = base64UrlEncode(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. AES-GCM 数据加解密 (用于保护 KV 里的 2FA 密钥)
|
||||
// ==========================================
|
||||
|
||||
@@ -140,7 +140,7 @@ main {
|
||||
background: transparent; position: relative;
|
||||
}
|
||||
.login-card {
|
||||
width: 100%; max-width: 420px; border-radius: 16px;
|
||||
width: 92%; max-width: 400px; border-radius: 16px;
|
||||
box-shadow: var(--glass-card-shadow);
|
||||
background: var(--glass-card-bg);
|
||||
backdrop-filter: blur(10px); border: none;
|
||||
@@ -148,21 +148,21 @@ main {
|
||||
.logo-container { text-align: center; margin-bottom: 30px; padding-top: 10px; }
|
||||
.logo-container h2 { margin: 16px 0 8px; color: var(--el-text-color-primary); font-size: 24px; font-weight: 600; }
|
||||
.subtitle { margin: 0; color: var(--el-text-color-secondary); font-size: 14px; }
|
||||
.action-container { display: flex; justify-content: center; margin-bottom: 30px; }
|
||||
.github-btn {
|
||||
.action-container { display: flex; flex-direction: column; gap: 15px; margin-bottom: 30px; }
|
||||
.oauth-btn {
|
||||
width: 100%; height: 50px; font-size: 16px; border-radius: 8px;
|
||||
background-color: var(--github-btn-bg); border-color: var(--github-btn-border);
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.github-btn:hover, .github-btn:focus {
|
||||
background-color: #4a4e53; border-color: #4a4e53;
|
||||
transform: translateY(-2px); box-shadow: 0 4px 12px rgba(36, 41, 46, 0.3);
|
||||
.oauth-btn:hover, .oauth-btn:focus {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.footer-tips { margin-top: 10px; }
|
||||
|
||||
/* OAuthCallback.vue */
|
||||
.callback-container { display: flex; justify-content: center; align-items: center; height: 100%; background: transparent; }
|
||||
.loading-card {
|
||||
width: 100%; max-width: 420px; border-radius: 16px;
|
||||
width: 92%; max-width: 400px; border-radius: 16px;
|
||||
background: var(--glass-card-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -9,16 +9,20 @@
|
||||
|
||||
<div class="action-container">
|
||||
<el-button
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
type="primary"
|
||||
size="large"
|
||||
class="github-btn"
|
||||
class="oauth-btn"
|
||||
:style="{ backgroundColor: provider.color, borderColor: provider.color }"
|
||||
:loading="loading"
|
||||
@click="handleGitHubLogin"
|
||||
@click="handleLogin(provider.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<el-icon><Platform /></el-icon>
|
||||
<el-icon v-if="provider.icon"><span v-html="provider.icon" style="display: flex;"></span></el-icon>
|
||||
<el-icon v-else><Platform /></el-icon>
|
||||
</template>
|
||||
通过 GitHub 授权登录
|
||||
通过 {{ provider.name }} 授权登录
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -36,22 +40,40 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Lock, Platform } from '@element-plus/icons-vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const providers = ref([])
|
||||
|
||||
const handleGitHubLogin = async () => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/oauth/providers')
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
providers.value = data.providers
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load providers', e)
|
||||
}
|
||||
})
|
||||
|
||||
const handleLogin = async (providerId) => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 1. 获取授权链接
|
||||
const response = await fetch('/api/oauth/authorize')
|
||||
const response = await fetch(`/api/oauth/authorize/${providerId}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.authUrl) {
|
||||
// 2. 存储 state 防御 CSRF 并跳转
|
||||
localStorage.setItem('oauth_state', data.state)
|
||||
localStorage.setItem('oauth_provider', providerId)
|
||||
// 如果有 PKCE verifier,也存起来
|
||||
if (data.codeVerifier) {
|
||||
localStorage.setItem('oauth_code_verifier', data.codeVerifier)
|
||||
}
|
||||
window.location.href = data.authUrl
|
||||
} else {
|
||||
ElMessage.error(data.error || '获取授权链接失败')
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
v-if="!errorMsg"
|
||||
icon="info"
|
||||
title="正在安全登录中"
|
||||
sub-title="请稍候,系统正在与 GitHub 交换安全凭证..."
|
||||
:sub-title="`请稍候,系统正在与 ${providerName} 交换安全凭证...`"
|
||||
>
|
||||
<template #extra>
|
||||
<el-icon class="is-loading" :size="40" color="#409eff"><Loading /></el-icon>
|
||||
@@ -35,14 +35,24 @@ import { userState } from '../states/user'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const errorMsg = ref('')
|
||||
const providerName = ref('身份提供商')
|
||||
|
||||
onMounted(async () => {
|
||||
const code = route.query.code
|
||||
const state = route.query.state
|
||||
const error = route.query.error
|
||||
const providerId = localStorage.getItem('oauth_provider') || 'github'
|
||||
|
||||
// 直接从 API 获取提供商名称用于展示
|
||||
try {
|
||||
const res = await fetch('/api/oauth/providers')
|
||||
const data = await res.json()
|
||||
const p = data.providers?.find(x => x.id === providerId)
|
||||
if (p) providerName.value = p.name
|
||||
} catch (e) {}
|
||||
|
||||
if (error) {
|
||||
errorMsg.value = route.query.error_description || '您拒绝了 GitHub 的授权请求'
|
||||
errorMsg.value = route.query.error_description || `您拒绝了 ${providerName.value} 的授权请求`
|
||||
return
|
||||
}
|
||||
|
||||
@@ -53,18 +63,22 @@ onMounted(async () => {
|
||||
|
||||
// 🛡️ 前端 State 校验:防止 CSRF 攻击
|
||||
const savedState = localStorage.getItem('oauth_state')
|
||||
const codeVerifier = localStorage.getItem('oauth_code_verifier')
|
||||
|
||||
if (!savedState || savedState !== state) {
|
||||
errorMsg.value = '安全警告:State 校验失败,请求可能被篡改'
|
||||
return
|
||||
}
|
||||
localStorage.removeItem('oauth_state') // 校验通过后立即清除,防止重放
|
||||
localStorage.removeItem('oauth_provider')
|
||||
localStorage.removeItem('oauth_code_verifier')
|
||||
|
||||
try {
|
||||
// 关键步骤:把 code 发给后端换取我们自己的 JWT
|
||||
const response = await fetch('/api/oauth/callback', {
|
||||
const response = await fetch(`/api/oauth/callback/${providerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, state })
|
||||
body: JSON.stringify({ code, state, codeVerifier })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
Reference in new issue
Block a user