增加oidc登录

This commit is contained in:
mtvpls
2025-12-13 15:11:14 +08:00
parent d2cd4f6955
commit 822bb4a07e
12 changed files with 1153 additions and 2 deletions

View File

@@ -0,0 +1,99 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { issuerUrl } = await request.json();
if (!issuerUrl || typeof issuerUrl !== 'string') {
return NextResponse.json(
{ error: 'Issuer URL不能为空' },
{ status: 400 }
);
}
// 构建well-known URL
const wellKnownUrl = `${issuerUrl}/.well-known/openid-configuration`;
console.log('正在获取OIDC配置:', wellKnownUrl);
// 通过后端获取配置避免CORS问题
const response = await fetch(wellKnownUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
// 设置超时
signal: AbortSignal.timeout(10000), // 10秒超时
});
if (!response.ok) {
console.error('获取OIDC配置失败:', response.status, response.statusText);
return NextResponse.json(
{
error: `无法获取OIDC配置: ${response.status} ${response.statusText}`,
},
{ status: 400 }
);
}
const data = await response.json();
// 验证返回的数据包含必需的端点
if (!data.authorization_endpoint || !data.token_endpoint || !data.userinfo_endpoint) {
return NextResponse.json(
{
error: 'OIDC配置不完整缺少必需的端点',
},
{ status: 400 }
);
}
// 返回端点配置
return NextResponse.json({
authorization_endpoint: data.authorization_endpoint,
token_endpoint: data.token_endpoint,
userinfo_endpoint: data.userinfo_endpoint,
issuer: data.issuer,
});
} catch (error) {
console.error('OIDC自动发现失败:', error);
if (error instanceof Error) {
if (error.name === 'AbortError') {
return NextResponse.json(
{ error: '请求超时请检查Issuer URL是否正确' },
{ status: 408 }
);
}
return NextResponse.json(
{ error: `获取配置失败: ${error.message}` },
{ status: 500 }
);
}
return NextResponse.json(
{ error: '获取配置失败请检查Issuer URL是否正确' },
{ status: 500 }
);
}
}

View File

