feat(auth): P0 登录与会话(迁移 v16、argon2id、HttpOnly Cookie)

- 迁移机制:schema::migrate 支持 15→16,新增 admin/session/setting 表
- 接口:session/setup/login/logout/password;SameSite=Strict + Origin 校验 + 失败限流
- 会话只存 token 的 sha256;改密清空全部会话
- 前端:/login 独立壳(首次=设置密码),页头登录/退出切换
- 测试:37 passed(迁移 + 登录/会话 4 例)
- 文档:README 登录说明、docs/ADMIN-PLAN.md 方案与 P0 记录
This commit is contained in:
stash
2026-10-29 12:41:03 +08:00
parent 1aac8bcbe7
commit dea7781b86
14 changed files with 1222 additions and 46 deletions
+32
View File
@@ -773,6 +773,38 @@
white-space: nowrap;
}
/* ---------- 登录页 ---------- */
.login-page {
display: grid;
place-items: center;
min-height: 100vh;
min-height: 100dvh;
padding: var(--su-space-6);
background-color: var(--su-bg-page);
}
.login-card {
display: flex;
flex-direction: column;
gap: var(--su-space-4);
width: min(380px, 100%);
}
.login-brand {
align-items: center;
}
.login-back {
font-size: var(--su-font-size-sm);
color: var(--su-text-muted);
text-align: center;
}
.login-back:hover {
color: var(--su-color-primary);
}
/* ---------- 响应式 ---------- */
@media (max-width: 1100px) {
+21
View File
@@ -28,6 +28,7 @@ import {
import { useProjects } from './useProjects'
import { useCatalog } from './useCatalog'
import { useViewRoute } from './useViewRoute'
import { fetchSession, logout as logoutRequest, type SessionState } from './auth'
import { parseViewPath, viewPath, type View } from './data/view'
import './App.css'
@@ -38,9 +39,21 @@ export default function App() {
const [keyword, setKeyword] = useState('')
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
const { view, go } = useViewRoute()
const [session, setSession] = useState<SessionState | null>(null)
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
// 登录态:只用来切换页头按钮与后续的编辑入口
useEffect(() => {
let alive = true
fetchSession().then((next) => {
if (alive) setSession(next)
})
return () => {
alive = false
}
}, [])
const categories = useMemo(
() => withLocalCategory(projects, catalog.categories),
[projects, catalog.categories],
@@ -244,6 +257,10 @@ export default function App() {
)
}
const handleLogout = () => {
void logoutRequest().then(() => window.location.assign('/'))
}
return (
<>
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
@@ -270,7 +287,11 @@ export default function App() {
projectCount={projects.length}
sampleProject={projects[0]?.name}
live={live}
authenticated={session?.authenticated === true}
username={session?.username ?? undefined}
onFocusSearch={() => searchRef.current?.focus()}
onLogin={() => window.location.assign('/login')}
onLogout={handleLogout}
/>
}
sider={
+64
View File
@@ -0,0 +1,64 @@
/**
* 登录/会话的接口封装。
*
* 会话是 HttpOnly Cookie,前端拿不到 token,只能问 `/api/auth/session`。
*/
export type SessionState = {
authenticated: boolean
has_admin: boolean
username?: string | null
}
type Result = { ok: true } | { ok: false; status: number; error: string }
async function request(
method: 'POST' | 'PATCH',
url: string,
body: unknown,
): Promise<Result> {
try {
const response = await fetch(url, {
method,
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return {
ok: false,
status: response.status,
error: payload?.error ?? `请求失败(${response.status})`,
}
} catch (error) {
return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function fetchSession(): Promise<SessionState> {
try {
const response = await fetch('/api/auth/session', { credentials: 'same-origin' })
if (!response.ok) return { authenticated: false, has_admin: false }
return (await response.json()) as SessionState
} catch {
return { authenticated: false, has_admin: false }
}
}
export function login(password: string): Promise<Result> {
return request('POST', '/api/auth/login', { password })
}
export function setup(password: string, username = 'admin'): Promise<Result> {
return request('POST', '/api/auth/setup', { password, username })
}
export function logout(): Promise<Result> {
return request('POST', '/api/auth/logout', {})
}
export function changePassword(current: string, next: string): Promise<Result> {
return request('PATCH', '/api/auth/password', { current, next })
}
+126
View File
@@ -0,0 +1,126 @@
import { Button, Card, FormField, Icon, Input, Notice, Space, Text, Title } from '@stash/ui'
import { useEffect, useState } from 'react'
import { fetchSession, login, setup, type SessionState } from '../../auth'
/**
* 登录页:首次打开是「设置密码」,之后是「登录」。
*
* 成功后整页跳回首页(而不是 SPA 内跳转),让服务端重新下发 Cookie 后的首帧
* 也带上登录态,避免首屏闪一下未登录 UI。
*/
export function LoginPage() {
const [session, setSession] = useState<SessionState | null>(null)
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
let alive = true
fetchSession().then((next) => {
if (!alive) return
if (next.authenticated) {
window.location.assign('/')
return
}
setSession(next)
})
return () => {
alive = false
}
}, [])
const isSetup = session !== null && !session.has_admin
const submit = async () => {
setError('')
if (isSetup && password !== confirm) {
setError('两次输入的密码不一致')
return
}
setBusy(true)
const result = isSetup ? await setup(password) : await login(password)
setBusy(false)
if (result.ok) {
window.location.assign('/')
return
}
setError(result.error)
}
return (
<div className="login-page">
<Card padding="lg" className="login-card">
<Space gap="sm" className="login-brand">
<span className="site-brand-mark" aria-hidden="true">
S
</span>
<span className="site-brand-name">STASH</span>
</Space>
<Title level={2}>{isSetup ? '设置管理员密码' : '登录'}</Title>
<Text size="sm" tone="muted">
{isSetup
? '首次使用:设置一个至少 8 位的密码,之后用它在任意设备上登录。'
: '登录后可以编辑侧栏、页头滚动信息与内容。'}
</Text>
{error !== '' && (
<Notice tone="error" title="操作失败">
{error}
</Notice>
)}
<FormField
label="密码"
htmlFor="password"
required
hint={isSetup ? '至少 8 位,建议混合字母与数字' : undefined}
>
<Input
id="password"
type="password"
size="lg"
icon="lock_line"
placeholder="输入密码"
value={password}
autoFocus
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
{isSetup && (
<FormField label="确认密码" htmlFor="confirm" required>
<Input
id="confirm"
type="password"
size="lg"
icon="keyhole_line"
placeholder="再输入一次"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
)}
<Button size="lg" block loading={busy} icon={<Icon name="lock_line" />} onClick={submit}>
{isSetup ? '设置并登录' : '登录'}
</Button>
<a className="login-back" href="/">
返回首页
</a>
</Card>
</div>
)
}
+28 -4
View File
@@ -9,10 +9,18 @@ type SiteHeaderProps = {
/** 用于展示的第一个项目名,没有项目时留空 */
sampleProject?: string
live: boolean
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */
authenticated: boolean
username?: string
onFocusSearch: () => void
onLogin: () => void
onLogout: () => void
}
type InfoGroupProps = Omit<SiteHeaderProps, 'onFocusSearch'> & {
type InfoGroupProps = {
projectCount: number
sampleProject?: string
live: boolean
'aria-hidden'?: boolean
}
@@ -46,7 +54,16 @@ function InfoGroup({
)
}
export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }: SiteHeaderProps) {
export function SiteHeader({
projectCount,
sampleProject,
live,
authenticated,
username,
onFocusSearch,
onLogin,
onLogout,
}: SiteHeaderProps) {
return (
<div className="site-header-inner">
<a className="site-brand" href="/" aria-label="Stash 首页">
@@ -81,8 +98,15 @@ export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }:
icon={<Icon name="search_3_line" />}
onClick={onFocusSearch}
/>
{/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */}
<IconButton label="登录" icon={<Icon name="user_3_line" />} />
{authenticated ? (
<IconButton
label={username === undefined ? '退出登录' : `退出登录(${username})`}
icon={<Icon name="exit_line" />}
onClick={onLogout}
/>
) : (
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
)}
</div>
</div>
)
+9 -3
View File
@@ -3,9 +3,15 @@ import { createRoot } from 'react-dom/client'
import '@stash/ui/style.css'
import './index.css'
import App from './App.tsx'
import { LoginPage } from './components/auth/LoginPage'
/**
* 入口分流:登录页是独立壳(不挂 Layout / 侧栏 / 搜索),
* 且必须在 App 的 `useViewRoute` 把地址规范化之前判定,
* 所以放在这里用首次加载的 pathname 决定,而不是放进 App 里。
*/
const isLoginPage = window.location.pathname === '/login'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
<StrictMode>{isLoginPage ? <LoginPage /> : <App />}</StrictMode>,
)