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:
@@ -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) {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user