增加oidc登录
This commit is contained in:
@@ -303,6 +303,14 @@ interface SiteConfig {
|
|||||||
TurnstileSiteKey?: string;
|
TurnstileSiteKey?: string;
|
||||||
TurnstileSecretKey?: string;
|
TurnstileSecretKey?: string;
|
||||||
DefaultUserTags?: string[];
|
DefaultUserTags?: string[];
|
||||||
|
EnableOIDCLogin?: boolean;
|
||||||
|
EnableOIDCRegistration?: boolean;
|
||||||
|
OIDCIssuer?: string;
|
||||||
|
OIDCAuthorizationEndpoint?: string;
|
||||||
|
OIDCTokenEndpoint?: string;
|
||||||
|
OIDCUserInfoEndpoint?: string;
|
||||||
|
OIDCClientId?: string;
|
||||||
|
OIDCClientSecret?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频源数据类型
|
// 视频源数据类型
|
||||||
@@ -4589,6 +4597,14 @@ const SiteConfigComponent = ({
|
|||||||
TurnstileSiteKey: '',
|
TurnstileSiteKey: '',
|
||||||
TurnstileSecretKey: '',
|
TurnstileSecretKey: '',
|
||||||
DefaultUserTags: [],
|
DefaultUserTags: [],
|
||||||
|
EnableOIDCLogin: false,
|
||||||
|
EnableOIDCRegistration: false,
|
||||||
|
OIDCIssuer: '',
|
||||||
|
OIDCAuthorizationEndpoint: '',
|
||||||
|
OIDCTokenEndpoint: '',
|
||||||
|
OIDCUserInfoEndpoint: '',
|
||||||
|
OIDCClientId: '',
|
||||||
|
OIDCClientSecret: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 豆瓣数据源相关状态
|
// 豆瓣数据源相关状态
|
||||||
@@ -4662,6 +4678,14 @@ const SiteConfigComponent = ({
|
|||||||
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
|
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
|
||||||
TurnstileSecretKey: config.SiteConfig.TurnstileSecretKey || '',
|
TurnstileSecretKey: config.SiteConfig.TurnstileSecretKey || '',
|
||||||
DefaultUserTags: config.SiteConfig.DefaultUserTags || [],
|
DefaultUserTags: config.SiteConfig.DefaultUserTags || [],
|
||||||
|
EnableOIDCLogin: config.SiteConfig.EnableOIDCLogin || false,
|
||||||
|
EnableOIDCRegistration: config.SiteConfig.EnableOIDCRegistration || false,
|
||||||
|
OIDCIssuer: config.SiteConfig.OIDCIssuer || '',
|
||||||
|
OIDCAuthorizationEndpoint: config.SiteConfig.OIDCAuthorizationEndpoint || '',
|
||||||
|
OIDCTokenEndpoint: config.SiteConfig.OIDCTokenEndpoint || '',
|
||||||
|
OIDCUserInfoEndpoint: config.SiteConfig.OIDCUserInfoEndpoint || '',
|
||||||
|
OIDCClientId: config.SiteConfig.OIDCClientId || '',
|
||||||
|
OIDCClientSecret: config.SiteConfig.OIDCClientSecret || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
@@ -5412,6 +5436,293 @@ const SiteConfigComponent = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* OIDC配置 */}
|
||||||
|
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
|
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
OIDC配置
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 启用OIDC登录 */}
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
启用OIDC登录
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
EnableOIDCLogin: !prev.EnableOIDCLogin,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||||
|
siteSettings.EnableOIDCLogin
|
||||||
|
? buttonStyles.toggleOn
|
||||||
|
: buttonStyles.toggleOff
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||||
|
buttonStyles.toggleThumb
|
||||||
|
} transition-transform ${
|
||||||
|
siteSettings.EnableOIDCLogin
|
||||||
|
? buttonStyles.toggleThumbOn
|
||||||
|
: buttonStyles.toggleThumbOff
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
开启后登录页面将显示OIDC登录按钮
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 启用OIDC注册 */}
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
启用OIDC注册
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
EnableOIDCRegistration: !prev.EnableOIDCRegistration,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||||
|
siteSettings.EnableOIDCRegistration
|
||||||
|
? buttonStyles.toggleOn
|
||||||
|
: buttonStyles.toggleOff
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||||
|
buttonStyles.toggleThumb
|
||||||
|
} transition-transform ${
|
||||||
|
siteSettings.EnableOIDCRegistration
|
||||||
|
? buttonStyles.toggleThumbOn
|
||||||
|
: buttonStyles.toggleThumbOff
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
开启后允许通过OIDC方式注册新用户(需要先启用OIDC登录)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OIDC Issuer */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
OIDC Issuer URL(可选)
|
||||||
|
</label>
|
||||||
|
<div className='flex flex-col sm:flex-row gap-2'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='https://your-oidc-provider.com/realms/your-realm'
|
||||||
|
value={siteSettings.OIDCIssuer || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
OIDCIssuer: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={async () => {
|
||||||
|
if (!siteSettings.OIDCIssuer) {
|
||||||
|
showError('请先输入Issuer URL', showAlert);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withLoading('oidcDiscover', async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/oidc-discover', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ issuerUrl: siteSettings.OIDCIssuer }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || '获取配置失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
OIDCAuthorizationEndpoint: data.authorization_endpoint || '',
|
||||||
|
OIDCTokenEndpoint: data.token_endpoint || '',
|
||||||
|
OIDCUserInfoEndpoint: data.userinfo_endpoint || '',
|
||||||
|
}));
|
||||||
|
showSuccess('自动发现成功', showAlert);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '自动发现失败,请手动配置端点';
|
||||||
|
showError(errorMessage, showAlert);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading('oidcDiscover')}
|
||||||
|
className={`px-4 py-2 ${isLoading('oidcDiscover') ? buttonStyles.disabled : buttonStyles.primary} rounded-lg whitespace-nowrap sm:w-auto w-full`}
|
||||||
|
>
|
||||||
|
{isLoading('oidcDiscover') ? '发现中...' : '自动发现'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
OIDC提供商的Issuer URL,填写后可点击"自动发现"按钮自动获取端点配置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Authorization Endpoint */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
Authorization Endpoint(授权端点)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='https://your-oidc-provider.com/realms/your-realm/protocol/openid-connect/auth'
|
||||||
|
value={siteSettings.OIDCAuthorizationEndpoint || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
OIDCAuthorizationEndpoint: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
用户授权的端点URL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token Endpoint */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
Token Endpoint(Token端点)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='https://your-oidc-provider.com/realms/your-realm/protocol/openid-connect/token'
|
||||||
|
value={siteSettings.OIDCTokenEndpoint || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
OIDCTokenEndpoint: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
交换授权码获取token的端点URL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* UserInfo Endpoint */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
UserInfo Endpoint(用户信息端点)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='https://your-oidc-provider.com/realms/your-realm/protocol/openid-connect/userinfo'
|
||||||
|
value={siteSettings.OIDCUserInfoEndpoint || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
OIDCUserInfoEndpoint: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
获取用户信息的端点URL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OIDC Client ID */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
OIDC Client ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='请输入Client ID'
|
||||||
|
value={siteSettings.OIDCClientId || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
OIDCClientId: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
在OIDC提供商处注册应用后获得的Client ID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OIDC Client Secret */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
OIDC Client Secret
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='password'
|
||||||
|
placeholder='请输入Client Secret'
|
||||||
|
value={siteSettings.OIDCClientSecret || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
OIDCClientSecret: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
在OIDC提供商处注册应用后获得的Client Secret
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OIDC Redirect URI - 只读显示 */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
OIDC Redirect URI(回调地址)
|
||||||
|
</label>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
readOnly
|
||||||
|
value={
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? `${(window as any).RUNTIME_CONFIG?.SITE_BASE || window.location.origin}/api/auth/oidc/callback`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-300 cursor-default'
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
const uri = `${(window as any).RUNTIME_CONFIG?.SITE_BASE || window.location.origin}/api/auth/oidc/callback`;
|
||||||
|
navigator.clipboard.writeText(uri);
|
||||||
|
showSuccess('已复制到剪贴板', showAlert);
|
||||||
|
}}
|
||||||
|
className='absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors'
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
这是系统自动生成的回调地址,基于环境变量SITE_BASE。请在OIDC提供商(如Keycloak、Auth0等)的应用配置中添加此地址作为允许的重定向URI
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className='flex justify-end'>
|
<div className='flex justify-end'>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,14 @@ export async function POST(request: NextRequest) {
|
|||||||
TurnstileSiteKey,
|
TurnstileSiteKey,
|
||||||
TurnstileSecretKey,
|
TurnstileSecretKey,
|
||||||
DefaultUserTags,
|
DefaultUserTags,
|
||||||
|
EnableOIDCLogin,
|
||||||
|
EnableOIDCRegistration,
|
||||||
|
OIDCIssuer,
|
||||||
|
OIDCAuthorizationEndpoint,
|
||||||
|
OIDCTokenEndpoint,
|
||||||
|
OIDCUserInfoEndpoint,
|
||||||
|
OIDCClientId,
|
||||||
|
OIDCClientSecret,
|
||||||
} = body as {
|
} = body as {
|
||||||
SiteName: string;
|
SiteName: string;
|
||||||
Announcement: string;
|
Announcement: string;
|
||||||
@@ -72,6 +80,14 @@ export async function POST(request: NextRequest) {
|
|||||||
TurnstileSiteKey?: string;
|
TurnstileSiteKey?: string;
|
||||||
TurnstileSecretKey?: string;
|
TurnstileSecretKey?: string;
|
||||||
DefaultUserTags?: 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') ||
|
(LoginRequireTurnstile !== undefined && typeof LoginRequireTurnstile !== 'boolean') ||
|
||||||
(TurnstileSiteKey !== undefined && typeof TurnstileSiteKey !== 'string') ||
|
(TurnstileSiteKey !== undefined && typeof TurnstileSiteKey !== 'string') ||
|
||||||
(TurnstileSecretKey !== undefined && typeof TurnstileSecretKey !== '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 });
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -137,6 +161,14 @@ export async function POST(request: NextRequest) {
|
|||||||
TurnstileSiteKey,
|
TurnstileSiteKey,
|
||||||
TurnstileSecretKey,
|
TurnstileSecretKey,
|
||||||
DefaultUserTags,
|
DefaultUserTags,
|
||||||
|
EnableOIDCLogin,
|
||||||
|
EnableOIDCRegistration,
|
||||||
|
OIDCIssuer,
|
||||||
|
OIDCAuthorizationEndpoint,
|
||||||
|
OIDCTokenEndpoint,
|
||||||
|
OIDCUserInfoEndpoint,
|
||||||
|
OIDCClientId,
|
||||||
|
OIDCClientSecret,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 写入数据库
|
// 写入数据库
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ export async function GET(request: NextRequest) {
|
|||||||
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
|
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
|
||||||
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
|
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
|
||||||
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
|
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
|
||||||
|
EnableOIDCLogin: config.SiteConfig.EnableOIDCLogin || false,
|
||||||
|
EnableOIDCRegistration: config.SiteConfig.EnableOIDCRegistration || false,
|
||||||
};
|
};
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,6 +340,32 @@ function LoginPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* OIDC登录按钮 */}
|
||||||
|
{siteConfig?.EnableOIDCLogin && shouldAskUsername && (
|
||||||
|
<div className='mt-6'>
|
||||||
|
<div className='relative'>
|
||||||
|
<div className='absolute inset-0 flex items-center'>
|
||||||
|
<div className='w-full border-t border-gray-300 dark:border-gray-600'></div>
|
||||||
|
</div>
|
||||||
|
<div className='relative flex justify-center text-sm'>
|
||||||
|
<span className='px-2 bg-white/60 dark:bg-zinc-900/60 text-gray-500 dark:text-gray-400'>
|
||||||
|
或
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => window.location.href = '/api/auth/oidc/login'}
|
||||||
|
className='mt-4 w-full inline-flex justify-center items-center rounded-lg border-2 border-gray-300 dark:border-gray-600 bg-white/60 dark:bg-zinc-800/60 backdrop-blur py-3 text-base font-semibold text-gray-700 dark:text-gray-200 shadow-sm transition-all duration-200 hover:bg-gray-50 dark:hover:bg-zinc-700/60'
|
||||||
|
>
|
||||||
|
<svg className='w-5 h-5 mr-2' fill='currentColor' viewBox='0 0 20 20'>
|
||||||
|
<path fillRule='evenodd' d='M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z' clipRule='evenodd' />
|
||||||
|
</svg>
|
||||||
|
使用OIDC登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 版本信息显示 */}
|
{/* 版本信息显示 */}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { CURRENT_VERSION } from '@/lib/version';
|
||||||
|
|
||||||
|
import { useSite } from '@/components/SiteProvider';
|
||||||
|
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||||
|
|
||||||
|
function OIDCRegisterPageClient() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [oidcInfo, setOidcInfo] = useState<any>(null);
|
||||||
|
|
||||||
|
const { siteName } = useSite();
|
||||||
|
|
||||||
|
// 检查OIDC session
|
||||||
|
useEffect(() => {
|
||||||
|
const checkSession = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/oidc/session-info');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setOidcInfo(data);
|
||||||
|
} else {
|
||||||
|
// session无效,跳转到登录页
|
||||||
|
router.replace('/login?error=' + encodeURIComponent('OIDC会话已过期'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查session失败:', error);
|
||||||
|
router.replace('/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkSession();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
setError('请输入用户名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/auth/oidc/complete-register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// 注册成功,跳转到首页
|
||||||
|
router.replace('/');
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setError(data.error || '注册失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError('网络错误,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!oidcInfo) {
|
||||||
|
return (
|
||||||
|
<div className='relative min-h-screen flex items-center justify-center px-4'>
|
||||||
|
<div className='text-gray-500 dark:text-gray-400'>加载中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'>
|
||||||
|
<div className='absolute top-4 right-4'>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 backdrop-blur-xl shadow-2xl p-10 dark:border dark:border-zinc-800'>
|
||||||
|
<h1 className='text-green-600 tracking-tight text-center text-3xl font-extrabold mb-2 bg-clip-text drop-shadow-sm'>
|
||||||
|
{siteName}
|
||||||
|
</h1>
|
||||||
|
<p className='text-center text-sm text-gray-600 dark:text-gray-400 mb-8'>
|
||||||
|
完成OIDC注册
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* OIDC信息显示 */}
|
||||||
|
{oidcInfo && (
|
||||||
|
<div className='mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
|
||||||
|
<p className='text-sm text-blue-700 dark:text-blue-400'>
|
||||||
|
{oidcInfo.email && (
|
||||||
|
<>
|
||||||
|
邮箱: <strong>{oidcInfo.email}</strong>
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{oidcInfo.name && (
|
||||||
|
<>
|
||||||
|
名称: <strong>{oidcInfo.name}</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className='space-y-6'>
|
||||||
|
<div>
|
||||||
|
<label htmlFor='username' className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
选择用户名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id='username'
|
||||||
|
type='text'
|
||||||
|
autoComplete='username'
|
||||||
|
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
|
||||||
|
placeholder='输入用户名(3-20位字母、数字、下划线)'
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
用户名只能包含字母、数字、下划线,长度3-20位
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className='text-sm text-red-600 dark:text-red-400'>{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
disabled={!username || loading}
|
||||||
|
className='inline-flex w-full justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
>
|
||||||
|
{loading ? '注册中...' : '完成注册'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 返回登录链接 */}
|
||||||
|
<div className='text-center'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => router.push('/login')}
|
||||||
|
className='text-sm text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 transition-colors'
|
||||||
|
>
|
||||||
|
返回登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 版本信息 */}
|
||||||
|
<div className='absolute bottom-4 left-1/2 transform -translate-x-1/2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
<span className='font-mono'>v{CURRENT_VERSION}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OIDCRegisterPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<OIDCRegisterPageClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,15 @@ export interface AdminConfig {
|
|||||||
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
|
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
|
||||||
TurnstileSecretKey?: string; // Cloudflare Turnstile Secret Key
|
TurnstileSecretKey?: string; // Cloudflare Turnstile Secret Key
|
||||||
DefaultUserTags?: string[]; // 新注册用户的默认用户组
|
DefaultUserTags?: string[]; // 新注册用户的默认用户组
|
||||||
|
// OIDC配置
|
||||||
|
EnableOIDCLogin?: boolean; // 启用OIDC登录
|
||||||
|
EnableOIDCRegistration?: boolean; // 启用OIDC注册
|
||||||
|
OIDCIssuer?: string; // OIDC Issuer URL (用于自动发现)
|
||||||
|
OIDCAuthorizationEndpoint?: string; // 授权端点
|
||||||
|
OIDCTokenEndpoint?: string; // Token端点
|
||||||
|
OIDCUserInfoEndpoint?: string; // 用户信息端点
|
||||||
|
OIDCClientId?: string; // OIDC Client ID
|
||||||
|
OIDCClientSecret?: string; // OIDC Client Secret
|
||||||
};
|
};
|
||||||
UserConfig: {
|
UserConfig: {
|
||||||
Users: {
|
Users: {
|
||||||
|
|||||||
+1
-1
@@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean {
|
|||||||
// 配置middleware匹配规则
|
// 配置middleware匹配规则
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!_next/static|_next/image|favicon.ico|login|register|warning|api/login|api/register|api/logout|api/cron|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css).*)',
|
'/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/cron|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css).*)',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user