@@ -50,6 +50,14 @@ export async function POST(request: NextRequest) {
TurnstileSiteKey,
TurnstileSecretKey,
DefaultUserTags,
EnableOIDCLogin,
EnableOIDCRegistration,
OIDCIssuer,
OIDCAuthorizationEndpoint,
OIDCTokenEndpoint,
OIDCUserInfoEndpoint,
OIDCClientId,
OIDCClientSecret,
} = body as {
SiteName: string;
Announcement: string;
@@ -72,6 +80,14 @@ export async function POST(request: NextRequest) {
TurnstileSiteKey?: string;
TurnstileSecretKey?: string;
DefaultUserTags?: string[];
EnableOIDCLogin?: boolean;
EnableOIDCRegistration?: boolean;
OIDCIssuer?: string;
OIDCAuthorizationEndpoint?: string;
OIDCTokenEndpoint?: string;
OIDCUserInfoEndpoint?: string;
OIDCClientId?: string;
OIDCClientSecret?: string;
};
// 参数校验
@@ -96,7 +112,15 @@ export async function POST(request: NextRequest) {
(LoginRequireTurnstile !== undefined && typeof LoginRequireTurnstile !== 'boolean') ||
(TurnstileSiteKey !== undefined && typeof TurnstileSiteKey !== 'string') ||
(TurnstileSecretKey !== undefined && typeof TurnstileSecretKey !== 'string') ||
(DefaultUserTags !== undefined && !Array.isArray(DefaultUserTags))
(DefaultUserTags !== undefined && !Array.isArray(DefaultUserTags)) ||
(EnableOIDCLogin !== undefined && typeof EnableOIDCLogin !== 'boolean') ||
(EnableOIDCRegistration !== undefined && typeof EnableOIDCRegistration !== 'boolean') ||
(OIDCIssuer !== undefined && typeof OIDCIssuer !== 'string') ||
(OIDCAuthorizationEndpoint !== undefined && typeof OIDCAuthorizationEndpoint !== 'string') ||
(OIDCTokenEndpoint !== undefined && typeof OIDCTokenEndpoint !== 'string') ||
(OIDCUserInfoEndpoint !== undefined && typeof OIDCUserInfoEndpoint !== 'string') ||
(OIDCClientId !== undefined && typeof OIDCClientId !== 'string') ||
(OIDCClientSecret !== undefined && typeof OIDCClientSecret !== 'string')
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
@@ -137,6 +161,14 @@ export async function POST(request: NextRequest) {
TurnstileSiteKey,
TurnstileSecretKey,
DefaultUserTags,
EnableOIDCLogin,
EnableOIDCRegistration,
OIDCIssuer,
OIDCAuthorizationEndpoint,
OIDCTokenEndpoint,
OIDCUserInfoEndpoint,
OIDCClientId,
OIDCClientSecret,
};
// 写入数据库

View File

@@ -0,0 +1,213 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 生成签名
async function generateSignature(
data: string,
secret: string
): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, messageData);
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// 生成认证Cookie
async function generateAuthCookie(
username: string,
role: 'owner' | 'admin' | 'user'
): Promise<string> {
const authData: any = { role };
if (username && process.env.PASSWORD) {
authData.username = username;
const signature = await generateSignature(username, process.env.PASSWORD);
authData.signature = signature;
authData.timestamp = Date.now();
}
return encodeURIComponent(JSON.stringify(authData));
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
// 使用环境变量SITE_BASE或当前请求的origin
const origin = process.env.SITE_BASE || request.nextUrl.origin;
// 检查是否有错误
if (error) {
console.error('OIDC认证错误:', error);
return NextResponse.redirect(
new URL(`/login?error=${encodeURIComponent('OIDC认证失败')}`, origin)
);
}
// 验证必需参数
if (!code || !state) {
return NextResponse.redirect(
new URL('/login?error=' + encodeURIComponent('缺少必需参数'), origin)
);
}
// 验证state
const storedState = request.cookies.get('oidc_state')?.value;
if (!storedState || storedState !== state) {
return NextResponse.redirect(
new URL('/login?error=' + encodeURIComponent('状态验证失败'), origin)
);
}
const config = await getConfig();
const siteConfig = config.SiteConfig;
// 检查OIDC配置
if (!siteConfig.OIDCTokenEndpoint || !siteConfig.OIDCUserInfoEndpoint || !siteConfig.OIDCClientId || !siteConfig.OIDCClientSecret) {
return NextResponse.redirect(
new URL('/login?error=' + encodeURIComponent('OIDC配置不完整'), origin)
);
}
const redirectUri = `${origin}/api/auth/oidc/callback`;
// 交换code获取token
const tokenResponse = await fetch(siteConfig.OIDCTokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: redirectUri,
client_id: siteConfig.OIDCClientId,
client_secret: siteConfig.OIDCClientSecret,
}),
});
if (!tokenResponse.ok) {
console.error('获取token失败:', await tokenResponse.text());
return NextResponse.redirect(
new URL('/login?error=' + encodeURIComponent('获取token失败'), origin)
);
}
const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;
const idToken = tokenData.id_token;
if (!accessToken || !idToken) {
return NextResponse.redirect(
new URL('/login?error=' + encodeURIComponent('token无效'), origin)
);
}
// 获取用户信息
const userInfoResponse = await fetch(siteConfig.OIDCUserInfoEndpoint, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!userInfoResponse.ok) {
console.error('获取用户信息失败:', await userInfoResponse.text());
return NextResponse.redirect(
new URL('/login?error=' + encodeURIComponent('获取用户信息失败'), origin)
);
}
const userInfo = await userInfoResponse.json();
const oidcSub = userInfo.sub; // OIDC的唯一标识符
if (!oidcSub) {
return NextResponse.redirect(
new URL('/login?error=' + encodeURIComponent('用户信息无效'), origin)
);
}
// 检查用户是否已存在(通过OIDC sub查找)
const existingUser = config.UserConfig.Users.find((u: any) => u.oidcSub === oidcSub);
if (existingUser) {
// 用户已存在,直接登录
const response = NextResponse.redirect(new URL('/', origin));
const cookieValue = await generateAuthCookie(
existingUser.username,
existingUser.role || 'user'
);
const expires = new Date();
expires.setDate(expires.getDate() + 7);
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'lax',
httpOnly: false,
secure: false,
});
// 清除state cookie
response.cookies.delete('oidc_state');
return response;
}
// 用户不存在,检查是否允许注册
if (!siteConfig.EnableOIDCRegistration) {
return NextResponse.redirect(
new URL('/login?error=' + encodeURIComponent('该OIDC账号未注册'), origin)
);
}
// 需要注册,跳转到用户名输入页面
// 将OIDC信息存储到session中
const oidcSession = {
sub: oidcSub,
email: userInfo.email,
name: userInfo.name,
timestamp: Date.now(),
};
const response = NextResponse.redirect(new URL('/oidc-register', origin));
response.cookies.set('oidc_session', JSON.stringify(oidcSession), {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 600, // 10分钟
});
// 清除state cookie
response.cookies.delete('oidc_state');
return response;
} catch (error) {
console.error('OIDC回调处理失败:', error);
const origin = process.env.SITE_BASE || request.nextUrl.origin;
return NextResponse.redirect(
new URL('/login?error=' + encodeURIComponent('服务器错误'), origin)
);
}
}

