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
+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>
)