feat: add protected login workspace
This commit is contained in:
@@ -11,3 +11,10 @@ SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
# 生产环境填写公网入口,用于把 /api/img/... 补成 Seedance 可访问的绝对 URL。
|
||||
PUBLIC_APP_URL=
|
||||
|
||||
# Web login. Local Docker can use any private values; production values only live on VPS.
|
||||
WEB_AUTH_USERNAME=
|
||||
WEB_AUTH_PASSWORD=
|
||||
WEB_AUTH_SESSION_SECRET=
|
||||
WEB_AUTH_COOKIE_NAME=ai_toy_session
|
||||
WEB_AUTH_COOKIE_SECURE=false
|
||||
|
||||
@@ -803,6 +803,52 @@
|
||||
"message": "fix: polish regeneration controls",
|
||||
"hash": "254c2c3",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-19T14:45:29+08:00",
|
||||
"type": "commit",
|
||||
"message": "fix: polish regeneration controls",
|
||||
"hash": "aa03bae",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-19T06:50:03Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:fix: polish regeneration controls",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-19T14:56:30+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-19 14:56 (+5, ~1)",
|
||||
"hash": "010cb44",
|
||||
"files_changed": 8
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-19T07:00:03Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 8 项未提交变更 · 最近提交:auto-save 2026-05-19 14:56 (+5, ~1)",
|
||||
"files_changed": 8
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-19T15:01:55+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-19 15:01 (+1, ~8, -1)",
|
||||
"hash": "e723029",
|
||||
"files_changed": 9
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-19T07:10:03Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 2 项未提交变更 · 最近提交:auto-save 2026-05-19 15:01 (+1, ~8, -1)",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-19T15:12:47+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-19 15:12 (~2)",
|
||||
"hash": "d3e29e7",
|
||||
"files_changed": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,12 +5,38 @@
|
||||
"kind": "tool",
|
||||
"created": "2026-05-18",
|
||||
"urls": [
|
||||
{ "type": "app", "url": "https://ai-toy.kang-kang.com", "label": "VPS 生产" },
|
||||
{ "type": "app", "url": "http://localhost:4560", "label": "本地 Docker" }
|
||||
{
|
||||
"type": "app",
|
||||
"url": "https://ai-toy.kang-kang.com",
|
||||
"label": "VPS 生产"
|
||||
},
|
||||
{
|
||||
"type": "app",
|
||||
"url": "https://ai-toy.kang-kang.com/login",
|
||||
"label": "VPS 登录"
|
||||
},
|
||||
{
|
||||
"type": "app",
|
||||
"url": "http://localhost:4560",
|
||||
"label": "本地 Docker"
|
||||
}
|
||||
],
|
||||
"credentials": [
|
||||
{ "name": "OPENAI_API_KEY", "env": "OPENAI_API_KEY", "note": "GPT 文本/结构化/图片生成;没填则图片 mock" },
|
||||
{ "name": "SEEDANCE_API_KEY", "env": "SEEDANCE_API_KEY", "note": "Seedance 视频生成;没填则视频接口不可用" }
|
||||
{
|
||||
"name": "OPENAI_API_KEY",
|
||||
"env": "OPENAI_API_KEY",
|
||||
"note": "GPT 文本/结构化/图片生成;没填则图片 mock"
|
||||
},
|
||||
{
|
||||
"name": "SEEDANCE_API_KEY",
|
||||
"env": "SEEDANCE_API_KEY",
|
||||
"note": "Seedance 视频生成;没填则视频接口不可用"
|
||||
},
|
||||
{
|
||||
"name": "WEB_LOGIN",
|
||||
"env": "WEB_AUTH_USERNAME/WEB_AUTH_PASSWORD/WEB_AUTH_SESSION_SECRET",
|
||||
"note": "网页登录;生产值只放 VPS deploy/.env.production 和 /root/ai-toy-patent-workflow-login.txt"
|
||||
}
|
||||
],
|
||||
"ports": [
|
||||
{
|
||||
@@ -28,5 +54,11 @@
|
||||
"Docker Compose local/prod parity",
|
||||
"Coolify Traefik"
|
||||
],
|
||||
"ownership": "personal"
|
||||
"ownership": "personal",
|
||||
"quick_login": {
|
||||
"label": "AI Toy Patent / 登录",
|
||||
"url": "https://ai-toy.kang-kang.com/login",
|
||||
"username": "kangwan",
|
||||
"password": "22668050fb50f6e95cb5e32c"
|
||||
}
|
||||
}
|
||||
|
||||
10
RULES.md
10
RULES.md
@@ -12,13 +12,17 @@
|
||||
- 服务名 / 容器名:`ai-toy-patent-workflow`
|
||||
- 服务器路径:`/opt/ai-toy-patent-workflow`
|
||||
- 主站 / 前端:https://ai-toy.kang-kang.com
|
||||
- 登录页:https://ai-toy.kang-kang.com/login
|
||||
- 本地 Docker:http://localhost:4560
|
||||
- API / 后端:内置 Next.js API Route(生产同域名)
|
||||
- 文档 / 解析:无
|
||||
- 管理后台:无
|
||||
|
||||
## 快捷登录
|
||||
- 无登录系统(本地工具)
|
||||
- 已启用应用内登录页
|
||||
- 登录地址:`https://ai-toy.kang-kang.com/login`
|
||||
- 用户名 / 密码:见 `.project.json.quick_login`;生产备份在 VPS `/root/ai-toy-patent-workflow-login.txt`
|
||||
- 会话:HttpOnly HMAC Cookie,生产变量只放 VPS `deploy/.env.production`
|
||||
|
||||
## 元数据回写清单
|
||||
- 改公网域名或迁移部署时,更新 `.project.json.urls` + 本节
|
||||
@@ -39,9 +43,13 @@
|
||||
- `SEEDANCE_MODEL` — 默认 `doubao-seedance-2-0-260128`
|
||||
- `SEEDANCE_API_BASE` — 默认 `https://ark.cn-beijing.volces.com/api/v3`
|
||||
- `PUBLIC_APP_URL` — 生产填公网入口,用于把 `/api/img/...` 补成 Seedance 可访问的绝对 URL
|
||||
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` — 网页登录;真实值只放 `.env.local` 和 VPS `deploy/.env.production`
|
||||
- `WEB_AUTH_COOKIE_NAME` — 默认 `ai_toy_session`
|
||||
- `WEB_AUTH_COOKIE_SECURE` — 本地 `false`,生产 `true`
|
||||
- 配置位置:`.env.local`(gitignored),参考 `.env.local.example`
|
||||
- 生产配置模板:`deploy/.env.production.example`;真实生产值只放 VPS 的 `/opt/ai-toy-patent-workflow/deploy/.env.production`
|
||||
- 图片生成未配置 GPT Key 时回退 mock(SVG 占位图),视频生成不 mock,必须配置 Seedance Key
|
||||
- 除 `/login`、`/api/auth/*` 和 `/api/img/*` 外,页面与 API 都需要登录;`/api/img/*` 保持公开是为了 Seedance 能从公网读取参考图
|
||||
|
||||
## 规则
|
||||
- 全项目规则真源:`/Users/kangwan/Projects/code/20260317-rules-dashboard/RULES.md`
|
||||
|
||||
@@ -8,3 +8,10 @@ GPT_API_BASE=https://api.openai.com/v1
|
||||
SEEDANCE_API_KEY=
|
||||
SEEDANCE_MODEL=doubao-seedance-2-0-260128
|
||||
SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
# Web login. Keep real values only on the VPS at deploy/.env.production.
|
||||
WEB_AUTH_USERNAME=
|
||||
WEB_AUTH_PASSWORD=
|
||||
WEB_AUTH_SESSION_SECRET=
|
||||
WEB_AUTH_COOKIE_NAME=ai_toy_session
|
||||
WEB_AUTH_COOKIE_SECURE=true
|
||||
|
||||
17
src/app/api/auth/check/route.ts
Normal file
17
src/app/api/auth/check/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { AUTH_COOKIE_NAME, verifyAuthToken } from '@/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const cookie = req.headers.get('cookie') || '';
|
||||
const token = cookie
|
||||
.split(';')
|
||||
.map(part => part.trim())
|
||||
.find(part => part.startsWith(`${AUTH_COOKIE_NAME}=`))
|
||||
?.slice(AUTH_COOKIE_NAME.length + 1);
|
||||
const username = verifyAuthToken(token ? decodeURIComponent(token) : '');
|
||||
if (!username) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ ok: true, username });
|
||||
}
|
||||
37
src/app/api/auth/login/route.ts
Normal file
37
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { AUTH_COOKIE_NAME, authCookieSecure, configuredUsername, isAuthConfigured, makeAuthToken, validateCredentials } from '@/lib/auth';
|
||||
import { recordEvent } from '@/lib/auditDb';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!isAuthConfigured()) {
|
||||
recordEvent({ action: 'auth.login_unconfigured', targetType: 'auth', status: 'error', message: 'WEB_AUTH not configured' });
|
||||
return NextResponse.json({ error: 'WEB_AUTH not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({})) as { username?: unknown; password?: unknown; remember?: unknown };
|
||||
const username = String(body.username || '');
|
||||
const password = String(body.password || '');
|
||||
const remember = body.remember !== false;
|
||||
|
||||
if (!validateCredentials(username, password)) {
|
||||
recordEvent({ action: 'auth.login_failed', targetType: 'auth', targetId: username.trim() || null, status: 'error' });
|
||||
return NextResponse.json({ error: '用户名或密码不正确' }, { status: 401 });
|
||||
}
|
||||
|
||||
const ttlSeconds = remember ? 60 * 60 * 24 * 30 : 60 * 60 * 12;
|
||||
const response = NextResponse.json({ ok: true, username: configuredUsername() });
|
||||
response.cookies.set({
|
||||
name: AUTH_COOKIE_NAME,
|
||||
value: makeAuthToken(configuredUsername(), ttlSeconds),
|
||||
maxAge: ttlSeconds,
|
||||
httpOnly: true,
|
||||
secure: authCookieSecure(),
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
recordEvent({ action: 'auth.login_ok', targetType: 'auth', targetId: configuredUsername(), status: 'ok', metadata: { remember } });
|
||||
return response;
|
||||
}
|
||||
21
src/app/api/auth/logout/route.ts
Normal file
21
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { AUTH_COOKIE_NAME, authCookieSecure } from '@/lib/auth';
|
||||
import { recordEvent } from '@/lib/auditDb';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.set({
|
||||
name: AUTH_COOKIE_NAME,
|
||||
value: '',
|
||||
maxAge: 0,
|
||||
httpOnly: true,
|
||||
secure: authCookieSecure(),
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
recordEvent({ action: 'auth.logout', targetType: 'auth', status: 'ok' });
|
||||
return response;
|
||||
}
|
||||
@@ -139,6 +139,449 @@ input, textarea {
|
||||
@apply inline-block text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-300/80;
|
||||
}
|
||||
|
||||
/* ===== Login ===== */
|
||||
.login-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(10, 10, 15, 0.96), rgba(18, 18, 28, 0.98)),
|
||||
linear-gradient(90deg, rgba(139, 92, 246, 0.12), rgba(59, 130, 246, 0.08));
|
||||
color: #fff;
|
||||
}
|
||||
.login-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px),
|
||||
linear-gradient(135deg, rgba(139,92,246,0.18), transparent 48%, rgba(59,130,246,0.14));
|
||||
background-size: 42px 42px, 42px 42px, auto;
|
||||
mask-image: linear-gradient(180deg, rgba(0,0,0,0.92), rgba(0,0,0,0.5));
|
||||
}
|
||||
.login-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(360px, 430px);
|
||||
align-items: center;
|
||||
gap: 56px;
|
||||
width: min(1120px, calc(100vw - 48px));
|
||||
margin: 0 auto;
|
||||
padding: 42px 0;
|
||||
}
|
||||
.login-showcase {
|
||||
min-width: 0;
|
||||
}
|
||||
.login-brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: rgba(255,255,255,0.76);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.login-showcase h1 {
|
||||
margin: 22px 0 10px;
|
||||
font-size: clamp(38px, 6vw, 72px);
|
||||
line-height: 1;
|
||||
font-weight: 760;
|
||||
}
|
||||
.login-showcase p {
|
||||
max-width: 520px;
|
||||
margin: 0;
|
||||
color: rgba(255,255,255,0.52);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.login-character-strip {
|
||||
margin-top: 34px;
|
||||
width: min(620px, 100%);
|
||||
height: 300px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.035)),
|
||||
rgba(5, 5, 10, 0.45);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 24px 70px rgba(0,0,0,0.32);
|
||||
overflow: hidden;
|
||||
}
|
||||
.login-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(9, 9, 14, 0.76);
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: 0 24px 90px rgba(0,0,0,0.44), inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
}
|
||||
.login-panel__eyebrow {
|
||||
color: rgba(167,139,250,0.86);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.login-panel h2 {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 720;
|
||||
}
|
||||
.login-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
height: 46px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.055);
|
||||
color: rgba(255,255,255,0.48);
|
||||
transition: border-color .18s ease, background .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
.login-field:focus-within {
|
||||
border-color: rgba(167,139,250,0.62);
|
||||
background: rgba(255,255,255,0.08);
|
||||
box-shadow: 0 0 0 4px rgba(139,92,246,0.16);
|
||||
}
|
||||
.login-field input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
}
|
||||
.login-field input::placeholder {
|
||||
color: rgba(255,255,255,0.3);
|
||||
}
|
||||
.login-icon-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: rgba(255,255,255,0.55);
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-icon-button:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #fff;
|
||||
}
|
||||
.login-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 24px;
|
||||
color: rgba(255,255,255,0.58);
|
||||
font-size: 13px;
|
||||
}
|
||||
.login-options label,
|
||||
.login-success {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.login-options input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: #8b5cf6;
|
||||
}
|
||||
.login-success {
|
||||
color: #86efac;
|
||||
}
|
||||
.login-message {
|
||||
min-height: 20px;
|
||||
color: #fca5a5;
|
||||
font-size: 13px;
|
||||
}
|
||||
.login-submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 46px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #08080c;
|
||||
font-size: 15px;
|
||||
font-weight: 720;
|
||||
cursor: pointer;
|
||||
transition: transform .18s ease, background .18s ease, opacity .18s ease;
|
||||
}
|
||||
.login-submit:hover {
|
||||
background: #f1edff;
|
||||
}
|
||||
.login-submit:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.login-submit:disabled {
|
||||
cursor: wait;
|
||||
opacity: .72;
|
||||
}
|
||||
.login-character-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.login-stage-grid {
|
||||
position: absolute;
|
||||
inset: 22px;
|
||||
border: 1px dashed rgba(255,255,255,0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.login-characters-container {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 50%;
|
||||
bottom: -8px;
|
||||
width: 550px;
|
||||
height: 400px;
|
||||
transform: translateX(-50%) scale(0.66);
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
.login-character-base {
|
||||
position: absolute;
|
||||
left: 28px;
|
||||
right: 18px;
|
||||
bottom: -12px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(ellipse at center, rgba(0,0,0,0.52), rgba(0,0,0,0) 72%);
|
||||
filter: blur(1px);
|
||||
}
|
||||
.login-figure {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
transform-origin: bottom center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
transition: transform .7s cubic-bezier(.4,0,.2,1), height .55s cubic-bezier(.4,0,.2,1);
|
||||
filter: drop-shadow(0 34px 32px rgba(0,0,0,0.42));
|
||||
}
|
||||
.login-figure::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background: radial-gradient(circle at 30% 18%, rgba(255,255,255,0.34), transparent 24%), linear-gradient(90deg, rgba(255,255,255,0.18), transparent 26%, transparent 62%, rgba(0,0,0,0.3));
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
.login-figure::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background: linear-gradient(105deg, rgba(255,255,255,0.2), transparent 30%, rgba(0,0,0,0.26));
|
||||
box-shadow: inset 12px 0 22px rgba(255,255,255,0.09), inset -18px 0 28px rgba(0,0,0,0.22), inset 0 -28px 36px rgba(0,0,0,0.18);
|
||||
}
|
||||
.login-figure__rim {
|
||||
position: absolute;
|
||||
inset: 8px;
|
||||
z-index: 1;
|
||||
border: 1px solid rgba(255,255,255,0.16);
|
||||
border-radius: inherit;
|
||||
}
|
||||
.login-figure__shine {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 18%;
|
||||
top: 18%;
|
||||
width: 42%;
|
||||
height: 11%;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.22);
|
||||
transform: rotate(-14deg);
|
||||
}
|
||||
.login-figure__panel {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
border: 1px solid rgba(255,255,255,0.13);
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.login-figure__signal {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.login-figure__signal span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.56);
|
||||
}
|
||||
.login-figure--purple {
|
||||
left: 72px;
|
||||
width: 176px;
|
||||
height: 392px;
|
||||
z-index: 1;
|
||||
background: linear-gradient(165deg, #9b7bff 0%, #6c3ff5 46%, #3e1eb8 100%);
|
||||
border-radius: 22px 22px 8px 8px;
|
||||
}
|
||||
.login-figure--black {
|
||||
left: 240px;
|
||||
width: 120px;
|
||||
height: 310px;
|
||||
z-index: 2;
|
||||
background: linear-gradient(165deg, #34343a 0%, #202126 48%, #111217 100%);
|
||||
border-radius: 18px 18px 6px 6px;
|
||||
}
|
||||
.login-figure--orange {
|
||||
left: 0;
|
||||
width: 240px;
|
||||
height: 150px;
|
||||
z-index: 3;
|
||||
background: linear-gradient(160deg, #ffb27f 0%, #ff8f61 56%, #dc6f48 100%);
|
||||
border-radius: 120px 120px 0 0;
|
||||
}
|
||||
.login-figure--yellow {
|
||||
left: 310px;
|
||||
width: 140px;
|
||||
height: 230px;
|
||||
z-index: 4;
|
||||
background: linear-gradient(160deg, #fff17a 0%, #e8d754 58%, #bfa73a 100%);
|
||||
border-radius: 70px 70px 0 0;
|
||||
}
|
||||
.login-figure--purple .login-figure__panel { left: 22px; bottom: 88px; width: 36px; height: 152px; border-radius: 999px; }
|
||||
.login-figure--purple .login-figure__signal { left: 26px; bottom: 46px; }
|
||||
.login-figure--black .login-figure__panel { right: 16px; bottom: 40px; width: 12px; height: 190px; border-radius: 999px; }
|
||||
.login-figure--black .login-figure__signal { right: 18px; top: 88px; flex-direction: column; }
|
||||
.login-figure--orange .login-figure__panel { left: 42px; bottom: 24px; width: 88px; height: 18px; border-radius: 999px; }
|
||||
.login-figure--orange .login-figure__signal { right: 54px; bottom: 28px; }
|
||||
.login-figure--yellow .login-figure__panel { left: 24px; bottom: 44px; width: 92px; height: 24px; border-radius: 999px; background: rgba(45,45,45,0.08); }
|
||||
.login-figure--yellow .login-figure__signal { left: 34px; top: 124px; }
|
||||
.login-figure--yellow .login-figure__signal span,
|
||||
.login-figure--orange .login-figure__signal span { background: rgba(45,45,45,0.36); }
|
||||
.login-eyes {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
transition: left .35s cubic-bezier(.4,0,.2,1), top .35s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.login-eyes--purple { left: 75px; top: 25px; gap: 32px; }
|
||||
.login-eyes--black { left: 26px; top: 32px; gap: 24px; }
|
||||
.login-eyes--orange { left: 112px; top: 60px; gap: 32px; }
|
||||
.login-eyes--yellow { left: 52px; top: 40px; gap: 24px; }
|
||||
.login-eye {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
.login-eye--small { width: 20px; height: 20px; }
|
||||
.login-eye::after,
|
||||
.login-pupil::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #2d2d2d;
|
||||
transform: translate(var(--eye-x), var(--eye-y));
|
||||
transition: transform .08s linear, opacity .2s ease;
|
||||
}
|
||||
.login-eye::after { margin: -4px 0 0 -4px; }
|
||||
.login-pupil {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.login-pupil::after {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -6px 0 0 -6px;
|
||||
}
|
||||
.login-mouth {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
background: #2d2d2d;
|
||||
transition: width .35s cubic-bezier(.4,0,.2,1), height .35s cubic-bezier(.4,0,.2,1), border-radius .35s cubic-bezier(.4,0,.2,1), transform .35s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.login-mouth--purple { left: 97px; top: 57px; width: 24px; height: 8px; border-radius: 0 0 12px 12px; }
|
||||
.login-mouth--orange { left: 126px; top: 92px; width: 26px; height: 13px; border-radius: 0 0 13px 13px; }
|
||||
.login-yellow-mouth {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 40px;
|
||||
top: 88px;
|
||||
}
|
||||
.login-yellow-mouth path {
|
||||
stroke: #2d2d2d;
|
||||
stroke-width: 3;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.login-character-stage[data-mood="typing"] .login-figure--purple { height: 430px; transform: skewX(-10deg) translateX(36px); }
|
||||
.login-character-stage[data-mood="typing"] .login-figure--black { transform: skewX(7deg) translateX(6px); }
|
||||
.login-character-stage[data-mood="typing"] .login-figure--orange { transform: skewX(-5deg); }
|
||||
.login-character-stage[data-mood="typing"] .login-figure--yellow { transform: skewX(4deg); }
|
||||
.login-character-stage[data-mood="typing"] .login-mouth--purple { width: 7px; height: 32px; border-radius: 0; transform: translate(14px, -28px) skewX(10deg); }
|
||||
.login-character-stage[data-mood="typing"] .login-mouth--orange { width: 14px; height: 14px; border-radius: 50%; transform: translateX(6px); }
|
||||
.login-character-stage[data-mood="peek"] .login-figure--purple,
|
||||
.login-character-stage[data-mood="peek"] .login-figure--black { transform: skewX(0deg) translateY(-10px); }
|
||||
.login-character-stage[data-mood="peek"] .login-eyes--purple { left: 40px; top: 14px; }
|
||||
.login-character-stage[data-mood="peek"] .login-eyes--black { left: 2px; top: 20px; }
|
||||
.login-character-stage[data-mood="peek"] .login-eyes--orange { left: 68px; top: 48px; }
|
||||
.login-character-stage[data-mood="peek"] .login-eyes--yellow { left: 10px; top: 28px; }
|
||||
.login-character-stage[data-mood="peek"] .login-eye::after,
|
||||
.login-character-stage[data-mood="peek"] .login-pupil::after { transform: translate(-8px, -6px); }
|
||||
.login-character-stage[data-mood="error"] .login-characters-container { animation: login-shake .28s ease-in-out 2; }
|
||||
.login-character-stage[data-mood="error"] .login-eye { height: 10px; margin-top: 4px; }
|
||||
.login-character-stage[data-mood="error"] .login-mouth--purple,
|
||||
.login-character-stage[data-mood="error"] .login-mouth--orange { border-radius: 12px 12px 0 0; transform: translateY(10px); }
|
||||
.login-character-stage[data-mood="success"] .login-characters-container { animation: login-stage-success .75s cubic-bezier(.34,1.56,.64,1) both; }
|
||||
.login-character-stage[data-mood="success"] .login-mouth--purple { width: 30px; height: 16px; border-radius: 0 0 15px 15px; }
|
||||
.login-character-stage[data-mood="success"] .login-mouth--orange { width: 32px; height: 18px; border-radius: 0 0 16px 16px; }
|
||||
@keyframes login-shake {
|
||||
0%, 100% { translate: 0 0; }
|
||||
33% { translate: -5px 0; }
|
||||
66% { translate: 5px 0; }
|
||||
}
|
||||
@keyframes login-stage-success {
|
||||
0% { translate: 0 0; }
|
||||
45% { translate: 0 -22px; }
|
||||
100% { translate: 0 -8px; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.login-shell {
|
||||
width: min(100vw - 28px, 460px);
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
align-content: center;
|
||||
}
|
||||
.login-showcase h1 {
|
||||
font-size: 40px;
|
||||
}
|
||||
.login-showcase p {
|
||||
font-size: 13px;
|
||||
}
|
||||
.login-character-strip {
|
||||
height: 210px;
|
||||
}
|
||||
.login-characters-container {
|
||||
transform: translateX(-50%) scale(0.46);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Divider ===== */
|
||||
.divider-line {
|
||||
@apply h-px w-full;
|
||||
|
||||
170
src/app/login/page.tsx
Normal file
170
src/app/login/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import type { FormEvent } from 'react';
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AnimatedLoginCharacters, type LoginCharacterMood } from '@/components/login/AnimatedLoginCharacters';
|
||||
|
||||
type LoginStatus = 'idle' | 'loading' | 'success';
|
||||
|
||||
function Icon({ type }: { type: 'user' | 'lock' | 'eye' | 'eyeOff' | 'arrow' | 'check' }) {
|
||||
const common = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
|
||||
if (type === 'user') return <svg {...common}><path d="M20 21a8 8 0 0 0-16 0" /><circle cx="12" cy="7" r="4" /></svg>;
|
||||
if (type === 'lock') return <svg {...common}><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></svg>;
|
||||
if (type === 'eye') return <svg {...common}><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" /></svg>;
|
||||
if (type === 'eyeOff') return <svg {...common}><path d="m3 3 18 18" /><path d="M10.6 10.6A3 3 0 0 0 13.4 13.4" /><path d="M9.9 5.2A10.7 10.7 0 0 1 12 5c6.5 0 10 7 10 7a18 18 0 0 1-3.2 4.3" /><path d="M6.4 6.5C3.5 8.3 2 12 2 12s3.5 7 10 7c1.3 0 2.5-.3 3.6-.7" /></svg>;
|
||||
if (type === 'check') return <svg {...common}><path d="M20 6 9 17l-5-5" /></svg>;
|
||||
return <svg {...common}><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></svg>;
|
||||
}
|
||||
|
||||
function LoginInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [activeField, setActiveField] = useState<'username' | 'password' | null>(null);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [status, setStatus] = useState<LoginStatus>('idle');
|
||||
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
const centerX = window.innerWidth / 2;
|
||||
const centerY = window.innerHeight / 2;
|
||||
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX));
|
||||
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY));
|
||||
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 });
|
||||
};
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
return () => window.removeEventListener('pointermove', onPointerMove);
|
||||
}, []);
|
||||
|
||||
const disabled = status === 'loading' || status === 'success';
|
||||
const mood: LoginCharacterMood = useMemo(() => {
|
||||
if (status === 'success') return 'success';
|
||||
if (hasError) return 'error';
|
||||
if (showPassword && activeField === 'password') return 'peek';
|
||||
if (activeField || username || password) return 'typing';
|
||||
return 'idle';
|
||||
}, [activeField, hasError, password, showPassword, status, username]);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setHasError(false);
|
||||
setMessage('');
|
||||
if (!username.trim() || !password) {
|
||||
setHasError(true);
|
||||
setMessage('账号和密码都要填。');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('loading');
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, remember }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
setStatus('success');
|
||||
const next = searchParams.get('next') || '/';
|
||||
window.setTimeout(() => { window.location.href = next.startsWith('/') ? next : '/'; }, 350);
|
||||
} catch {
|
||||
setStatus('idle');
|
||||
setHasError(true);
|
||||
setMessage('账号或密码不对。');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<div className="login-backdrop" />
|
||||
<section className="login-shell">
|
||||
<div className="login-showcase">
|
||||
<div className="login-brand-mark">AI Toy</div>
|
||||
<h1>AI Toy Patent</h1>
|
||||
<p>进入玩具 IP 生成工作台,管理上传、生成、图库和操作记录。</p>
|
||||
<div className="login-character-strip">
|
||||
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="login-panel" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<div className="login-panel__eyebrow">Private Workspace</div>
|
||||
<h2>登录工作台</h2>
|
||||
</div>
|
||||
|
||||
<label className="login-field">
|
||||
<span><Icon type="user" /></span>
|
||||
<input
|
||||
value={username}
|
||||
disabled={disabled}
|
||||
autoComplete="username"
|
||||
placeholder="账号"
|
||||
onFocus={() => setActiveField('username')}
|
||||
onBlur={() => setActiveField(null)}
|
||||
onChange={event => {
|
||||
setUsername(event.target.value);
|
||||
if (hasError) setHasError(false);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="login-field">
|
||||
<span><Icon type="lock" /></span>
|
||||
<input
|
||||
value={password}
|
||||
disabled={disabled}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
placeholder="密码"
|
||||
onFocus={() => setActiveField('password')}
|
||||
onBlur={() => setActiveField(null)}
|
||||
onChange={event => {
|
||||
setPassword(event.target.value);
|
||||
if (hasError) setHasError(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-icon-button"
|
||||
disabled={disabled}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => setShowPassword(value => !value)}
|
||||
aria-label={showPassword ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
<Icon type={showPassword ? 'eyeOff' : 'eye'} />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<div className="login-options">
|
||||
<label>
|
||||
<input type="checkbox" checked={remember} disabled={disabled} onChange={event => setRemember(event.target.checked)} />
|
||||
<span>保持登录</span>
|
||||
</label>
|
||||
{status === 'success' ? <span className="login-success"><Icon type="check" /> 已通过</span> : null}
|
||||
</div>
|
||||
|
||||
{message ? <div className="login-message">{message}</div> : null}
|
||||
|
||||
<button className="login-submit" type="submit" disabled={disabled}>
|
||||
<Icon type="arrow" />
|
||||
{status === 'loading' ? '验证中' : '进入'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -271,6 +271,11 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen text-white">
|
||||
<Sidebar
|
||||
@@ -319,6 +324,12 @@ export default function Home() {
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="chip chip-neutral hover:border-violet-300/40 hover:text-white transition-colors"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
<span className={provider === 'gpt' ? 'chip chip-live' : provider === '?' ? 'chip chip-neutral' : 'chip chip-mock'}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${provider === 'gpt' ? 'bg-emerald-400' : provider === '?' ? 'bg-white/40' : 'bg-amber-400'}`} />
|
||||
{provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider}
|
||||
|
||||
84
src/components/login/AnimatedLoginCharacters.tsx
Normal file
84
src/components/login/AnimatedLoginCharacters.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export type LoginCharacterMood = 'idle' | 'typing' | 'peek' | 'error' | 'success';
|
||||
|
||||
type EyeKind = 'eye' | 'small-eye' | 'pupil';
|
||||
|
||||
type FigureSpec = {
|
||||
id: 'purple' | 'black' | 'orange' | 'yellow';
|
||||
eyeKind: EyeKind;
|
||||
mouth?: 'purple' | 'orange' | 'yellow';
|
||||
marks?: number;
|
||||
};
|
||||
|
||||
const FIGURES: FigureSpec[] = [
|
||||
{ id: 'purple', eyeKind: 'eye', mouth: 'purple', marks: 3 },
|
||||
{ id: 'black', eyeKind: 'small-eye', marks: 2 },
|
||||
{ id: 'orange', eyeKind: 'pupil', mouth: 'orange', marks: 2 },
|
||||
{ id: 'yellow', eyeKind: 'pupil', mouth: 'yellow', marks: 3 },
|
||||
];
|
||||
|
||||
function Eyes({ figure, kind }: { figure: FigureSpec['id']; kind: EyeKind }) {
|
||||
const className = kind === 'pupil' ? 'login-pupil' : kind === 'small-eye' ? 'login-eye login-eye--small' : 'login-eye';
|
||||
|
||||
return (
|
||||
<span className={`login-eyes login-eyes--${figure}`}>
|
||||
<span className={className} />
|
||||
<span className={className} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Mouth({ type }: { type: NonNullable<FigureSpec['mouth']> }) {
|
||||
if (type === 'yellow') {
|
||||
return (
|
||||
<span className="login-yellow-mouth">
|
||||
<svg width="80" height="20" viewBox="0 0 80 20" aria-hidden="true">
|
||||
<path d="M0 10 Q10 10, 20 10 Q30 10, 40 10 Q50 10, 60 10 Q70 10, 80 10" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className={`login-mouth login-mouth--${type}`} />;
|
||||
}
|
||||
|
||||
function Figure({ spec }: { spec: FigureSpec }) {
|
||||
return (
|
||||
<div className={`login-figure login-figure--${spec.id}`}>
|
||||
<span className="login-figure__rim" />
|
||||
<span className="login-figure__shine" />
|
||||
<span className="login-figure__panel" />
|
||||
<span className="login-figure__signal">
|
||||
{Array.from({ length: spec.marks ?? 0 }).map((_, index) => <span key={index} />)}
|
||||
</span>
|
||||
<Eyes figure={spec.id} kind={spec.eyeKind} />
|
||||
{spec.mouth ? <Mouth type={spec.mouth} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimatedLoginCharacters({
|
||||
mood,
|
||||
eyeOffset,
|
||||
}: {
|
||||
mood: LoginCharacterMood;
|
||||
eyeOffset: { x: number; y: number };
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="login-character-stage"
|
||||
data-mood={mood}
|
||||
aria-hidden="true"
|
||||
style={{ '--eye-x': `${eyeOffset.x}px`, '--eye-y': `${eyeOffset.y}px` } as CSSProperties}
|
||||
>
|
||||
<div className="login-stage-grid" />
|
||||
<div className="login-characters-container">
|
||||
<div className="login-character-base" />
|
||||
{FIGURES.map(spec => <Figure key={spec.id} spec={spec} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/lib/auth.ts
Normal file
80
src/lib/auth.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export const AUTH_COOKIE_NAME = process.env.WEB_AUTH_COOKIE_NAME?.trim() || 'ai_toy_session';
|
||||
|
||||
function authUsername() {
|
||||
return process.env.WEB_AUTH_USERNAME?.trim() || '';
|
||||
}
|
||||
|
||||
function authPassword() {
|
||||
return process.env.WEB_AUTH_PASSWORD || '';
|
||||
}
|
||||
|
||||
function authSecret() {
|
||||
return process.env.WEB_AUTH_SESSION_SECRET || '';
|
||||
}
|
||||
|
||||
export function isAuthConfigured() {
|
||||
return Boolean(authUsername() && authPassword() && authSecret());
|
||||
}
|
||||
|
||||
export function authCookieSecure() {
|
||||
const value = process.env.WEB_AUTH_COOKIE_SECURE?.trim().toLowerCase();
|
||||
if (value) return !['0', 'false', 'no'].includes(value);
|
||||
const publicUrl = process.env.PUBLIC_APP_URL || process.env.NEXT_PUBLIC_APP_URL || '';
|
||||
return publicUrl.startsWith('https://');
|
||||
}
|
||||
|
||||
function base64UrlEncode(input: string) {
|
||||
return Buffer.from(input, 'utf-8').toString('base64url');
|
||||
}
|
||||
|
||||
function base64UrlDecode(input: string) {
|
||||
return Buffer.from(input, 'base64url').toString('utf-8');
|
||||
}
|
||||
|
||||
function sign(body: string) {
|
||||
return createHmac('sha256', authSecret()).update(body).digest('hex');
|
||||
}
|
||||
|
||||
function safeCompare(a: string, b: string) {
|
||||
const left = Buffer.from(a);
|
||||
const right = Buffer.from(b);
|
||||
return left.length === right.length && timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
export function makeAuthToken(username: string, ttlSeconds: number) {
|
||||
if (!isAuthConfigured()) throw new Error('WEB_AUTH not configured');
|
||||
const body = base64UrlEncode(JSON.stringify({
|
||||
u: username,
|
||||
exp: Math.floor(Date.now() / 1000) + ttlSeconds,
|
||||
n: randomUUID(),
|
||||
}));
|
||||
return `${body}.${sign(body)}`;
|
||||
}
|
||||
|
||||
export function verifyAuthToken(token?: string | null): string | null {
|
||||
if (!isAuthConfigured() || !token || !token.includes('.')) return null;
|
||||
const [body, suppliedSig] = token.split('.', 2);
|
||||
if (!body || !suppliedSig || !safeCompare(sign(body), suppliedSig)) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(base64UrlDecode(body)) as { u?: unknown; exp?: unknown };
|
||||
const username = String(payload.u || '');
|
||||
const expiresAt = Number(payload.exp || 0);
|
||||
if (username !== authUsername() || !Number.isFinite(expiresAt)) return null;
|
||||
if (expiresAt < Math.floor(Date.now() / 1000)) return null;
|
||||
return username;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCredentials(username: string, password: string) {
|
||||
if (!isAuthConfigured()) return false;
|
||||
return safeCompare(username.trim(), authUsername()) && safeCompare(password, authPassword());
|
||||
}
|
||||
|
||||
export function configuredUsername() {
|
||||
return authUsername();
|
||||
}
|
||||
95
src/middleware.ts
Normal file
95
src/middleware.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
|
||||
const COOKIE_NAME = process.env.WEB_AUTH_COOKIE_NAME?.trim() || 'ai_toy_session';
|
||||
|
||||
function isPublicPath(pathname: string) {
|
||||
return (
|
||||
pathname === '/login' ||
|
||||
pathname.startsWith('/login/') ||
|
||||
pathname.startsWith('/_next/') ||
|
||||
pathname.startsWith('/api/auth/') ||
|
||||
pathname.startsWith('/api/img/') ||
|
||||
pathname === '/favicon.ico' ||
|
||||
pathname === '/robots.txt' ||
|
||||
pathname === '/sitemap.xml'
|
||||
);
|
||||
}
|
||||
|
||||
function isBrowserHtml(pathname: string) {
|
||||
return !pathname.startsWith('/api/') || pathname.startsWith('/api/gallery/') || pathname.startsWith('/api/audit/');
|
||||
}
|
||||
|
||||
function authConfigured() {
|
||||
return Boolean(process.env.WEB_AUTH_USERNAME && process.env.WEB_AUTH_SESSION_SECRET);
|
||||
}
|
||||
|
||||
function bytesToHex(buffer: ArrayBuffer) {
|
||||
return [...new Uint8Array(buffer)].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string) {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
async function sign(body: string) {
|
||||
const secret = process.env.WEB_AUTH_SESSION_SECRET || '';
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
return bytesToHex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body)));
|
||||
}
|
||||
|
||||
function decodePayload(body: string) {
|
||||
const base64 = body.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
||||
return JSON.parse(atob(padded)) as { u?: unknown; exp?: unknown };
|
||||
}
|
||||
|
||||
async function verifyToken(token?: string) {
|
||||
if (!authConfigured() || !token || !token.includes('.')) return false;
|
||||
const [body, suppliedSig] = token.split('.', 2);
|
||||
if (!body || !suppliedSig || !safeEqual(await sign(body), suppliedSig)) return false;
|
||||
try {
|
||||
const payload = decodePayload(body);
|
||||
const username = String(payload.u || '');
|
||||
const expiresAt = Number(payload.exp || 0);
|
||||
return username === process.env.WEB_AUTH_USERNAME && expiresAt >= Math.floor(Date.now() / 1000);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const { pathname, search } = req.nextUrl;
|
||||
const authed = await verifyToken(req.cookies.get(COOKIE_NAME)?.value);
|
||||
|
||||
if (pathname === '/login' || pathname.startsWith('/login/')) {
|
||||
if (authed) {
|
||||
const next = req.nextUrl.searchParams.get('next') || '/';
|
||||
return NextResponse.redirect(new URL(next.startsWith('/') ? next : '/', req.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (isPublicPath(pathname)) return NextResponse.next();
|
||||
if (authed) return NextResponse.next();
|
||||
|
||||
if (isBrowserHtml(pathname)) {
|
||||
const loginUrl = new URL('/login', req.url);
|
||||
loginUrl.searchParams.set('next', `${pathname}${search}`);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!.*\\.).*)', '/api/:path*'],
|
||||
};
|
||||
Reference in New Issue
Block a user