View File

@@ -0,0 +1,181 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 生成签名
async function generateSignature(
data: string,
secret: string
): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, messageData);
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// 生成认证Cookie
async function generateAuthCookie(
username: string,
role: 'owner' | 'admin' | 'user'
): Promise<string> {
const authData: any = { role };
if (username && process.env.PASSWORD) {
authData.username = username;
const signature = await generateSignature(username, process.env.PASSWORD);
authData.signature = signature;
authData.timestamp = Date.now();
}
return encodeURIComponent(JSON.stringify(authData));
}
export async function POST(request: NextRequest) {
try {
const { username } = await request.json();
// 验证用户名
if (!username || typeof username !== 'string') {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
// 验证用户名格式
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
return NextResponse.json(
{ error: '用户名只能包含字母、数字、下划线长度3-20位' },
{ status: 400 }
);
}
// 获取OIDC session
const oidcSessionCookie = request.cookies.get('oidc_session')?.value;
if (!oidcSessionCookie) {
return NextResponse.json(
{ error: 'OIDC会话已过期请重新登录' },
{ status: 400 }
);
}
let oidcSession;
try {
oidcSession = JSON.parse(oidcSessionCookie);
} catch {
return NextResponse.json(
{ error: 'OIDC会话无效' },
{ status: 400 }
);
}
// 检查session是否过期(10分钟)
if (Date.now() - oidcSession.timestamp > 600000) {
return NextResponse.json(
{ error: 'OIDC会话已过期请重新登录' },
{ status: 400 }
);
}
const config = await getConfig();
const siteConfig = config.SiteConfig;
// 检查是否启用OIDC注册
if (!siteConfig.EnableOIDCRegistration) {
return NextResponse.json(
{ error: 'OIDC注册未启用' },
{ status: 403 }
);
}
// 检查是否与站长同名
if (username === process.env.USERNAME) {
return NextResponse.json(
{ error: '该用户名不可用' },
{ status: 409 }
);
}
// 检查用户名是否已存在
const existingUser = config.UserConfig.Users.find((u) => u.username === username);
if (existingUser) {
return NextResponse.json(
{ error: '用户名已存在' },
{ status: 409 }
);
}
// 检查OIDC sub是否已被使用
const existingOIDCUser = config.UserConfig.Users.find((u: any) => u.oidcSub === oidcSession.sub);
if (existingOIDCUser) {
return NextResponse.json(
{ error: '该OIDC账号已被注册' },
{ status: 409 }
);
}
// 创建用户
try {
// 生成随机密码(OIDC用户不需要密码登录)
const randomPassword = crypto.randomUUID();
await db.registerUser(username, randomPassword);
// 将用户添加到配置中
const newUser: any = {
username: username,
role: 'user',
banned: false,
oidcSub: oidcSession.sub, // 保存OIDC标识符
};
// 如果配置了默认用户组,分配给新用户
if (siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0) {
newUser.tags = siteConfig.DefaultUserTags;
}
config.UserConfig.Users.push(newUser);
// 保存配置
await db.saveAdminConfig(config);
// 设置认证cookie
const response = NextResponse.json({ ok: true, message: '注册成功' });
const cookieValue = await generateAuthCookie(username, 'user');
const expires = new Date();
expires.setDate(expires.getDate() + 7);
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'lax',
httpOnly: false,
secure: false,
});
// 清除OIDC session
response.cookies.delete('oidc_session');
return response;
} catch (err) {
console.error('创建用户失败', err);
return NextResponse.json({ error: '注册失败,请稍后重试' }, { status: 500 });
}
} catch (error) {
console.error('OIDC注册完成失败:', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
}
}

