增加oidc登录
This commit is contained in:
213
src/app/api/auth/oidc/callback/route.ts
Normal file
213
src/app/api/auth/oidc/callback/route.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
181
src/app/api/auth/oidc/complete-register/route.ts
Normal file
181
src/app/api/auth/oidc/complete-register/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
63
src/app/api/auth/oidc/login/route.ts
Normal file
63
src/app/api/auth/oidc/login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
45
src/app/api/auth/oidc/session-info/route.ts
Normal file
45
src/app/api/auth/oidc/session-info/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user