View File

@@ -0,0 +1,63 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const config = await getConfig();
const siteConfig = config.SiteConfig;
// 检查是否启用OIDC登录
if (!siteConfig.EnableOIDCLogin) {
return NextResponse.json(
{ error: 'OIDC登录未启用' },
{ status: 403 }
);
}
// 检查OIDC配置
if (!siteConfig.OIDCAuthorizationEndpoint || !siteConfig.OIDCClientId) {
return NextResponse.json(
{ error: 'OIDC配置不完整请配置Authorization Endpoint和Client ID' },
{ status: 500 }
);
}
// 生成state参数用于防止CSRF攻击
const state = crypto.randomUUID();
// 使用环境变量SITE_BASE或当前请求的origin
const origin = process.env.SITE_BASE || request.nextUrl.origin;
const redirectUri = `${origin}/api/auth/oidc/callback`;
// 构建授权URL
const authUrl = new URL(siteConfig.OIDCAuthorizationEndpoint);
authUrl.searchParams.set('client_id', siteConfig.OIDCClientId);
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', state);
// 将state存储到cookie中
const response = NextResponse.redirect(authUrl);
response.cookies.set('oidc_state', state, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 600, // 10分钟
});
return response;
} catch (error) {
console.error('OIDC登录发起失败:', error);
return NextResponse.json(
{ error: '服务器错误' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const oidcSessionCookie = request.cookies.get('oidc_session')?.value;
if (!oidcSessionCookie) {
return NextResponse.json(
{ error: 'OIDC会话不存在' },
{ status: 404 }
);
}
let oidcSession;
try {
oidcSession = JSON.parse(oidcSessionCookie);
} catch {
return NextResponse.json(
{ error: 'OIDC会话无效' },
{ status: 400 }
);
}
// 检查session是否过期(10分钟)
if (Date.now() - oidcSession.timestamp > 600000) {
return NextResponse.json(
{ error: 'OIDC会话已过期' },
{ status: 400 }
);
}
// 返回用户信息(不包含sub)
return NextResponse.json({
email: oidcSession.email,
name: oidcSession.name,
});
} catch (error) {
return NextResponse.json(
{ error: '服务器错误' },
{ status: 500 }
);
}
}

View File

@@ -41,6 +41,8 @@ export async function GET(request: NextRequest) {
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
EnableOIDCLogin: config.SiteConfig.EnableOIDCLogin || false,
EnableOIDCRegistration: config.SiteConfig.EnableOIDCRegistration || false,
};
return NextResponse.json(result);
}