mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-06 20:02:41 +08:00
feat: add complete MFA support
Add complete MFA support with TOTP, recovery codes, WebAuthn, trusted-device cookie flow, and email/SMS OTP delivery via notification channels. Security follow-up: trusted device tokens are stored in HttpOnly cookies, and SMS OTP reuses the existing Webhook notifier to avoid introducing a new dynamic URL sink.
This commit is contained in:
@@ -6,7 +6,7 @@ import type * as Preset from '@docusaurus/preset-classic';
|
||||
// https://awuqing.github.io/BackupX/
|
||||
const config: Config = {
|
||||
title: 'BackupX',
|
||||
tagline: 'Self-hosted server backup management — one binary, one command',
|
||||
tagline: 'Self-hosted backup orchestration for servers, databases, storage targets and remote agents',
|
||||
favicon: 'img/favicon.ico',
|
||||
|
||||
future: {
|
||||
@@ -76,6 +76,16 @@ const config: Config = {
|
||||
label: 'Downloads',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
to: '/community',
|
||||
label: 'Community',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
to: '/sponsors',
|
||||
label: 'Sponsors',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
type: 'localeDropdown',
|
||||
position: 'right',
|
||||
@@ -115,6 +125,22 @@ const config: Config = {
|
||||
{label: 'Issues', href: 'https://github.com/Awuqing/BackupX/issues'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Community',
|
||||
items: [
|
||||
{label: 'Contributors', href: 'https://github.com/Awuqing/BackupX/graphs/contributors'},
|
||||
{label: 'Pull Requests', href: 'https://github.com/Awuqing/BackupX/pulls'},
|
||||
{label: 'Sponsor', to: '/sponsors'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Sponsors',
|
||||
items: [
|
||||
{label: 'Sponsor BackupX', href: 'https://github.com/sponsors/Awuqing'},
|
||||
{label: 'Partnership', href: 'https://github.com/Awuqing/BackupX/issues/new/choose'},
|
||||
{label: 'Sponsor tiers', to: '/sponsors'},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Copyright © ${new Date().getFullYear()} BackupX · Apache License 2.0`,
|
||||
},
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"home.badge": {
|
||||
"message": "开源 · v1.6.0",
|
||||
"message": "开源备份控制平面 · v2.2.1",
|
||||
"description": "Version badge on the hero"
|
||||
},
|
||||
"home.title.part1": {
|
||||
"message": "为每一台服务器提供",
|
||||
"message": "面向自托管服务器的",
|
||||
"description": "Hero title, first line"
|
||||
},
|
||||
"home.title.part2": {
|
||||
"message": "自托管备份管理。",
|
||||
"message": "备份编排平台。",
|
||||
"description": "Hero title accent second line"
|
||||
},
|
||||
"home.tagline": {
|
||||
"message": "一个二进制,一条命令。文件 / 数据库 / SAP HANA 备份直送 70+ 存储后端。",
|
||||
"message": "在一个清爽控制台中管理文件、数据库、SAP HANA 和远程节点备份。控制平面自己掌握,存储后端灵活选择。",
|
||||
"description": "Tagline on the home page"
|
||||
},
|
||||
"home.pageTitle": {
|
||||
"message": "自托管备份管理",
|
||||
"message": "面向自托管服务器的备份编排",
|
||||
"description": "Page <title> element on the home page"
|
||||
},
|
||||
"home.getStarted": {
|
||||
@@ -28,13 +28,26 @@
|
||||
"description": "Hero metric label: storage backends"
|
||||
},
|
||||
"home.metric.backupTypes": {
|
||||
"message": "备份类型",
|
||||
"message": "远程执行",
|
||||
"description": "Hero metric label: backup types"
|
||||
},
|
||||
"home.metric.license": {
|
||||
"message": "开源协议",
|
||||
"description": "Hero metric label: license"
|
||||
},
|
||||
"home.visual.eyebrow": {"message": "BackupX 控制台"},
|
||||
"home.visual.title": {"message": "运维概览"},
|
||||
"home.visual.status": {"message": "健康"},
|
||||
"home.visual.success": {"message": "成功率"},
|
||||
"home.visual.nodes": {"message": "活跃节点"},
|
||||
"home.visual.targets": {"message": "存储目标"},
|
||||
"home.visual.row1.title": {"message": "PostgreSQL 夜间备份"},
|
||||
"home.visual.row1.desc": {"message": "加密归档已上传至 S3"},
|
||||
"home.visual.row2.title": {"message": "SAP HANA 快照"},
|
||||
"home.visual.row2.desc": {"message": "正在 agent-shanghai-02 上运行"},
|
||||
"home.visual.row3.title": {"message": "保留策略清理"},
|
||||
"home.visual.row3.desc": {"message": "下一次执行在 4 小时后"},
|
||||
"home.command.title": {"message": "使用 Docker 启动"},
|
||||
|
||||
"section.features.tag": {
|
||||
"message": "核心能力",
|
||||
@@ -78,5 +91,70 @@
|
||||
"showcase.storage.desc": {"message": "阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV — 加上每一种 rclone 后端。测试连接、收藏、查看实时容量。"},
|
||||
"showcase.nodes.title": {"message": "几分钟搭起 Master-Agent"},
|
||||
"showcase.nodes.desc": {"message": "创建节点、复制令牌、在任意远程主机启动 Agent。路由到节点的任务在本地执行并直接上传到存储 — 无需反向连通性。"},
|
||||
"showcase.cta": {"message": "开始阅读文档"}
|
||||
"showcase.cta": {"message": "开始阅读文档"},
|
||||
|
||||
"community.tag": {"message": "社区"},
|
||||
"community.pageTitle": {"message": "社区、赞助商与贡献者"},
|
||||
"community.pageDescription": {"message": "赞助 BackupX,了解贡献者,并找到务实的参与方式。"},
|
||||
"community.title": {"message": "开放协作,面向长期运维"},
|
||||
"community.subtitle": {"message": "备份软件的信任来自透明发布、真实部署反馈,以及足够务实的贡献路径。"},
|
||||
"community.sponsor.kicker": {"message": "赞助商"},
|
||||
"community.sponsor.wallTitle": {"message": "赞助商"},
|
||||
"community.sponsor.title": {"message": "支持你依赖的备份基础设施"},
|
||||
"community.sponsor.cta": {"message": "赞助 BackupX"},
|
||||
"community.sponsor.openSlot": {"message": "赞助席位开放"},
|
||||
"community.sponsor.logo.project": {"message": "项目赞助"},
|
||||
"community.sponsor.logo.cloud": {"message": "云服务伙伴"},
|
||||
"community.sponsor.logo.object": {"message": "对象存储"},
|
||||
"community.sponsor.logo.cdn": {"message": "CDN 伙伴"},
|
||||
"community.sponsor.logo.database": {"message": "数据库伙伴"},
|
||||
"community.sponsor.logo.security": {"message": "安全审计"},
|
||||
"community.sponsor.logo.agent": {"message": "远程节点实验室"},
|
||||
"community.sponsor.logo.docs": {"message": "文档赞助"},
|
||||
"community.sponsor.logo.release": {"message": "发布赞助"},
|
||||
"community.sponsor.logo.s3": {"message": "S3 兼容"},
|
||||
"community.sponsor.logo.webdav": {"message": "WebDAV 伙伴"},
|
||||
"community.sponsor.logo.sftp": {"message": "SFTP 伙伴"},
|
||||
"community.sponsor.logo.docker": {"message": "容器伙伴"},
|
||||
"community.sponsor.logo.mirror": {"message": "镜像伙伴"},
|
||||
"community.sponsor.logo.restore": {"message": "恢复演练"},
|
||||
"community.sponsor.logo.qa": {"message": "测试实验室"},
|
||||
"community.sponsor.logo.oss": {"message": "开源支持"},
|
||||
"community.sponsor.logo.open": {"message": "赞助席位开放"},
|
||||
"community.sponsor.infrastructure.label": {"message": "基础设施"},
|
||||
"community.sponsor.infrastructure.title": {"message": "云与存储生态伙伴"},
|
||||
"community.sponsor.infrastructure.desc": {"message": "帮助 BackupX 覆盖对象存储、WebDAV、SFTP 以及区域云平台的真实验证。"},
|
||||
"community.sponsor.security.label": {"message": "安全"},
|
||||
"community.sponsor.security.title": {"message": "审计与可靠性支持者"},
|
||||
"community.sponsor.security.desc": {"message": "支持加密、恢复演练、发布签名和运维检查等强化工作。"},
|
||||
"community.sponsor.community.label": {"message": "社区"},
|
||||
"community.sponsor.community.title": {"message": "开源支持者"},
|
||||
"community.sponsor.community.desc": {"message": "支持文档、示例、平台测试和贡献者引导。"},
|
||||
"community.sponsor.tier.backer.name": {"message": "Backer"},
|
||||
"community.sponsor.tier.backer.amount": {"message": "适合个人与小团队"},
|
||||
"community.sponsor.tier.backer.desc": {"message": "支持文档、Issue 分流、兼容性测试和小型体验改进。"},
|
||||
"community.sponsor.tier.partner.name": {"message": "Partner"},
|
||||
"community.sponsor.tier.partner.amount": {"message": "适合存储与基础设施厂商"},
|
||||
"community.sponsor.tier.partner.desc": {"message": "支持 Provider 验证、部署示例、基准说明和集成指南。"},
|
||||
"community.sponsor.tier.enterprise.name": {"message": "Enterprise"},
|
||||
"community.sponsor.tier.enterprise.amount": {"message": "适合生产环境使用方"},
|
||||
"community.sponsor.tier.enterprise.desc": {"message": "赞助恢复演练、发布加固、审计和长期维护等可靠性工作。"},
|
||||
"community.contributor.kicker": {"message": "贡献者"},
|
||||
"community.contributor.all": {"message": "查看全部"},
|
||||
"community.contributor.source": {"message": "浏览器端通过 GitHub contributors API 获取。"},
|
||||
"community.contributor.botRole": {"message": "自动化贡献者"},
|
||||
"community.contributor.githubRole": {"message": "GitHub 贡献者"},
|
||||
"community.contributor.contributions": {"message": "{count} 次贡献"},
|
||||
"community.path.kicker": {"message": "贡献路径"},
|
||||
"community.path.issues.title": {"message": "反馈生产问题"},
|
||||
"community.path.issues.desc": {"message": "提交日志、部署拓扑和恢复预期。"},
|
||||
"community.path.docs.title": {"message": "完善文档与示例"},
|
||||
"community.path.docs.desc": {"message": "贡献存储、Agent 和数据库部署指南。"},
|
||||
"community.path.code.title": {"message": "提交聚焦的 PR"},
|
||||
"community.path.code.desc": {"message": "保持改动小而可测,并贴合现有架构。"},
|
||||
"sponsors.pageTitle": {"message": "赞助商"},
|
||||
"sponsors.pageDescription": {"message": "赞助 BackupX 的可靠性、文档、存储兼容性和长期维护。"},
|
||||
"sponsors.tag": {"message": "赞助商"},
|
||||
"sponsors.title": {"message": "赞助 BackupX 生态"},
|
||||
"sponsors.subtitle": {"message": "赞助帮助 BackupX 更贴近真实运维:经过验证的存储 Provider、可靠发布、恢复信心和更完善的文档。"}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"link.title.Docs": {"message": "文档"},
|
||||
"link.title.Features": {"message": "功能"},
|
||||
"link.title.More": {"message": "更多"},
|
||||
"link.title.Community": {"message": "社区"},
|
||||
"link.title.Sponsors": {"message": "赞助商"},
|
||||
"link.item.label.Introduction": {"message": "简介"},
|
||||
"link.item.label.Quick Start": {"message": "快速开始"},
|
||||
"link.item.label.Installation": {"message": "安装"},
|
||||
@@ -11,5 +13,11 @@
|
||||
"link.item.label.GitHub": {"message": "GitHub"},
|
||||
"link.item.label.Releases": {"message": "Releases"},
|
||||
"link.item.label.Docker Hub": {"message": "Docker Hub"},
|
||||
"link.item.label.Issues": {"message": "Issues"}
|
||||
"link.item.label.Issues": {"message": "Issues"},
|
||||
"link.item.label.Contributors": {"message": "贡献者"},
|
||||
"link.item.label.Pull Requests": {"message": "Pull Requests"},
|
||||
"link.item.label.Sponsor": {"message": "赞助"},
|
||||
"link.item.label.Sponsor BackupX": {"message": "赞助 BackupX"},
|
||||
"link.item.label.Partnership": {"message": "合作伙伴"},
|
||||
"link.item.label.Sponsor tiers": {"message": "赞助层级"}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
"message": "下载",
|
||||
"description": "Navbar item: Downloads"
|
||||
},
|
||||
"item.label.Community": {
|
||||
"message": "社区",
|
||||
"description": "Navbar item: Community"
|
||||
},
|
||||
"item.label.Sponsors": {
|
||||
"message": "赞助商",
|
||||
"description": "Navbar item: Sponsors"
|
||||
},
|
||||
"item.label.GitHub": {
|
||||
"message": "GitHub",
|
||||
"description": "Navbar item: GitHub"
|
||||
|
||||
329
docs-site/src/components/HomepageCommunity/index.tsx
Normal file
329
docs-site/src/components/HomepageCommunity/index.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import Heading from '@theme/Heading';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type SponsorSlot = {
|
||||
brand: ReactNode;
|
||||
name: ReactNode;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type Contributor = {
|
||||
login: string;
|
||||
avatarUrl?: string;
|
||||
contributions: number;
|
||||
type: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type GitHubContributor = {
|
||||
login: string;
|
||||
avatar_url?: string;
|
||||
contributions?: number;
|
||||
html_url?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type CommunityPath = {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
href: string;
|
||||
};
|
||||
|
||||
const SPONSOR_SLOTS: SponsorSlot[] = [
|
||||
{
|
||||
brand: 'BackupX',
|
||||
name: <Translate id="community.sponsor.logo.project">Project backer</Translate>,
|
||||
href: 'https://github.com/sponsors/Awuqing',
|
||||
},
|
||||
{
|
||||
brand: 'Cloud',
|
||||
name: <Translate id="community.sponsor.logo.cloud">Cloud partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Object',
|
||||
name: <Translate id="community.sponsor.logo.object">Object storage</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'CDN',
|
||||
name: <Translate id="community.sponsor.logo.cdn">CDN partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'DB',
|
||||
name: <Translate id="community.sponsor.logo.database">Database partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Security',
|
||||
name: <Translate id="community.sponsor.logo.security">Security audit</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Agent',
|
||||
name: <Translate id="community.sponsor.logo.agent">Remote node lab</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Docs',
|
||||
name: <Translate id="community.sponsor.logo.docs">Docs sponsor</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Release',
|
||||
name: <Translate id="community.sponsor.logo.release">Release sponsor</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'S3',
|
||||
name: <Translate id="community.sponsor.logo.s3">S3 compatible</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'WebDAV',
|
||||
name: <Translate id="community.sponsor.logo.webdav">WebDAV partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'SFTP',
|
||||
name: <Translate id="community.sponsor.logo.sftp">SFTP partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Docker',
|
||||
name: <Translate id="community.sponsor.logo.docker">Container partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Mirror',
|
||||
name: <Translate id="community.sponsor.logo.mirror">Mirror partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Restore',
|
||||
name: <Translate id="community.sponsor.logo.restore">Restore drill</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'QA',
|
||||
name: <Translate id="community.sponsor.logo.qa">Test lab</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'OSS',
|
||||
name: <Translate id="community.sponsor.logo.oss">Open source</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Open Slot',
|
||||
name: <Translate id="community.sponsor.logo.open">Sponsor slot open</Translate>,
|
||||
},
|
||||
];
|
||||
|
||||
const FALLBACK_CONTRIBUTORS: Contributor[] = [
|
||||
{
|
||||
login: 'Awuqing',
|
||||
contributions: 0,
|
||||
type: 'User',
|
||||
href: 'https://github.com/Awuqing',
|
||||
},
|
||||
{
|
||||
login: 'dependabot[bot]',
|
||||
contributions: 0,
|
||||
type: 'Bot',
|
||||
href: 'https://github.com/dependabot',
|
||||
},
|
||||
];
|
||||
|
||||
const COMMUNITY_PATHS: CommunityPath[] = [
|
||||
{
|
||||
title: <Translate id="community.path.issues.title">Report production issues</Translate>,
|
||||
description: <Translate id="community.path.issues.desc">Share logs, deployment topology and restore expectations.</Translate>,
|
||||
href: 'https://github.com/Awuqing/BackupX/issues',
|
||||
},
|
||||
{
|
||||
title: <Translate id="community.path.docs.title">Improve docs and examples</Translate>,
|
||||
description: <Translate id="community.path.docs.desc">Contribute deployment guides for storage, agents and databases.</Translate>,
|
||||
href: '/docs/development/contributing',
|
||||
},
|
||||
{
|
||||
title: <Translate id="community.path.code.title">Ship focused PRs</Translate>,
|
||||
description: <Translate id="community.path.code.desc">Keep changes small, tested and aligned with the existing architecture.</Translate>,
|
||||
href: 'https://github.com/Awuqing/BackupX/pulls',
|
||||
},
|
||||
];
|
||||
|
||||
function SponsorLogoCard({brand, name, href}: SponsorSlot) {
|
||||
return (
|
||||
<Link className={styles.sponsorLogoTile} to={href ?? 'https://github.com/sponsors/Awuqing'}>
|
||||
<span className={styles.sponsorLogoMark}>{brand}</span>
|
||||
<span className={styles.sponsorLogoName}>{name}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitials(login: string): string {
|
||||
return login
|
||||
.replace(/\[bot\]$/i, '')
|
||||
.split(/[-_\s]/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map(part => part[0]?.toUpperCase())
|
||||
.join('') || login.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function normalizeContributor(contributor: GitHubContributor): Contributor | null {
|
||||
if (!contributor.login) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
login: contributor.login,
|
||||
avatarUrl: contributor.avatar_url,
|
||||
contributions: contributor.contributions ?? 0,
|
||||
type: contributor.type ?? 'User',
|
||||
href: contributor.html_url ?? `https://github.com/${contributor.login}`,
|
||||
};
|
||||
}
|
||||
|
||||
function useGitHubContributors(): Contributor[] {
|
||||
const [contributors, setContributors] = useState<Contributor[]>(FALLBACK_CONTRIBUTORS);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch('https://api.github.com/repos/Awuqing/BackupX/contributors?per_page=12', {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub contributors request failed: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<GitHubContributor[]>;
|
||||
})
|
||||
.then(payload => {
|
||||
const nextContributors = payload
|
||||
.map(normalizeContributor)
|
||||
.filter((contributor): contributor is Contributor => Boolean(contributor));
|
||||
|
||||
if (nextContributors.length > 0) {
|
||||
setContributors(nextContributors);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
console.warn(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
return contributors;
|
||||
}
|
||||
|
||||
function ContributorCard({login, avatarUrl, contributions, type, href}: Contributor) {
|
||||
return (
|
||||
<Link className={styles.contributorCard} to={href}>
|
||||
{avatarUrl ? (
|
||||
<img className={styles.avatarImage} src={avatarUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className={styles.avatar} aria-hidden="true">{getInitials(login)}</span>
|
||||
)}
|
||||
<span className={styles.contributorBody}>
|
||||
<strong>{login}</strong>
|
||||
<span>
|
||||
{type === 'Bot' ? (
|
||||
<Translate id="community.contributor.botRole">Automation contributor</Translate>
|
||||
) : (
|
||||
<Translate id="community.contributor.githubRole">GitHub contributor</Translate>
|
||||
)}
|
||||
</span>
|
||||
<em>
|
||||
<Translate id="community.contributor.contributions" values={{count: contributions}}>
|
||||
{'{count} contributions'}
|
||||
</Translate>
|
||||
</em>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageSponsors(): ReactNode {
|
||||
return (
|
||||
<div className={styles.sponsorWall}>
|
||||
<div className={styles.sponsorWallHeader}>
|
||||
<Heading as="h3" className={styles.sponsorWallTitle}>
|
||||
<Translate id="community.sponsor.wallTitle">Sponsors</Translate>
|
||||
</Heading>
|
||||
<Link className={styles.sponsorWallAction} to="https://github.com/sponsors/Awuqing">
|
||||
<Translate id="community.sponsor.cta">Sponsor BackupX</Translate>
|
||||
<span aria-hidden="true">-></span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.sponsorLogoGrid}>
|
||||
{SPONSOR_SLOTS.map((slot, index) => (
|
||||
<SponsorLogoCard key={index} {...slot} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomepageCommunity(): ReactNode {
|
||||
const contributors = useGitHubContributors();
|
||||
|
||||
return (
|
||||
<section id="community" className={styles.section}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionHead}>
|
||||
<div className={styles.sectionTag}>
|
||||
<Translate id="community.tag">COMMUNITY</Translate>
|
||||
</div>
|
||||
<Heading as="h2" className={styles.sectionTitle}>
|
||||
<Translate id="community.title">Built in the open, ready for long-term operators</Translate>
|
||||
</Heading>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
<Translate id="community.subtitle">
|
||||
Backup software earns trust through transparent releases, real deployment feedback and a contributor path that stays practical.
|
||||
</Translate>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HomepageSponsors />
|
||||
|
||||
<div className={styles.communityGrid}>
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span>
|
||||
<Translate id="community.contributor.kicker">Contributors</Translate>
|
||||
</span>
|
||||
<Link to="https://github.com/Awuqing/BackupX/graphs/contributors">
|
||||
<Translate id="community.contributor.all">View all</Translate>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.panelNote}>
|
||||
<Translate id="community.contributor.source">Loaded from GitHub contributors API in the browser.</Translate>
|
||||
</div>
|
||||
<div className={styles.contributorList}>
|
||||
{contributors.map(contributor => (
|
||||
<ContributorCard key={contributor.login} {...contributor} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span>
|
||||
<Translate id="community.path.kicker">Contributor paths</Translate>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.pathList}>
|
||||
{COMMUNITY_PATHS.map((path, index) => (
|
||||
<Link key={index} className={styles.pathItem} to={path.href}>
|
||||
<span className={styles.pathIndex}>{String(index + 1).padStart(2, '0')}</span>
|
||||
<span>
|
||||
<strong>{path.title}</strong>
|
||||
<em>{path.description}</em>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
429
docs-site/src/components/HomepageCommunity/styles.module.css
Normal file
429
docs-site/src/components/HomepageCommunity/styles.module.css
Normal file
@@ -0,0 +1,429 @@
|
||||
.section {
|
||||
padding: 5.5rem 0 6rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.86) 100%),
|
||||
var(--ifm-background-color);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .section {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
|
||||
var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.sectionHead {
|
||||
max-width: 760px;
|
||||
margin: 0 auto 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sectionTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
margin-bottom: 1rem;
|
||||
padding: 4px 10px;
|
||||
color: #00a870;
|
||||
background: rgba(0, 180, 42, 0.1);
|
||||
border: 1px solid rgba(0, 180, 42, 0.18);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 2.35rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
margin: 0;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 1.04rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.sponsorWall {
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.06);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sponsorWall {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sponsorWallHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 60px;
|
||||
padding: 0 1.25rem;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sponsorWallHeader {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.sponsorWallTitle {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.sponsorWallTitle::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
content: "";
|
||||
background: #52c41a;
|
||||
border-radius: 3px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sponsorWallAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
color: #52c41a;
|
||||
background: rgba(82, 196, 26, 0.08);
|
||||
border: 1px solid rgba(82, 196, 26, 0.2);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: none !important;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.sponsorWallAction:hover,
|
||||
.sponsorWallAction:focus-visible {
|
||||
color: #389e0d;
|
||||
background: rgba(82, 196, 26, 0.14);
|
||||
border-color: #52c41a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sponsorLogoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
gap: 1px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sponsorLogoGrid {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.sponsorLogoTile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 106px;
|
||||
padding: 14px 10px;
|
||||
flex-direction: column;
|
||||
color: inherit;
|
||||
background: var(--ifm-background-color);
|
||||
text-align: center;
|
||||
text-decoration: none !important;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sponsorLogoTile {
|
||||
background: rgba(15, 17, 21, 0.78);
|
||||
}
|
||||
|
||||
.sponsorLogoTile:hover,
|
||||
.sponsorLogoTile:focus-visible {
|
||||
z-index: 1;
|
||||
color: inherit;
|
||||
background: rgba(82, 196, 26, 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgba(82, 196, 26, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sponsorLogoMark {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--ifm-color-primary);
|
||||
font-size: 1.45rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sponsorLogoTile:nth-child(2n) .sponsorLogoMark {
|
||||
color: #ff7d00;
|
||||
}
|
||||
|
||||
.sponsorLogoTile:nth-child(3n) .sponsorLogoMark {
|
||||
color: #14c9c9;
|
||||
}
|
||||
|
||||
.sponsorLogoTile:nth-child(4n) .sponsorLogoMark {
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
.sponsorLogoTile:nth-child(5n) .sponsorLogoMark {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.sponsorLogoName {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: 10px;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.06);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .panel {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.communityGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panelHeader a {
|
||||
color: var(--ifm-color-primary);
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.panelNote {
|
||||
margin: -0.35rem 0 1rem;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.contributorList,
|
||||
.pathList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.contributorCard,
|
||||
.pathItem {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
text-decoration: none !important;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.contributorCard:hover,
|
||||
.contributorCard:focus-visible,
|
||||
.pathItem:hover,
|
||||
.pathItem:focus-visible {
|
||||
color: inherit;
|
||||
background: var(--ifm-background-color);
|
||||
border-color: var(--ifm-color-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .contributorCard,
|
||||
[data-theme='dark'] .pathItem {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.contributorCard {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: #fff;
|
||||
background: #165dff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.avatarImage {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.contributorCard:nth-child(2) .avatar {
|
||||
background: #00a870;
|
||||
}
|
||||
|
||||
.contributorCard:nth-child(3) .avatar {
|
||||
background: #ff7d00;
|
||||
}
|
||||
|
||||
.contributorBody {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.contributorBody strong {
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.contributorBody span {
|
||||
color: var(--ifm-color-content);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.contributorBody em,
|
||||
.pathItem em {
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-style: normal;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.pathItem {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.pathIndex {
|
||||
color: var(--ifm-color-primary);
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pathItem strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.section {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.communityGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sponsorLogoGrid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sponsorLogoTile {
|
||||
min-height: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.section {
|
||||
padding: 3.25rem 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.sponsorWallHeader {
|
||||
display: grid;
|
||||
min-height: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.sponsorWallAction {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sponsorLogoGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sponsorLogoMark {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sponsorWallAction,
|
||||
.sponsorLogoTile,
|
||||
.contributorCard,
|
||||
.pathItem {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ function Feature({title, description, icon, link}: FeatureItem) {
|
||||
{link && (
|
||||
<span className={styles.featureLink}>
|
||||
<Translate id="feat.learnMore">Learn more</Translate>
|
||||
<span className={styles.featureArrow} aria-hidden="true">→</span>
|
||||
<span className={styles.featureArrow} aria-hidden="true">-></span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.section {
|
||||
padding: 6rem 0 4rem;
|
||||
padding: 5.5rem 0 4.25rem;
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.sectionHead {
|
||||
@@ -9,14 +10,17 @@
|
||||
}
|
||||
|
||||
.sectionTag {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
color: var(--ifm-color-primary);
|
||||
padding: 4px 12px;
|
||||
background: rgba(22, 93, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(22, 93, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@@ -26,10 +30,10 @@
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: clamp(1.8rem, 3vw, 2.5rem);
|
||||
font-size: 2.35rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
font-weight: 750;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
@@ -51,6 +55,9 @@
|
||||
.section {
|
||||
padding: 3.5rem 0 2rem;
|
||||
}
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -70,7 +77,7 @@
|
||||
padding: 1.75rem;
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
text-decoration: none !important;
|
||||
color: inherit;
|
||||
@@ -78,7 +85,7 @@
|
||||
}
|
||||
|
||||
.featureCardLink:hover {
|
||||
transform: translateY(-3px);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--ifm-color-primary);
|
||||
box-shadow: 0 12px 30px -8px rgba(22, 93, 255, 0.18);
|
||||
color: inherit;
|
||||
@@ -99,26 +106,26 @@
|
||||
.iconWrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(143, 75, 255, 0.08) 100%);
|
||||
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(20, 201, 201, 0.12) 100%);
|
||||
color: var(--ifm-color-primary);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .iconWrap {
|
||||
background: linear-gradient(135deg, rgba(96, 126, 255, 0.15) 0%, rgba(143, 75, 255, 0.12) 100%);
|
||||
background: linear-gradient(135deg, rgba(96, 126, 255, 0.15) 0%, rgba(20, 201, 201, 0.12) 100%);
|
||||
color: var(--ifm-color-primary-lighter);
|
||||
}
|
||||
|
||||
.featureTitle {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--ifm-heading-color);
|
||||
letter-spacing: -0.01em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.featureDesc {
|
||||
@@ -146,3 +153,17 @@
|
||||
.featureCardLink:hover .featureArrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sectionTitle {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.featureCard,
|
||||
.featureCardLink,
|
||||
.featureArrow {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function HomepageShowcase(): ReactNode {
|
||||
<p className={styles.captionDesc}>{current.description}</p>
|
||||
<Link to="/docs/getting-started/quick-start" className={styles.captionLink}>
|
||||
<Translate id="showcase.cta">Explore the docs</Translate>
|
||||
<span aria-hidden="true"> →</span>
|
||||
<span aria-hidden="true"> -></span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
.section {
|
||||
padding: 4rem 0 6rem;
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(22, 93, 255, 0.03) 100%);
|
||||
padding: 4.5rem 0 5.5rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.72) 100%),
|
||||
var(--ifm-background-color);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .section {
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(64, 128, 255, 0.04) 100%);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
|
||||
var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.sectionHead {
|
||||
@@ -14,26 +18,30 @@
|
||||
}
|
||||
|
||||
.sectionTag {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
color: #8f4bff;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
color: #0e7490;
|
||||
padding: 4px 12px;
|
||||
background: rgba(143, 75, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
background: rgba(20, 201, 201, 0.1);
|
||||
border: 1px solid rgba(20, 201, 201, 0.2);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sectionTag {
|
||||
background: rgba(143, 75, 255, 0.18);
|
||||
background: rgba(20, 201, 201, 0.16);
|
||||
color: #67e8f9;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: clamp(1.8rem, 3vw, 2.5rem);
|
||||
font-size: 2.35rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
font-weight: 750;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
@@ -49,34 +57,39 @@
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tabBtn {
|
||||
min-height: 40px;
|
||||
padding: 8px 18px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.tabBtn:hover {
|
||||
color: var(--ifm-color-primary);
|
||||
border-color: var(--ifm-color-primary);
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.tabBtnActive,
|
||||
.tabBtnActive:hover {
|
||||
background: linear-gradient(90deg, #165dff 0%, #4080ff 100%);
|
||||
color: #fff !important;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 14px rgba(22, 93, 255, 0.3);
|
||||
background: var(--ifm-background-color);
|
||||
color: var(--ifm-color-primary) !important;
|
||||
border-color: rgba(22, 93, 255, 0.18);
|
||||
box-shadow: 0 6px 16px rgba(22, 93, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Stage */
|
||||
@@ -96,10 +109,10 @@
|
||||
|
||||
.browser {
|
||||
background: var(--ifm-background-color);
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 30px 60px -20px rgba(22, 93, 255, 0.25),
|
||||
0 24px 58px -22px rgba(22, 93, 255, 0.28),
|
||||
0 0 0 1px var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
@@ -137,7 +150,7 @@
|
||||
margin: 0 auto;
|
||||
padding: 3px 14px;
|
||||
background: var(--ifm-background-color);
|
||||
border-radius: 999px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-family: 'SFMono-Regular', Menlo, monospace;
|
||||
@@ -169,8 +182,8 @@
|
||||
.captionTitle {
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
font-weight: 750;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
@@ -186,11 +199,49 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 500;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(22, 93, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
font-weight: 650;
|
||||
color: var(--ifm-color-primary);
|
||||
text-decoration: none !important;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.captionLink:hover {
|
||||
color: var(--ifm-color-primary-dark);
|
||||
background: rgba(22, 93, 255, 0.06);
|
||||
border-color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.section {
|
||||
padding: 3.25rem 0 4rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.tabBtn {
|
||||
flex: 1 1 130px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tabBtn,
|
||||
.captionLink {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,15 @@
|
||||
/* Surfaces */
|
||||
--ifm-background-color: #ffffff;
|
||||
--ifm-background-surface-color: #ffffff;
|
||||
--ifm-color-emphasis-100: #f7f9fc;
|
||||
--ifm-color-emphasis-200: #eef1f6;
|
||||
--ifm-color-emphasis-300: #dde3ec;
|
||||
--ifm-color-emphasis-100: #f5f7fa;
|
||||
--ifm-color-emphasis-200: #e5e6eb;
|
||||
--ifm-color-emphasis-300: #c9cdd4;
|
||||
--ifm-color-emphasis-400: #a9aeb8;
|
||||
|
||||
/* Typography */
|
||||
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
--ifm-font-family-monospace: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
--ifm-heading-font-weight: 600;
|
||||
--ifm-heading-font-weight: 700;
|
||||
--ifm-code-font-size: 92%;
|
||||
--ifm-h1-font-size: 2.25rem;
|
||||
--ifm-h2-font-size: 1.75rem;
|
||||
@@ -33,10 +34,11 @@
|
||||
--ifm-color-content: #1d2129;
|
||||
--ifm-color-content-secondary: #4e5969;
|
||||
--ifm-heading-color: #1d2129;
|
||||
--ifm-global-radius: 8px;
|
||||
|
||||
/* Navbar */
|
||||
--ifm-navbar-height: 64px;
|
||||
--ifm-navbar-background-color: rgba(255, 255, 255, 0.82);
|
||||
--ifm-navbar-background-color: rgba(255, 255, 255, 0.9);
|
||||
--ifm-navbar-link-color: #4e5969;
|
||||
--ifm-navbar-link-hover-color: var(--ifm-color-primary);
|
||||
|
||||
@@ -64,15 +66,16 @@
|
||||
|
||||
--ifm-background-color: #0f1115;
|
||||
--ifm-background-surface-color: #16181d;
|
||||
--ifm-color-emphasis-100: #1a1d23;
|
||||
--ifm-color-emphasis-200: #23272f;
|
||||
--ifm-color-emphasis-300: #2e343d;
|
||||
--ifm-color-emphasis-100: #1d2129;
|
||||
--ifm-color-emphasis-200: #272e3b;
|
||||
--ifm-color-emphasis-300: #384252;
|
||||
--ifm-color-emphasis-400: #4e5969;
|
||||
|
||||
--ifm-color-content: #e6e9ef;
|
||||
--ifm-color-content-secondary: #9aa3b2;
|
||||
--ifm-heading-color: #f0f2f5;
|
||||
|
||||
--ifm-navbar-background-color: rgba(15, 17, 21, 0.82);
|
||||
--ifm-navbar-background-color: rgba(15, 17, 21, 0.9);
|
||||
--ifm-navbar-link-color: #c9d1db;
|
||||
|
||||
--ifm-menu-color: #c9d1db;
|
||||
@@ -97,7 +100,7 @@
|
||||
|
||||
.navbar__title {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.navbar__link {
|
||||
@@ -105,10 +108,26 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.navbar__link,
|
||||
.button,
|
||||
a {
|
||||
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.button {
|
||||
border-radius: 8px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ifm-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Sidebar tweaks */
|
||||
.menu__link {
|
||||
font-size: 14px;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -226,9 +245,20 @@ code {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--ifm-color-emphasis-400, #adb5bd);
|
||||
background: var(--ifm-color-emphasis-400);
|
||||
}
|
||||
|
||||
[data-theme='dark'] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
19
docs-site/src/pages/community.tsx
Normal file
19
docs-site/src/pages/community.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {translate} from '@docusaurus/Translate';
|
||||
import Layout from '@theme/Layout';
|
||||
import HomepageCommunity from '@site/src/components/HomepageCommunity';
|
||||
|
||||
export default function Community(): ReactNode {
|
||||
return (
|
||||
<Layout
|
||||
title={translate({id: 'community.pageTitle', message: 'Community, sponsors and contributors'})}
|
||||
description={translate({
|
||||
id: 'community.pageDescription',
|
||||
message: 'Sponsor BackupX, meet contributors, and find practical ways to contribute.',
|
||||
})}>
|
||||
<main>
|
||||
<HomepageCommunity />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,42 @@
|
||||
/* ── Hero ───────────────────────────────────────────── */
|
||||
/* Hero */
|
||||
.hero {
|
||||
position: relative;
|
||||
padding: 7rem 0 6rem;
|
||||
overflow: hidden;
|
||||
background: var(--bx-hero-bg);
|
||||
padding: 7rem 0 5.5rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(22, 93, 255, 0.08) 0%, rgba(255, 255, 255, 0) 72%),
|
||||
linear-gradient(90deg, rgba(20, 201, 201, 0.08) 0%, rgba(250, 173, 20, 0.08) 100%),
|
||||
var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.heroBg {
|
||||
.hero::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 15% 20%, rgba(104, 127, 255, 0.18) 0%, transparent 45%),
|
||||
radial-gradient(circle at 85% 70%, rgba(22, 93, 255, 0.15) 0%, transparent 50%),
|
||||
linear-gradient(180deg, #f7f9ff 0%, #ffffff 100%);
|
||||
z-index: 0;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(22, 93, 255, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(22, 93, 255, 0.06) 1px, transparent 1px);
|
||||
background-size: 44px 44px;
|
||||
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.75), transparent 82%);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .heroBg {
|
||||
[data-theme='dark'] .hero {
|
||||
background:
|
||||
radial-gradient(circle at 15% 20%, rgba(96, 126, 255, 0.22) 0%, transparent 45%),
|
||||
radial-gradient(circle at 85% 70%, rgba(118, 70, 255, 0.18) 0%, transparent 50%),
|
||||
linear-gradient(180deg, #0f1115 0%, #0b0d10 100%);
|
||||
linear-gradient(180deg, rgba(64, 128, 255, 0.16) 0%, rgba(15, 17, 21, 0) 72%),
|
||||
linear-gradient(90deg, rgba(20, 201, 201, 0.1) 0%, rgba(250, 173, 20, 0.08) 100%),
|
||||
var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.heroInner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(420px, 0.9fr);
|
||||
gap: 4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.hero {
|
||||
padding: 4rem 0 3rem;
|
||||
}
|
||||
.heroInner {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -54,137 +48,144 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 14px;
|
||||
background: rgba(22, 93, 255, 0.08);
|
||||
border: 1px solid rgba(22, 93, 255, 0.15);
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
min-height: 32px;
|
||||
padding: 5px 12px;
|
||||
color: var(--ifm-color-primary);
|
||||
font-weight: 500;
|
||||
background: rgba(22, 93, 255, 0.09);
|
||||
border: 1px solid rgba(22, 93, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badge {
|
||||
background: rgba(96, 126, 255, 0.15);
|
||||
border-color: rgba(96, 126, 255, 0.3);
|
||||
background: rgba(64, 128, 255, 0.16);
|
||||
border-color: rgba(64, 128, 255, 0.3);
|
||||
color: var(--ifm-color-primary-lighter);
|
||||
}
|
||||
|
||||
.badgeDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--ifm-color-primary);
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: #00b42a;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 4px rgba(22, 93, 255, 0.18);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
box-shadow: 0 0 0 4px rgba(0, 180, 42, 0.12);
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2.25rem, 4vw, 3.4rem);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.025em;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 3.45rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.heroTitleAccent {
|
||||
display: block;
|
||||
background: linear-gradient(90deg, #4080ff 0%, #8f4bff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-top: 6px;
|
||||
margin-top: 8px;
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.65;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
max-width: 540px;
|
||||
max-width: 640px;
|
||||
margin: 0;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.72;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn {
|
||||
min-height: 46px;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
background: linear-gradient(90deg, #165dff 0%, #4080ff 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 20px rgba(22, 93, 255, 0.3);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
background: #165dff;
|
||||
border: 1px solid #165dff;
|
||||
box-shadow: 0 10px 24px rgba(22, 93, 255, 0.24);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.primaryBtn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 25px rgba(22, 93, 255, 0.4);
|
||||
.primaryBtn:hover,
|
||||
.primaryBtn:focus-visible {
|
||||
color: #fff;
|
||||
background: #0e4fe6;
|
||||
border-color: #0e4fe6;
|
||||
box-shadow: 0 14px 30px rgba(22, 93, 255, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btnArrow {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.primaryBtn:hover .btnArrow {
|
||||
transform: translateX(4px);
|
||||
.primaryBtn:hover .btnArrow,
|
||||
.primaryBtn:focus-visible .btnArrow {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
color: var(--ifm-font-color-base);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover {
|
||||
border-color: var(--ifm-color-primary);
|
||||
.secondaryBtn:hover,
|
||||
.secondaryBtn:focus-visible {
|
||||
color: var(--ifm-color-primary);
|
||||
border-color: var(--ifm-color-primary);
|
||||
background: var(--ifm-background-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.75rem;
|
||||
padding-top: 1.5rem;
|
||||
gap: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
font-size: 12px;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.metricDivider {
|
||||
@@ -193,81 +194,277 @@
|
||||
background: var(--ifm-color-emphasis-300);
|
||||
}
|
||||
|
||||
/* ── Code window (macOS-style) ─────────────────────── */
|
||||
.heroCode {
|
||||
position: relative;
|
||||
/* Product visual */
|
||||
.heroVisual {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.codeWindow {
|
||||
background: #0f1622;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 20px 50px -10px rgba(15, 22, 34, 0.35),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
.consolePanel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(22, 93, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 24px 60px rgba(29, 33, 41, 0.12);
|
||||
}
|
||||
|
||||
[data-theme='light'] .codeWindow {
|
||||
box-shadow: 0 20px 50px -10px rgba(22, 93, 255, 0.2), 0 0 0 1px rgba(22, 93, 255, 0.06);
|
||||
[data-theme='dark'] .consolePanel {
|
||||
background: rgba(22, 24, 29, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.codeHeader {
|
||||
.consoleHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background: #161f2e;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.codeDot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
[data-theme='dark'] .consoleHeader {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.codeDotRed { background: #ff5f56; }
|
||||
.codeDotYellow { background: #ffbd2e; }
|
||||
.codeDotGreen { background: #27c93f; }
|
||||
.consoleHeader strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.codeTitle {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #7b8696;
|
||||
letter-spacing: 0.05em;
|
||||
.consoleEyebrow {
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.codeBody {
|
||||
margin: 0;
|
||||
padding: 18px 20px;
|
||||
font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: #e1e7ef;
|
||||
background: transparent;
|
||||
.consoleStatus {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 4px 10px;
|
||||
color: #00a870;
|
||||
background: rgba(0, 180, 42, 0.1);
|
||||
border: 1px solid rgba(0, 180, 42, 0.2);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.consoleGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .consoleGrid {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.consoleGrid > div {
|
||||
min-width: 0;
|
||||
padding: 1.1rem 1.25rem;
|
||||
border-right: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .consoleGrid > div {
|
||||
border-right-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.consoleGrid > div:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.consoleGrid strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.consoleLabel {
|
||||
display: block;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.timelineRow {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .timelineRow {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.timelineRow:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.timelineRow strong,
|
||||
.timelineRow span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.timelineRow strong {
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timelineRow span {
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.timelineRow em {
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timelineDotOk,
|
||||
.timelineDotInfo,
|
||||
.timelineDotWarn {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.timelineDotOk {
|
||||
background: #00b42a;
|
||||
}
|
||||
|
||||
.timelineDotInfo {
|
||||
background: #165dff;
|
||||
}
|
||||
|
||||
.timelineDotWarn {
|
||||
background: #ff7d00;
|
||||
}
|
||||
|
||||
.commandCard {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 1rem 1.1rem;
|
||||
background: #111827;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 16px 34px rgba(17, 24, 39, 0.18);
|
||||
}
|
||||
|
||||
.commandTitle {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.commandCard code {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.codeBody code {
|
||||
color: #e5e7eb;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.codePrompt {
|
||||
color: #4080ff;
|
||||
margin-right: 6px;
|
||||
user-select: none;
|
||||
@media (max-width: 996px) {
|
||||
.hero {
|
||||
padding: 4.5rem 0 3.5rem;
|
||||
}
|
||||
|
||||
.heroInner {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.25rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
.codeComment {
|
||||
color: #6e7889;
|
||||
font-style: italic;
|
||||
@media (max-width: 640px) {
|
||||
.hero {
|
||||
padding: 3.75rem 0 2.75rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2.05rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
gap: 0.85rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metricDivider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.consoleHeader,
|
||||
.timelineRow {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.consoleGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.consoleGrid > div {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.consoleGrid > div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .consoleGrid > div {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.codeString {
|
||||
color: #82d1ff;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.primaryBtn,
|
||||
.secondaryBtn,
|
||||
.btnArrow {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,34 +7,34 @@ import Layout from '@theme/Layout';
|
||||
import Heading from '@theme/Heading';
|
||||
import HomepageFeatures from '@site/src/components/HomepageFeatures';
|
||||
import HomepageShowcase from '@site/src/components/HomepageShowcase';
|
||||
import HomepageCommunity from '@site/src/components/HomepageCommunity';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
function HomepageHeader() {
|
||||
return (
|
||||
<header className={styles.hero}>
|
||||
<div className={styles.heroBg} aria-hidden="true" />
|
||||
<div className={clsx('container', styles.heroInner)}>
|
||||
<div className={styles.heroContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeDot} />
|
||||
<Translate id="home.badge">Open-source · v1.6.0</Translate>
|
||||
<Translate id="home.badge">Open-source backup control plane · v2.2.1</Translate>
|
||||
</div>
|
||||
<Heading as="h1" className={styles.heroTitle}>
|
||||
<Translate id="home.title.part1">Self-hosted backup management</Translate>
|
||||
<Translate id="home.title.part1">Backup orchestration</Translate>
|
||||
<span className={styles.heroTitleAccent}>
|
||||
<Translate id="home.title.part2">for every server.</Translate>
|
||||
<Translate id="home.title.part2">for self-hosted servers.</Translate>
|
||||
</span>
|
||||
</Heading>
|
||||
<p className={styles.heroSubtitle}>
|
||||
<Translate id="home.tagline">
|
||||
One binary, one command. File / database / SAP HANA backups routed to 70+ storage backends.
|
||||
Run file, database, SAP HANA and remote-node backups from one clean console. Keep the control plane yours, keep the storage flexible.
|
||||
</Translate>
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<Link className={clsx('button button--primary button--lg', styles.primaryBtn)} to="/docs/getting-started/quick-start">
|
||||
<Translate id="home.getStarted">Get Started</Translate>
|
||||
<span className={styles.btnArrow} aria-hidden="true">→</span>
|
||||
<span className={styles.btnArrow} aria-hidden="true">-></span>
|
||||
</Link>
|
||||
<Link className={clsx('button button--lg', styles.secondaryBtn)} to="https://github.com/Awuqing/BackupX">
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style={{marginRight: 6}}>
|
||||
@@ -52,9 +52,9 @@ function HomepageHeader() {
|
||||
</div>
|
||||
<div className={styles.metricDivider} />
|
||||
<div className={styles.metric}>
|
||||
<div className={styles.metricValue}>5</div>
|
||||
<div className={styles.metricValue}>Agent</div>
|
||||
<div className={styles.metricLabel}>
|
||||
<Translate id="home.metric.backupTypes">Backup types</Translate>
|
||||
<Translate id="home.metric.backupTypes">Remote execution</Translate>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.metricDivider} />
|
||||
@@ -66,29 +66,85 @@ function HomepageHeader() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.heroCode}>
|
||||
<div className={styles.codeWindow}>
|
||||
<div className={styles.codeHeader}>
|
||||
<span className={clsx(styles.codeDot, styles.codeDotRed)} />
|
||||
<span className={clsx(styles.codeDot, styles.codeDotYellow)} />
|
||||
<span className={clsx(styles.codeDot, styles.codeDotGreen)} />
|
||||
<span className={styles.codeTitle}>bash</span>
|
||||
<div className={styles.heroVisual}>
|
||||
<div className={styles.consolePanel}>
|
||||
<div className={styles.consoleHeader}>
|
||||
<div>
|
||||
<span className={styles.consoleEyebrow}>
|
||||
<Translate id="home.visual.eyebrow">BackupX Console</Translate>
|
||||
</span>
|
||||
<strong>
|
||||
<Translate id="home.visual.title">Operations overview</Translate>
|
||||
</strong>
|
||||
</div>
|
||||
<span className={styles.consoleStatus}>
|
||||
<Translate id="home.visual.status">Healthy</Translate>
|
||||
</span>
|
||||
</div>
|
||||
<pre className={styles.codeBody}>
|
||||
<code>
|
||||
<span className={styles.codeComment}># Docker one-liner</span>{'\n'}
|
||||
<span className={styles.codePrompt}>$</span> docker run -d --name backupx \{'\n'}
|
||||
{' '}-p 8340:8340 \{'\n'}
|
||||
{' '}-v backupx-data:/app/data \{'\n'}
|
||||
{' '}awuqing/backupx:latest{'\n'}
|
||||
{'\n'}
|
||||
<span className={styles.codeComment}># Open http://localhost:8340</span>{'\n'}
|
||||
<span className={styles.codeComment}># Deploy an Agent on a remote host</span>{'\n'}
|
||||
<span className={styles.codePrompt}>$</span> backupx agent \{'\n'}
|
||||
{' '}--master <span className={styles.codeString}>http://master:8340</span> \{'\n'}
|
||||
{' '}--token <span className={styles.codeString}><token></span>
|
||||
</code>
|
||||
</pre>
|
||||
<div className={styles.consoleGrid}>
|
||||
<div>
|
||||
<span className={styles.consoleLabel}>
|
||||
<Translate id="home.visual.success">Success rate</Translate>
|
||||
</span>
|
||||
<strong>99.4%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.consoleLabel}>
|
||||
<Translate id="home.visual.nodes">Active nodes</Translate>
|
||||
</span>
|
||||
<strong>12</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.consoleLabel}>
|
||||
<Translate id="home.visual.targets">Storage targets</Translate>
|
||||
</span>
|
||||
<strong>8</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.timeline}>
|
||||
<div className={styles.timelineRow}>
|
||||
<span className={styles.timelineDotOk} />
|
||||
<div>
|
||||
<strong>
|
||||
<Translate id="home.visual.row1.title">PostgreSQL nightly</Translate>
|
||||
</strong>
|
||||
<span>
|
||||
<Translate id="home.visual.row1.desc">Encrypted archive uploaded to S3</Translate>
|
||||
</span>
|
||||
</div>
|
||||
<em>02:10</em>
|
||||
</div>
|
||||
<div className={styles.timelineRow}>
|
||||
<span className={styles.timelineDotInfo} />
|
||||
<div>
|
||||
<strong>
|
||||
<Translate id="home.visual.row2.title">SAP HANA snapshot</Translate>
|
||||
</strong>
|
||||
<span>
|
||||
<Translate id="home.visual.row2.desc">Running on agent-shanghai-02</Translate>
|
||||
</span>
|
||||
</div>
|
||||
<em>68%</em>
|
||||
</div>
|
||||
<div className={styles.timelineRow}>
|
||||
<span className={styles.timelineDotWarn} />
|
||||
<div>
|
||||
<strong>
|
||||
<Translate id="home.visual.row3.title">Retention cleanup</Translate>
|
||||
</strong>
|
||||
<span>
|
||||
<Translate id="home.visual.row3.desc">Next run in 4 hours</Translate>
|
||||
</span>
|
||||
</div>
|
||||
<em>queued</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.commandCard}>
|
||||
<div className={styles.commandTitle}>
|
||||
<Translate id="home.command.title">Start with Docker</Translate>
|
||||
</div>
|
||||
<code>docker run -d -p 8340:8340 awuqing/backupx:v2.2.1</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,12 +156,13 @@ export default function Home(): ReactNode {
|
||||
const {siteConfig} = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title={translate({id: 'home.pageTitle', message: 'Self-hosted backup management'})}
|
||||
title={translate({id: 'home.pageTitle', message: 'Backup orchestration for self-hosted servers'})}
|
||||
description={siteConfig.tagline}>
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
<HomepageShowcase />
|
||||
<HomepageCommunity />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
39
docs-site/src/pages/sponsors.tsx
Normal file
39
docs-site/src/pages/sponsors.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {translate} from '@docusaurus/Translate';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import Layout from '@theme/Layout';
|
||||
import Heading from '@theme/Heading';
|
||||
import {HomepageSponsors} from '@site/src/components/HomepageCommunity';
|
||||
import styles from '@site/src/components/HomepageCommunity/styles.module.css';
|
||||
|
||||
export default function Sponsors(): ReactNode {
|
||||
return (
|
||||
<Layout
|
||||
title={translate({id: 'sponsors.pageTitle', message: 'Sponsors'})}
|
||||
description={translate({
|
||||
id: 'sponsors.pageDescription',
|
||||
message: 'Sponsor BackupX reliability, documentation, storage compatibility and long-term maintenance.',
|
||||
})}>
|
||||
<main>
|
||||
<section className={styles.section}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionHead}>
|
||||
<div className={styles.sectionTag}>
|
||||
<Translate id="sponsors.tag">SPONSORS</Translate>
|
||||
</div>
|
||||
<Heading as="h1" className={styles.sectionTitle}>
|
||||
<Translate id="sponsors.title">Sponsor the BackupX ecosystem</Translate>
|
||||
</Heading>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
<Translate id="sponsors.subtitle">
|
||||
Sponsorship helps keep BackupX practical for real operators: tested storage providers, reliable releases, restore confidence and better documentation.
|
||||
</Translate>
|
||||
</p>
|
||||
</div>
|
||||
<HomepageSponsors />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/rclone/rclone v1.73.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/spf13/viper v1.20.0
|
||||
@@ -181,7 +182,6 @@ require (
|
||||
github.com/pkg/xattr v0.4.12 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
|
||||
@@ -60,9 +60,9 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
|
||||
jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
|
||||
rateLimiter := security.NewLoginRateLimiter(5, time.Minute)
|
||||
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter)
|
||||
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
|
||||
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
|
||||
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter, configCipher)
|
||||
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
|
||||
storageRegistry := storage.NewRegistry(
|
||||
storageRclone.NewLocalDiskFactory(),
|
||||
storageRclone.NewS3Factory(),
|
||||
@@ -87,6 +87,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
retentionService := backupretention.NewService(backupRecordRepo)
|
||||
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
|
||||
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
|
||||
authService.SetNotificationService(notificationService)
|
||||
// 初始化 rclone 传输配置(重试 + 带宽限制)
|
||||
rcloneCtx := storageRclone.ConfiguredContext(ctx, storageRclone.TransferConfig{
|
||||
LowLevelRetries: cfg.Backup.Retries,
|
||||
@@ -245,32 +246,32 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
metricsCollector.Start(ctx)
|
||||
|
||||
router := aphttp.NewRouter(aphttp.RouterDependencies{
|
||||
Context: ctx,
|
||||
Config: cfg,
|
||||
Version: version,
|
||||
Logger: appLogger,
|
||||
AuthService: authService,
|
||||
SystemService: systemService,
|
||||
StorageTargetService: storageTargetService,
|
||||
BackupTaskService: backupTaskService,
|
||||
BackupExecutionService: backupExecutionService,
|
||||
BackupRecordService: backupRecordService,
|
||||
RestoreService: restoreService,
|
||||
VerificationService: verificationService,
|
||||
ReplicationService: replicationService,
|
||||
TaskTemplateService: taskTemplateService,
|
||||
TaskExportService: taskExportService,
|
||||
SearchService: searchService,
|
||||
EventBroadcaster: eventBroadcaster,
|
||||
UserService: userService,
|
||||
ApiKeyService: apiKeyService,
|
||||
NotificationService: notificationService,
|
||||
DashboardService: dashboardService,
|
||||
SettingsService: settingsService,
|
||||
Context: ctx,
|
||||
Config: cfg,
|
||||
Version: version,
|
||||
Logger: appLogger,
|
||||
AuthService: authService,
|
||||
SystemService: systemService,
|
||||
StorageTargetService: storageTargetService,
|
||||
BackupTaskService: backupTaskService,
|
||||
BackupExecutionService: backupExecutionService,
|
||||
BackupRecordService: backupRecordService,
|
||||
RestoreService: restoreService,
|
||||
VerificationService: verificationService,
|
||||
ReplicationService: replicationService,
|
||||
TaskTemplateService: taskTemplateService,
|
||||
TaskExportService: taskExportService,
|
||||
SearchService: searchService,
|
||||
EventBroadcaster: eventBroadcaster,
|
||||
UserService: userService,
|
||||
ApiKeyService: apiKeyService,
|
||||
NotificationService: notificationService,
|
||||
DashboardService: dashboardService,
|
||||
SettingsService: settingsService,
|
||||
NodeService: nodeService,
|
||||
AgentService: agentService,
|
||||
DatabaseDiscoveryService: databaseDiscoveryService,
|
||||
AuditService: auditService,
|
||||
AuditService: auditService,
|
||||
JWTManager: jwtManager,
|
||||
UserRepository: userRepo,
|
||||
SystemConfigRepo: systemConfigRepo,
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net"
|
||||
stdhttp "net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
trustedDeviceCookieName = "backupx_trusted_device"
|
||||
trustedDeviceCookiePath = "/api/auth"
|
||||
trustedDeviceCookieMaxAge = int((30 * 24 * time.Hour) / time.Second)
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
}
|
||||
@@ -44,11 +55,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(input.TrustedDeviceToken) == "" {
|
||||
input.TrustedDeviceToken = trustedDeviceCookieValue(c)
|
||||
}
|
||||
payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if payload.TrustedDeviceToken != "" {
|
||||
setTrustedDeviceCookie(c, payload.TrustedDeviceToken)
|
||||
payload.TrustedDeviceToken = ""
|
||||
}
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
@@ -83,9 +101,315 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
clearTrustedDeviceCookie(c)
|
||||
response.Success(c, gin.H{"changed": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) PrepareTwoFactor(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.TwoFactorSetupInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
payload, err := h.authService.PrepareTwoFactor(c.Request.Context(), subject, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) EnableTwoFactor(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.EnableTwoFactorInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
user, err := h.authService.EnableTwoFactor(c.Request.Context(), subject, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) DisableTwoFactor(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.DisableTwoFactorInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
user, err := h.authService.DisableTwoFactor(c.Request.Context(), subject, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if !user.MFAEnabled {
|
||||
clearTrustedDeviceCookie(c)
|
||||
}
|
||||
response.Success(c, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.RegenerateRecoveryCodesInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
payload, err := h.authService.RegenerateRecoveryCodes(c.Request.Context(), subject, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ConfigureOTP(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.OTPConfigInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_OTP_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
user, err := h.authService.ConfigureOutOfBandOTP(c.Request.Context(), subject, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if !user.MFAEnabled {
|
||||
clearTrustedDeviceCookie(c)
|
||||
}
|
||||
response.Success(c, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) SendLoginOTP(c *gin.Context) {
|
||||
var input service.LoginOTPInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_OTP_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
if err := h.authService.SendLoginOTP(c.Request.Context(), input, ClientKey(c)); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"sent": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) BeginWebAuthnRegistration(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.WebAuthnRegistrationOptionsInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
options, err := h.authService.BeginWebAuthnRegistration(c.Request.Context(), subject, input, webAuthnRequestContext(c))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, options)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) FinishWebAuthnRegistration(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.WebAuthnRegistrationFinishInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
user, err := h.authService.FinishWebAuthnRegistration(c.Request.Context(), subject, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) BeginWebAuthnLogin(c *gin.Context) {
|
||||
var input service.WebAuthnLoginOptionsInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
options, err := h.authService.BeginWebAuthnLogin(c.Request.Context(), input, webAuthnRequestContext(c), ClientKey(c))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, options)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ListWebAuthnCredentials(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
items, err := h.authService.ListWebAuthnCredentials(c.Request.Context(), subject)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, items)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) DeleteWebAuthnCredential(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.WebAuthnCredentialDeleteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
user, err := h.authService.DeleteWebAuthnCredential(c.Request.Context(), subject, c.Param("id"), input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if !user.MFAEnabled {
|
||||
clearTrustedDeviceCookie(c)
|
||||
}
|
||||
response.Success(c, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ListTrustedDevices(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
items, err := h.authService.ListTrustedDevices(c.Request.Context(), subject)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, items)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RevokeTrustedDevice(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.TrustedDeviceRevokeInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_TRUSTED_DEVICE_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
if err := h.authService.RevokeTrustedDevice(c.Request.Context(), subject, c.Param("id"), input); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
clearTrustedDeviceCookie(c)
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
response.Success(c, gin.H{"loggedOut": true})
|
||||
}
|
||||
|
||||
func webAuthnRequestContext(c *gin.Context) service.WebAuthnRequestContext {
|
||||
host := firstForwardedValue(c.Request.Host)
|
||||
if forwardedHost := firstForwardedValue(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
rpID := host
|
||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||
rpID = parsedHost
|
||||
}
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if forwardedProto := firstForwardedValue(c.GetHeader("X-Forwarded-Proto")); forwardedProto != "" {
|
||||
scheme = forwardedProto
|
||||
}
|
||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||
if origin == "" {
|
||||
origin = scheme + "://" + host
|
||||
}
|
||||
return service.WebAuthnRequestContext{RPID: rpID, Origin: origin}
|
||||
}
|
||||
|
||||
func firstForwardedValue(value string) string {
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
|
||||
func trustedDeviceCookieValue(c *gin.Context) string {
|
||||
token, err := c.Cookie(trustedDeviceCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
|
||||
func setTrustedDeviceCookie(c *gin.Context, token string) {
|
||||
writeTrustedDeviceCookie(c, strings.TrimSpace(token), trustedDeviceCookieMaxAge)
|
||||
}
|
||||
|
||||
func clearTrustedDeviceCookie(c *gin.Context) {
|
||||
writeTrustedDeviceCookie(c, "", -1)
|
||||
}
|
||||
|
||||
func writeTrustedDeviceCookie(c *gin.Context, value string, maxAge int) {
|
||||
c.SetSameSite(stdhttp.SameSiteLaxMode)
|
||||
c.SetCookie(trustedDeviceCookieName, value, maxAge, trustedDeviceCookiePath, "", requestIsSecure(c), true)
|
||||
}
|
||||
|
||||
func requestIsSecure(c *gin.Context) bool {
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(firstForwardedValue(c.GetHeader("X-Forwarded-Proto")), "https")
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/security"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/internal/storage/codec"
|
||||
)
|
||||
|
||||
// setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router,
|
||||
@@ -40,6 +41,13 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
|
||||
if err != nil {
|
||||
t.Fatalf("db: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("sql db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = sqlDB.Close()
|
||||
})
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
systemConfigRepo := repository.NewSystemConfigRepository(db)
|
||||
@@ -48,7 +56,7 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
|
||||
t.Fatalf("security: %v", err)
|
||||
}
|
||||
jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour)
|
||||
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute))
|
||||
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute), codec.NewConfigCipher(resolved.EncryptionKey))
|
||||
systemSvc := service.NewSystemService(cfg, "test", time.Now().UTC())
|
||||
|
||||
nodeRepo := repository.NewNodeRepository(db)
|
||||
|
||||
@@ -94,9 +94,22 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
auth.GET("/setup/status", authHandler.SetupStatus)
|
||||
auth.POST("/setup", authHandler.Setup)
|
||||
auth.POST("/login", authHandler.Login)
|
||||
auth.POST("/otp/send", authHandler.SendLoginOTP)
|
||||
auth.POST("/webauthn/login/options", authHandler.BeginWebAuthnLogin)
|
||||
auth.POST("/logout", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Logout)
|
||||
auth.GET("/profile", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Profile)
|
||||
auth.PUT("/password", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ChangePassword)
|
||||
auth.POST("/2fa/setup", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.PrepareTwoFactor)
|
||||
auth.POST("/2fa/enable", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.EnableTwoFactor)
|
||||
auth.POST("/2fa/recovery-codes", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.RegenerateRecoveryCodes)
|
||||
auth.DELETE("/2fa", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.DisableTwoFactor)
|
||||
auth.PUT("/otp/config", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ConfigureOTP)
|
||||
auth.POST("/webauthn/register/options", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.BeginWebAuthnRegistration)
|
||||
auth.POST("/webauthn/register/finish", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.FinishWebAuthnRegistration)
|
||||
auth.GET("/webauthn/credentials", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ListWebAuthnCredentials)
|
||||
auth.DELETE("/webauthn/credentials/:id", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.DeleteWebAuthnCredential)
|
||||
auth.GET("/trusted-devices", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ListTrustedDevices)
|
||||
auth.DELETE("/trusted-devices/:id", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.RevokeTrustedDevice)
|
||||
}
|
||||
|
||||
system := api.Group("/system")
|
||||
@@ -229,6 +242,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
users.GET("", userHandler.List)
|
||||
users.POST("", userHandler.Create)
|
||||
users.PUT("/:id", userHandler.Update)
|
||||
users.POST("/:id/2fa/reset", userHandler.ResetTwoFactor)
|
||||
users.DELETE("/:id", userHandler.Delete)
|
||||
}
|
||||
|
||||
@@ -279,10 +293,10 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
nodes.PUT("/:id", RequireRole("admin"), nodeHandler.Update)
|
||||
nodes.DELETE("/:id", RequireRole("admin"), nodeHandler.Delete)
|
||||
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
|
||||
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
|
||||
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
|
||||
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
|
||||
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
|
||||
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
|
||||
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
|
||||
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
|
||||
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
|
||||
|
||||
// Agent API(token 认证,无需 JWT)
|
||||
if deps.AgentService != nil {
|
||||
|
||||
@@ -16,50 +16,17 @@ import (
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/security"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/internal/storage/codec"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
func TestSetupLoginAndProfileFlow(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
cfg := config.Config{
|
||||
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
|
||||
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
|
||||
Security: config.SecurityConfig{JWTExpire: "24h"},
|
||||
Log: config.LogConfig{Level: "error"},
|
||||
}
|
||||
|
||||
log, err := logger.New(cfg.Log)
|
||||
if err != nil {
|
||||
t.Fatalf("logger.New error: %v", err)
|
||||
}
|
||||
db, err := database.Open(cfg.Database, log)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open error: %v", err)
|
||||
}
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
systemConfigRepo := repository.NewSystemConfigRepository(db)
|
||||
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSecurity error: %v", err)
|
||||
}
|
||||
jwtManager := security.NewJWTManager(resolved.JWTSecret, time.Hour)
|
||||
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, security.NewLoginRateLimiter(5, time.Minute))
|
||||
systemService := service.NewSystemService(cfg, "test", time.Now().UTC())
|
||||
|
||||
router := NewRouter(RouterDependencies{
|
||||
Config: cfg,
|
||||
Version: "test",
|
||||
Logger: log,
|
||||
AuthService: authService,
|
||||
SystemService: systemService,
|
||||
JWTManager: jwtManager,
|
||||
UserRepository: userRepo,
|
||||
SystemConfigRepo: systemConfigRepo,
|
||||
})
|
||||
router, _ := newTestHTTPRouter(t)
|
||||
|
||||
setupBody, _ := json.Marshal(map[string]string{
|
||||
"username": "admin",
|
||||
"password": "password-123",
|
||||
"username": "admin",
|
||||
"password": "password-123",
|
||||
"displayName": "Admin",
|
||||
})
|
||||
setupRequest := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
|
||||
@@ -92,3 +59,143 @@ func TestSetupLoginAndProfileFlow(t *testing.T) {
|
||||
t.Fatalf("expected profile 200, got %d", profileRecorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedDeviceCookieSkipsMFA(t *testing.T) {
|
||||
router, authService := newTestHTTPRouter(t)
|
||||
if _, err := authService.Setup(context.Background(), service.SetupInput{
|
||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("Setup error: %v", err)
|
||||
}
|
||||
totpSetup, err := authService.PrepareTwoFactor(context.Background(), "1", service.TwoFactorSetupInput{
|
||||
CurrentPassword: "password-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareTwoFactor error: %v", err)
|
||||
}
|
||||
enableCode, err := totp.GenerateCode(totpSetup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode error: %v", err)
|
||||
}
|
||||
if _, err := authService.EnableTwoFactor(context.Background(), "1", service.EnableTwoFactorInput{Code: enableCode}); err != nil {
|
||||
t.Fatalf("EnableTwoFactor error: %v", err)
|
||||
}
|
||||
|
||||
loginCode, err := totp.GenerateCode(totpSetup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode login error: %v", err)
|
||||
}
|
||||
loginBody, _ := json.Marshal(map[string]any{
|
||||
"username": "admin",
|
||||
"password": "password-123",
|
||||
"twoFactorCode": loginCode,
|
||||
"rememberDevice": true,
|
||||
"trustedDeviceName": "test browser",
|
||||
})
|
||||
loginRequest := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(loginBody))
|
||||
loginRequest.Header.Set("Content-Type", "application/json")
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginRecorder, loginRequest)
|
||||
|
||||
if loginRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected login 200, got %d: %s", loginRecorder.Code, loginRecorder.Body.String())
|
||||
}
|
||||
trustedCookie := findCookie(loginRecorder.Result().Cookies(), trustedDeviceCookieName)
|
||||
if trustedCookie == nil {
|
||||
t.Fatalf("expected trusted device cookie")
|
||||
}
|
||||
if !trustedCookie.HttpOnly {
|
||||
t.Fatalf("expected trusted device cookie to be HttpOnly")
|
||||
}
|
||||
if trustedCookie.Path != trustedDeviceCookiePath {
|
||||
t.Fatalf("expected trusted device cookie path %q, got %q", trustedDeviceCookiePath, trustedCookie.Path)
|
||||
}
|
||||
var loginResponse struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
TrustedDeviceToken string `json:"trustedDeviceToken"`
|
||||
TrustedDevice *service.TrustedDeviceOutput `json:"trustedDevice"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(loginRecorder.Body.Bytes(), &loginResponse); err != nil {
|
||||
t.Fatalf("unmarshal login response: %v", err)
|
||||
}
|
||||
if loginResponse.Data.Token == "" || loginResponse.Data.TrustedDevice == nil {
|
||||
t.Fatalf("expected login token and trusted device metadata")
|
||||
}
|
||||
if loginResponse.Data.TrustedDeviceToken != "" {
|
||||
t.Fatalf("trusted device token should not be exposed in response body")
|
||||
}
|
||||
|
||||
secondBody, _ := json.Marshal(map[string]string{
|
||||
"username": "admin",
|
||||
"password": "password-123",
|
||||
})
|
||||
secondRequest := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(secondBody))
|
||||
secondRequest.Header.Set("Content-Type", "application/json")
|
||||
secondRequest.AddCookie(trustedCookie)
|
||||
secondRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(secondRecorder, secondRequest)
|
||||
|
||||
if secondRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected trusted device login 200, got %d: %s", secondRecorder.Code, secondRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newTestHTTPRouter(t *testing.T) (http.Handler, *service.AuthService) {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
cfg := config.Config{
|
||||
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
|
||||
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
|
||||
Security: config.SecurityConfig{JWTExpire: "24h"},
|
||||
Log: config.LogConfig{Level: "error"},
|
||||
}
|
||||
|
||||
log, err := logger.New(cfg.Log)
|
||||
if err != nil {
|
||||
t.Fatalf("logger.New error: %v", err)
|
||||
}
|
||||
db, err := database.Open(cfg.Database, log)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open error: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = sqlDB.Close()
|
||||
})
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
systemConfigRepo := repository.NewSystemConfigRepository(db)
|
||||
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSecurity error: %v", err)
|
||||
}
|
||||
jwtManager := security.NewJWTManager(resolved.JWTSecret, time.Hour)
|
||||
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, security.NewLoginRateLimiter(5, time.Minute), codec.NewConfigCipher(resolved.EncryptionKey))
|
||||
systemService := service.NewSystemService(cfg, "test", time.Now().UTC())
|
||||
|
||||
router := NewRouter(RouterDependencies{
|
||||
Config: cfg,
|
||||
Version: "test",
|
||||
Logger: log,
|
||||
AuthService: authService,
|
||||
SystemService: systemService,
|
||||
JWTManager: jwtManager,
|
||||
UserRepository: userRepo,
|
||||
SystemConfigRepo: systemConfigRepo,
|
||||
})
|
||||
return router, authService
|
||||
}
|
||||
|
||||
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -78,3 +78,18 @@ func (h *UserHandler) Delete(c *gin.Context) {
|
||||
fmt.Sprintf("删除用户 (ID: %d)", id))
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *UserHandler) ResetTwoFactor(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.ResetTwoFactor(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "user", "reset_two_factor", "user", fmt.Sprintf("%d", id), item.Username,
|
||||
fmt.Sprintf("重置用户 %s 的 MFA", item.Username))
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
@@ -22,12 +22,25 @@ func IsValidRole(role string) bool {
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
|
||||
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
|
||||
DisplayName string `gorm:"size:128;not null" json:"displayName"`
|
||||
Email string `gorm:"size:255" json:"email"`
|
||||
Role string `gorm:"size:32;not null;default:admin" json:"role"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
|
||||
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
|
||||
DisplayName string `gorm:"size:128;not null" json:"displayName"`
|
||||
Email string `gorm:"size:255" json:"email"`
|
||||
Phone string `gorm:"size:64" json:"phone"`
|
||||
Role string `gorm:"size:32;not null;default:admin" json:"role"`
|
||||
// TwoFactorSecretCiphertext 保存 TOTP 密钥密文;未启用时可作为待确认密钥。
|
||||
TwoFactorEnabled bool `gorm:"column:two_factor_enabled;not null;default:false" json:"twoFactorEnabled"`
|
||||
TwoFactorSecretCiphertext string `gorm:"column:two_factor_secret_ciphertext;type:text" json:"-"`
|
||||
// TwoFactorRecoveryCodeHashes 保存一次性恢复码哈希的 JSON 数组。
|
||||
TwoFactorRecoveryCodeHashes string `gorm:"column:two_factor_recovery_code_hashes;type:text" json:"-"`
|
||||
// WebAuthnCredentials 保存通行密钥公钥元数据 JSON,不包含私钥或明文密钥。
|
||||
WebAuthnCredentials string `gorm:"column:webauthn_credentials;type:text" json:"-"`
|
||||
WebAuthnChallengeCiphertext string `gorm:"column:webauthn_challenge_ciphertext;type:text" json:"-"`
|
||||
TrustedDevices string `gorm:"column:trusted_devices;type:text" json:"-"`
|
||||
EmailOTPEnabled bool `gorm:"column:email_otp_enabled;not null;default:false" json:"emailOtpEnabled"`
|
||||
SMSOTPEnabled bool `gorm:"column:sms_otp_enabled;not null;default:false" json:"smsOtpEnabled"`
|
||||
OutOfBandOTPCiphertext string `gorm:"column:out_of_band_otp_ciphertext;type:text" json:"-"`
|
||||
// Disabled 禁用账号(不删除保留审计)。禁用后无法登录。
|
||||
Disabled bool `gorm:"not null;default:false" json:"disabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
23
server/internal/security/otp_code.go
Normal file
23
server/internal/security/otp_code.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const LoginOTPDigits = 6
|
||||
|
||||
func GenerateNumericOTP() (string, error) {
|
||||
limit := big.NewInt(1_000_000)
|
||||
value, err := rand.Int(rand.Reader, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%0*d", LoginOTPDigits, value.Int64()), nil
|
||||
}
|
||||
|
||||
func NormalizeNumericOTP(code string) string {
|
||||
return strings.TrimSpace(code)
|
||||
}
|
||||
49
server/internal/security/recovery_code.go
Normal file
49
server/internal/security/recovery_code.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const RecoveryCodeCount = 10
|
||||
|
||||
func GenerateRecoveryCodes(count int) ([]string, error) {
|
||||
if count <= 0 {
|
||||
count = RecoveryCodeCount
|
||||
}
|
||||
codes := make([]string, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
raw := make([]byte, 8)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, fmt.Errorf("generate recovery code: %w", err)
|
||||
}
|
||||
encoded := strings.ToUpper(hex.EncodeToString(raw))
|
||||
codes = append(codes, encoded[0:4]+"-"+encoded[4:8]+"-"+encoded[8:12]+"-"+encoded[12:16])
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
func NormalizeRecoveryCode(code string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsSpace(r) || r == '-' {
|
||||
return -1
|
||||
}
|
||||
return unicode.ToUpper(r)
|
||||
}, strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
func IsRecoveryCodeCandidate(code string) bool {
|
||||
normalized := NormalizeRecoveryCode(code)
|
||||
if len(normalized) != 16 {
|
||||
return false
|
||||
}
|
||||
for _, r := range normalized {
|
||||
if !('0' <= r && r <= '9') && !('A' <= r && r <= 'F') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
68
server/internal/security/totp.go
Normal file
68
server/internal/security/totp.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image/png"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
const TOTPIssuer = "BackupX"
|
||||
|
||||
type TOTPEnrollment struct {
|
||||
Secret string
|
||||
OTPAuthURL string
|
||||
QRCodeDataURL string
|
||||
}
|
||||
|
||||
func GenerateTOTPEnrollment(accountName string) (*TOTPEnrollment, error) {
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: TOTPIssuer,
|
||||
AccountName: accountName,
|
||||
Period: 30,
|
||||
SecretSize: 20,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
image, err := key.Image(220, 220)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TOTPEnrollment{
|
||||
Secret: key.Secret(),
|
||||
OTPAuthURL: key.URL(),
|
||||
QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ValidateTOTPCode(secret string, code string) (bool, error) {
|
||||
return totp.ValidateCustom(NormalizeTOTPCode(code), secret, time.Now().UTC(), totp.ValidateOpts{
|
||||
Period: 30,
|
||||
Skew: 1,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
}
|
||||
|
||||
func NormalizeTOTPCode(code string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsSpace(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(code))
|
||||
}
|
||||
447
server/internal/security/webauthn.go
Normal file
447
server/internal/security/webauthn.go
Normal file
@@ -0,0 +1,447 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
WebAuthnChallengeBytes = 32
|
||||
)
|
||||
|
||||
type WebAuthnCredentialMaterial struct {
|
||||
CredentialID string
|
||||
PublicKeyX string
|
||||
PublicKeyY string
|
||||
SignCount uint32
|
||||
}
|
||||
|
||||
type WebAuthnParsedCredential struct {
|
||||
CredentialID string
|
||||
PublicKeyX string
|
||||
PublicKeyY string
|
||||
SignCount uint32
|
||||
}
|
||||
|
||||
type WebAuthnClientData struct {
|
||||
Type string `json:"type"`
|
||||
Challenge string `json:"challenge"`
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
|
||||
type WebAuthnAttestationResponse struct {
|
||||
ClientDataJSON string `json:"clientDataJSON"`
|
||||
AttestationObject string `json:"attestationObject"`
|
||||
}
|
||||
|
||||
type WebAuthnRegistrationResponse struct {
|
||||
ID string `json:"id"`
|
||||
RawID string `json:"rawId"`
|
||||
Type string `json:"type"`
|
||||
Response WebAuthnAttestationResponse `json:"response"`
|
||||
}
|
||||
|
||||
type WebAuthnAssertionResponse struct {
|
||||
ClientDataJSON string `json:"clientDataJSON"`
|
||||
AuthenticatorData string `json:"authenticatorData"`
|
||||
Signature string `json:"signature"`
|
||||
UserHandle string `json:"userHandle,omitempty"`
|
||||
}
|
||||
|
||||
type WebAuthnLoginAssertion struct {
|
||||
ID string `json:"id"`
|
||||
RawID string `json:"rawId"`
|
||||
Type string `json:"type"`
|
||||
Response WebAuthnAssertionResponse `json:"response"`
|
||||
}
|
||||
|
||||
func GenerateWebAuthnChallenge() (string, error) {
|
||||
buf := make([]byte, WebAuthnChallengeBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return EncodeBase64URL(buf), nil
|
||||
}
|
||||
|
||||
func EncodeBase64URL(data []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func DecodeBase64URL(value string) ([]byte, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("empty base64url value")
|
||||
}
|
||||
if decoded, err := base64.RawURLEncoding.DecodeString(trimmed); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
return base64.URLEncoding.DecodeString(trimmed)
|
||||
}
|
||||
|
||||
func VerifyWebAuthnRegistration(input WebAuthnRegistrationResponse, challenge string, rpID string, expectedOrigin string) (*WebAuthnParsedCredential, error) {
|
||||
if input.Type != "public-key" {
|
||||
return nil, fmt.Errorf("unexpected credential type: %s", input.Type)
|
||||
}
|
||||
clientDataRaw, err := DecodeBase64URL(input.Response.ClientDataJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode client data: %w", err)
|
||||
}
|
||||
if err := validateWebAuthnClientData(clientDataRaw, "webauthn.create", challenge, expectedOrigin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attestationObject, err := DecodeBase64URL(input.Response.AttestationObject)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode attestation object: %w", err)
|
||||
}
|
||||
parsed, err := parseCBORExact(attestationObject)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse attestation object: %w", err)
|
||||
}
|
||||
attestationMap, ok := parsed.(map[any]any)
|
||||
if !ok {
|
||||
return nil, errors.New("attestation object is not a map")
|
||||
}
|
||||
authData, ok := attestationMap["authData"].([]byte)
|
||||
if !ok {
|
||||
return nil, errors.New("attestation authData is missing")
|
||||
}
|
||||
credential, err := parseAttestedCredentialData(authData, rpID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawID := strings.TrimSpace(input.RawID)
|
||||
if rawID == "" {
|
||||
rawID = strings.TrimSpace(input.ID)
|
||||
}
|
||||
if rawID != "" && rawID != credential.CredentialID {
|
||||
return nil, errors.New("credential raw id does not match attested credential id")
|
||||
}
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
func VerifyWebAuthnAssertion(input WebAuthnLoginAssertion, challenge string, rpID string, expectedOrigin string, credential WebAuthnCredentialMaterial) (uint32, error) {
|
||||
if input.Type != "public-key" {
|
||||
return 0, fmt.Errorf("unexpected credential type: %s", input.Type)
|
||||
}
|
||||
rawID := strings.TrimSpace(input.RawID)
|
||||
if rawID == "" {
|
||||
rawID = strings.TrimSpace(input.ID)
|
||||
}
|
||||
if rawID != credential.CredentialID {
|
||||
return 0, errors.New("credential id does not match")
|
||||
}
|
||||
clientDataRaw, err := DecodeBase64URL(input.Response.ClientDataJSON)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("decode client data: %w", err)
|
||||
}
|
||||
if err := validateWebAuthnClientData(clientDataRaw, "webauthn.get", challenge, expectedOrigin); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
authData, err := DecodeBase64URL(input.Response.AuthenticatorData)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("decode authenticator data: %w", err)
|
||||
}
|
||||
signature, err := DecodeBase64URL(input.Response.Signature)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("decode signature: %w", err)
|
||||
}
|
||||
signCount, err := parseAssertionAuthenticatorData(authData, rpID, credential.SignCount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
xBytes, err := DecodeBase64URL(credential.PublicKeyX)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("decode public key x: %w", err)
|
||||
}
|
||||
yBytes, err := DecodeBase64URL(credential.PublicKeyY)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("decode public key y: %w", err)
|
||||
}
|
||||
publicKey := ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(xBytes), Y: new(big.Int).SetBytes(yBytes)}
|
||||
if !publicKey.Curve.IsOnCurve(publicKey.X, publicKey.Y) {
|
||||
return 0, errors.New("webauthn public key is not on P-256 curve")
|
||||
}
|
||||
clientDataHash := sha256.Sum256(clientDataRaw)
|
||||
verifyData := append(append([]byte{}, authData...), clientDataHash[:]...)
|
||||
digest := sha256.Sum256(verifyData)
|
||||
if !ecdsa.VerifyASN1(&publicKey, digest[:], signature) {
|
||||
return 0, errors.New("invalid webauthn signature")
|
||||
}
|
||||
return signCount, nil
|
||||
}
|
||||
|
||||
func validateWebAuthnClientData(raw []byte, expectedType string, challenge string, expectedOrigin string) error {
|
||||
var clientData WebAuthnClientData
|
||||
if err := json.Unmarshal(raw, &clientData); err != nil {
|
||||
return fmt.Errorf("parse client data: %w", err)
|
||||
}
|
||||
if clientData.Type != expectedType {
|
||||
return fmt.Errorf("unexpected webauthn client data type: %s", clientData.Type)
|
||||
}
|
||||
if clientData.Challenge != challenge {
|
||||
return errors.New("webauthn challenge mismatch")
|
||||
}
|
||||
if expectedOrigin != "" && clientData.Origin != expectedOrigin {
|
||||
return fmt.Errorf("webauthn origin mismatch: %s", clientData.Origin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAttestedCredentialData(authData []byte, rpID string) (*WebAuthnParsedCredential, error) {
|
||||
signCount, credentialData, err := parseAuthenticatorDataHeader(authData, rpID, true, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(credentialData) < 18 {
|
||||
return nil, errors.New("attested credential data is too short")
|
||||
}
|
||||
offset := 16
|
||||
credentialIDLength := int(binary.BigEndian.Uint16(credentialData[offset : offset+2]))
|
||||
offset += 2
|
||||
if credentialIDLength <= 0 || len(credentialData) < offset+credentialIDLength {
|
||||
return nil, errors.New("invalid credential id length")
|
||||
}
|
||||
credentialID := credentialData[offset : offset+credentialIDLength]
|
||||
offset += credentialIDLength
|
||||
publicKeyRaw := credentialData[offset:]
|
||||
publicKey, err := parseCBOR(publicKeyRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse credential public key: %w", err)
|
||||
}
|
||||
publicKeyMap, ok := publicKey.(map[any]any)
|
||||
if !ok {
|
||||
return nil, errors.New("credential public key is not a map")
|
||||
}
|
||||
kty, err := coseInt(publicKeyMap, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alg, err := coseInt(publicKeyMap, 3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
crv, err := coseInt(publicKeyMap, -1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kty != 2 || alg != -7 || crv != 1 {
|
||||
return nil, fmt.Errorf("unsupported COSE key: kty=%d alg=%d crv=%d", kty, alg, crv)
|
||||
}
|
||||
x, err := coseBytes(publicKeyMap, -2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
y, err := coseBytes(publicKeyMap, -3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !elliptic.P256().IsOnCurve(new(big.Int).SetBytes(x), new(big.Int).SetBytes(y)) {
|
||||
return nil, errors.New("credential public key is not on P-256 curve")
|
||||
}
|
||||
return &WebAuthnParsedCredential{
|
||||
CredentialID: EncodeBase64URL(credentialID),
|
||||
PublicKeyX: EncodeBase64URL(x),
|
||||
PublicKeyY: EncodeBase64URL(y),
|
||||
SignCount: signCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseAssertionAuthenticatorData(authData []byte, rpID string, previousSignCount uint32) (uint32, error) {
|
||||
signCount, _, err := parseAuthenticatorDataHeader(authData, rpID, false, previousSignCount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return signCount, nil
|
||||
}
|
||||
|
||||
func parseAuthenticatorDataHeader(authData []byte, rpID string, requireAttestedData bool, previousSignCount uint32) (uint32, []byte, error) {
|
||||
if len(authData) < 37 {
|
||||
return 0, nil, errors.New("authenticator data is too short")
|
||||
}
|
||||
expectedRPIDHash := sha256.Sum256([]byte(rpID))
|
||||
if string(authData[:32]) != string(expectedRPIDHash[:]) {
|
||||
return 0, nil, errors.New("rp id hash mismatch")
|
||||
}
|
||||
flags := authData[32]
|
||||
if flags&0x01 == 0 {
|
||||
return 0, nil, errors.New("user presence flag is missing")
|
||||
}
|
||||
signCount := binary.BigEndian.Uint32(authData[33:37])
|
||||
if previousSignCount > 0 && signCount > 0 && signCount <= previousSignCount {
|
||||
return 0, nil, errors.New("authenticator sign count did not increase")
|
||||
}
|
||||
if requireAttestedData && flags&0x40 == 0 {
|
||||
return 0, nil, errors.New("attested credential data flag is missing")
|
||||
}
|
||||
return signCount, authData[37:], nil
|
||||
}
|
||||
|
||||
func coseInt(m map[any]any, key int64) (int64, error) {
|
||||
value, ok := m[key]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("missing COSE key %d", key)
|
||||
}
|
||||
intValue, ok := value.(int64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid COSE key %d", key)
|
||||
}
|
||||
return intValue, nil
|
||||
}
|
||||
|
||||
func coseBytes(m map[any]any, key int64) ([]byte, error) {
|
||||
value, ok := m[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing COSE key %d", key)
|
||||
}
|
||||
bytesValue, ok := value.([]byte)
|
||||
if !ok || len(bytesValue) == 0 {
|
||||
return nil, fmt.Errorf("invalid COSE key %d", key)
|
||||
}
|
||||
return bytesValue, nil
|
||||
}
|
||||
|
||||
func parseCBOR(data []byte) (any, error) {
|
||||
reader := cborReader{data: data}
|
||||
value, err := reader.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseCBORExact(data []byte) (any, error) {
|
||||
reader := cborReader{data: data}
|
||||
value, err := reader.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reader.pos != len(data) {
|
||||
return nil, errors.New("trailing cbor data")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
type cborReader struct {
|
||||
data []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func (r *cborReader) read() (any, error) {
|
||||
if r.pos >= len(r.data) {
|
||||
return nil, errors.New("unexpected cbor eof")
|
||||
}
|
||||
initial := r.data[r.pos]
|
||||
r.pos++
|
||||
major := initial >> 5
|
||||
additional := initial & 0x1f
|
||||
length, err := r.readLength(additional)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch major {
|
||||
case 0:
|
||||
return int64(length), nil
|
||||
case 1:
|
||||
return -1 - int64(length), nil
|
||||
case 2:
|
||||
return r.readBytes(length)
|
||||
case 3:
|
||||
raw, err := r.readBytes(length)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(raw), nil
|
||||
case 4:
|
||||
out := make([]any, 0, length)
|
||||
for i := uint64(0); i < length; i++ {
|
||||
item, err := r.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
case 5:
|
||||
out := make(map[any]any, length)
|
||||
for i := uint64(0); i < length; i++ {
|
||||
key, err := r.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := r.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out, nil
|
||||
case 7:
|
||||
switch additional {
|
||||
case 20:
|
||||
return false, nil
|
||||
case 21:
|
||||
return true, nil
|
||||
case 22, 23:
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported cbor simple value: %d", additional)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported cbor major type: %d", major)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *cborReader) readLength(additional byte) (uint64, error) {
|
||||
switch {
|
||||
case additional < 24:
|
||||
return uint64(additional), nil
|
||||
case additional == 24:
|
||||
if r.pos+1 > len(r.data) {
|
||||
return 0, errors.New("unexpected cbor eof")
|
||||
}
|
||||
value := r.data[r.pos]
|
||||
r.pos++
|
||||
return uint64(value), nil
|
||||
case additional == 25:
|
||||
if r.pos+2 > len(r.data) {
|
||||
return 0, errors.New("unexpected cbor eof")
|
||||
}
|
||||
value := binary.BigEndian.Uint16(r.data[r.pos : r.pos+2])
|
||||
r.pos += 2
|
||||
return uint64(value), nil
|
||||
case additional == 26:
|
||||
if r.pos+4 > len(r.data) {
|
||||
return 0, errors.New("unexpected cbor eof")
|
||||
}
|
||||
value := binary.BigEndian.Uint32(r.data[r.pos : r.pos+4])
|
||||
r.pos += 4
|
||||
return uint64(value), nil
|
||||
case additional == 27:
|
||||
if r.pos+8 > len(r.data) {
|
||||
return 0, errors.New("unexpected cbor eof")
|
||||
}
|
||||
value := binary.BigEndian.Uint64(r.data[r.pos : r.pos+8])
|
||||
r.pos += 8
|
||||
return value, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported cbor additional info: %d", additional)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *cborReader) readBytes(length uint64) ([]byte, error) {
|
||||
if length > uint64(len(r.data)-r.pos) {
|
||||
return nil, errors.New("unexpected cbor eof")
|
||||
}
|
||||
out := r.data[r.pos : r.pos+int(length)]
|
||||
r.pos += int(length)
|
||||
return out, nil
|
||||
}
|
||||
179
server/internal/service/auth_methods.go
Normal file
179
server/internal/service/auth_methods.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
mfaChallengeTTL = 5 * time.Minute
|
||||
trustedDeviceTTL = 30 * 24 * time.Hour
|
||||
maxTrustedDeviceName = 128
|
||||
maxTrustedDevices = 10
|
||||
)
|
||||
|
||||
type WebAuthnCredentialRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CredentialID string `json:"credentialId"`
|
||||
PublicKeyX string `json:"publicKeyX"`
|
||||
PublicKeyY string `json:"publicKeyY"`
|
||||
SignCount uint32 `json:"signCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
}
|
||||
|
||||
type WebAuthnCredentialOutput struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
}
|
||||
|
||||
type webAuthnChallengeState struct {
|
||||
Type string `json:"type"`
|
||||
Challenge string `json:"challenge"`
|
||||
RPID string `json:"rpId"`
|
||||
Origin string `json:"origin"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type TrustedDeviceRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TokenHash string `json:"tokenHash"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastUsedAt time.Time `json:"lastUsedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
LastIP string `json:"lastIp"`
|
||||
}
|
||||
|
||||
type TrustedDeviceOutput struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
LastUsedAt string `json:"lastUsedAt"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
LastIP string `json:"lastIp"`
|
||||
}
|
||||
|
||||
type pendingOutOfBandOTP struct {
|
||||
Channel string `json:"channel"`
|
||||
CodeHash string `json:"codeHash"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
func userMFAEnabled(user *model.User) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
return user.TwoFactorEnabled ||
|
||||
strings.TrimSpace(user.WebAuthnCredentials) != "" ||
|
||||
user.EmailOTPEnabled ||
|
||||
user.SMSOTPEnabled
|
||||
}
|
||||
|
||||
func clearTrustedDevicesIfMFAOff(user *model.User) {
|
||||
if user == nil || userMFAEnabled(user) {
|
||||
return
|
||||
}
|
||||
user.TrustedDevices = ""
|
||||
user.OutOfBandOTPCiphertext = ""
|
||||
user.WebAuthnChallengeCiphertext = ""
|
||||
}
|
||||
|
||||
func parseWebAuthnCredentials(value string) ([]WebAuthnCredentialRecord, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var credentials []WebAuthnCredentialRecord
|
||||
if err := json.Unmarshal([]byte(value), &credentials); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
func encodeWebAuthnCredentials(credentials []WebAuthnCredentialRecord) (string, error) {
|
||||
if len(credentials) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
encoded, err := json.Marshal(credentials)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func webAuthnCredentialCount(user *model.User) int {
|
||||
if user == nil {
|
||||
return 0
|
||||
}
|
||||
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(credentials)
|
||||
}
|
||||
|
||||
func parseTrustedDevices(value string) ([]TrustedDeviceRecord, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var devices []TrustedDeviceRecord
|
||||
if err := json.Unmarshal([]byte(value), &devices); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func encodeTrustedDevices(devices []TrustedDeviceRecord) (string, error) {
|
||||
if len(devices) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
encoded, err := json.Marshal(devices)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func trustedDeviceCount(user *model.User) int {
|
||||
if user == nil {
|
||||
return 0
|
||||
}
|
||||
devices, err := parseTrustedDevices(user.TrustedDevices)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
count := 0
|
||||
for _, device := range devices {
|
||||
if device.ExpiresAt.After(now) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func toWebAuthnCredentialOutput(record WebAuthnCredentialRecord) WebAuthnCredentialOutput {
|
||||
return WebAuthnCredentialOutput{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
CreatedAt: record.CreatedAt,
|
||||
LastUsedAt: record.LastUsedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toTrustedDeviceOutput(record TrustedDeviceRecord) TrustedDeviceOutput {
|
||||
return TrustedDeviceOutput{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
CreatedAt: record.CreatedAt.Format(time.RFC3339),
|
||||
LastUsedAt: record.LastUsedAt.Format(time.RFC3339),
|
||||
ExpiresAt: record.ExpiresAt.Format(time.RFC3339),
|
||||
LastIP: record.LastIP,
|
||||
}
|
||||
}
|
||||
252
server/internal/service/auth_otp.go
Normal file
252
server/internal/service/auth_otp.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/security"
|
||||
)
|
||||
|
||||
type OTPConfigInput struct {
|
||||
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
|
||||
Channel string `json:"channel" binding:"required,oneof=email sms"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Email string `json:"email" binding:"omitempty,max=255"`
|
||||
Phone string `json:"phone" binding:"omitempty,max=64"`
|
||||
}
|
||||
|
||||
type LoginOTPInput struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=64"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
Channel string `json:"channel" binding:"required,oneof=email sms"`
|
||||
}
|
||||
|
||||
func (s *AuthService) ConfigureOutOfBandOTP(ctx context.Context, subject string, input OTPConfigInput) (*UserOutput, error) {
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
|
||||
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
|
||||
}
|
||||
channel := strings.TrimSpace(input.Channel)
|
||||
previousEmail := strings.TrimSpace(user.Email)
|
||||
previousPhone := strings.TrimSpace(user.Phone)
|
||||
contactChanged := false
|
||||
switch channel {
|
||||
case "email":
|
||||
email := strings.TrimSpace(input.Email)
|
||||
if email != "" {
|
||||
user.Email = email
|
||||
}
|
||||
contactChanged = previousEmail != strings.TrimSpace(user.Email)
|
||||
if input.Enabled && strings.TrimSpace(user.Email) == "" {
|
||||
return nil, apperror.BadRequest("AUTH_EMAIL_REQUIRED", "请先在用户资料中设置邮箱", nil)
|
||||
}
|
||||
user.EmailOTPEnabled = input.Enabled
|
||||
case "sms":
|
||||
phone := strings.TrimSpace(input.Phone)
|
||||
if phone != "" {
|
||||
user.Phone = phone
|
||||
}
|
||||
contactChanged = previousPhone != strings.TrimSpace(user.Phone)
|
||||
if input.Enabled && strings.TrimSpace(user.Phone) == "" {
|
||||
return nil, apperror.BadRequest("AUTH_PHONE_REQUIRED", "请先设置手机号", nil)
|
||||
}
|
||||
user.SMSOTPEnabled = input.Enabled
|
||||
default:
|
||||
return nil, apperror.BadRequest("AUTH_OTP_CHANNEL_INVALID", "验证码渠道不支持", nil)
|
||||
}
|
||||
if s.shouldClearPendingOTP(user, channel, contactChanged) {
|
||||
user.OutOfBandOTPCiphertext = ""
|
||||
}
|
||||
clearTrustedDevicesIfMFAOff(user)
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return nil, apperror.Internal("AUTH_OTP_CONFIG_FAILED", "无法更新 OTP 配置", err)
|
||||
}
|
||||
if s.auditService != nil {
|
||||
action := "otp_disable"
|
||||
if input.Enabled {
|
||||
action = "otp_enable"
|
||||
}
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: action,
|
||||
TargetType: "otp", TargetID: channel,
|
||||
Detail: fmt.Sprintf("%s %s OTP", map[bool]string{true: "启用", false: "关闭"}[input.Enabled], channel),
|
||||
})
|
||||
}
|
||||
return ToUserOutput(user), nil
|
||||
}
|
||||
|
||||
func (s *AuthService) SendLoginOTP(ctx context.Context, input LoginOTPInput, clientKey string) error {
|
||||
user, err := s.verifyPasswordForMFAStart(ctx, input.Username, input.Password, clientKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
channel := strings.TrimSpace(input.Channel)
|
||||
if channel == "email" && !user.EmailOTPEnabled {
|
||||
return apperror.BadRequest("AUTH_EMAIL_OTP_DISABLED", "当前账号未启用邮件验证码", nil)
|
||||
}
|
||||
if channel == "sms" && !user.SMSOTPEnabled {
|
||||
return apperror.BadRequest("AUTH_SMS_OTP_DISABLED", "当前账号未启用短信验证码", nil)
|
||||
}
|
||||
code, err := security.GenerateNumericOTP()
|
||||
if err != nil {
|
||||
return apperror.Internal("AUTH_OTP_GENERATE_FAILED", "无法生成登录验证码", err)
|
||||
}
|
||||
hash, err := security.HashPassword(code)
|
||||
if err != nil {
|
||||
return apperror.Internal("AUTH_OTP_GENERATE_FAILED", "无法处理登录验证码", err)
|
||||
}
|
||||
pending := pendingOutOfBandOTP{
|
||||
Channel: channel,
|
||||
CodeHash: hash,
|
||||
ExpiresAt: time.Now().UTC().Add(mfaChallengeTTL),
|
||||
}
|
||||
ciphertext, err := s.twoFactorCipher.EncryptJSON(pending)
|
||||
if err != nil {
|
||||
return apperror.Internal("AUTH_OTP_SAVE_FAILED", "无法保存登录验证码状态", err)
|
||||
}
|
||||
user.OutOfBandOTPCiphertext = ciphertext
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return apperror.Internal("AUTH_OTP_SAVE_FAILED", "无法保存登录验证码状态", err)
|
||||
}
|
||||
if err := s.deliverLoginOTP(ctx, user, channel, code); err != nil {
|
||||
user.OutOfBandOTPCiphertext = ""
|
||||
if updateErr := s.users.Update(ctx, user); updateErr != nil {
|
||||
return apperror.Internal("AUTH_OTP_SAVE_FAILED", "登录验证码发送失败,且无法回滚验证码状态", updateErr)
|
||||
}
|
||||
return apperror.BadRequest("AUTH_OTP_DELIVERY_FAILED", "登录验证码发送失败", err)
|
||||
}
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "otp_send",
|
||||
TargetType: "otp", TargetID: channel,
|
||||
Detail: "发送登录 OTP", ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) consumeOutOfBandOTP(ctx context.Context, user *model.User, code string, clientKey string) (bool, error) {
|
||||
if strings.TrimSpace(user.OutOfBandOTPCiphertext) == "" {
|
||||
return false, nil
|
||||
}
|
||||
var pending pendingOutOfBandOTP
|
||||
if err := s.twoFactorCipher.DecryptJSON(user.OutOfBandOTPCiphertext, &pending); err != nil {
|
||||
return false, apperror.Internal("AUTH_OTP_INVALID", "登录验证码状态异常", err)
|
||||
}
|
||||
if pending.ExpiresAt.Before(time.Now().UTC()) {
|
||||
user.OutOfBandOTPCiphertext = ""
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return false, apperror.Internal("AUTH_OTP_CONSUME_FAILED", "无法更新登录验证码状态", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if !outOfBandOTPChannelEnabled(user, pending.Channel) {
|
||||
user.OutOfBandOTPCiphertext = ""
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return false, apperror.Internal("AUTH_OTP_CONSUME_FAILED", "无法更新登录验证码状态", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if security.ComparePassword(pending.CodeHash, security.NormalizeNumericOTP(code)) != nil {
|
||||
return false, nil
|
||||
}
|
||||
user.OutOfBandOTPCiphertext = ""
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return false, apperror.Internal("AUTH_OTP_CONSUME_FAILED", "无法使用登录验证码", err)
|
||||
}
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "otp_used",
|
||||
TargetType: "otp", TargetID: pending.Channel,
|
||||
Detail: "使用登录 OTP 完成登录", ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) deliverLoginOTP(ctx context.Context, user *model.User, channel string, code string) error {
|
||||
if s.notificationService == nil {
|
||||
return fmt.Errorf("notification service is not configured")
|
||||
}
|
||||
switch channel {
|
||||
case "email":
|
||||
email := strings.TrimSpace(user.Email)
|
||||
if email == "" {
|
||||
return fmt.Errorf("user email is empty")
|
||||
}
|
||||
return s.notificationService.SendAuthEmailOTP(ctx, email, code)
|
||||
case "sms":
|
||||
phone := strings.TrimSpace(user.Phone)
|
||||
if phone == "" {
|
||||
return fmt.Errorf("user phone is empty")
|
||||
}
|
||||
return s.notificationService.SendAuthSMSOTP(ctx, phone, code)
|
||||
default:
|
||||
return fmt.Errorf("unsupported otp channel: %s", channel)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) verifyPasswordForMFAStart(ctx context.Context, username string, password string, clientKey string) (*model.User, error) {
|
||||
if clientKey == "" {
|
||||
clientKey = "unknown"
|
||||
}
|
||||
if !s.rateLimiter.Allow(clientKey) {
|
||||
return nil, apperror.TooManyRequests("AUTH_RATE_LIMITED", "登录尝试过于频繁,请稍后再试", nil)
|
||||
}
|
||||
user, err := s.users.FindByUsername(ctx, strings.TrimSpace(username))
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_LOOKUP_FAILED", "无法执行登录校验", err)
|
||||
}
|
||||
if user == nil || user.Disabled {
|
||||
return nil, apperror.Unauthorized("AUTH_INVALID_CREDENTIALS", "用户名或密码错误", nil)
|
||||
}
|
||||
if err := security.ComparePassword(user.PasswordHash, password); err != nil {
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "login_failed",
|
||||
Detail: "密码错误", ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
return nil, apperror.Unauthorized("AUTH_INVALID_CREDENTIALS", "用户名或密码错误", err)
|
||||
}
|
||||
if !userMFAEnabled(user) {
|
||||
return nil, apperror.BadRequest("AUTH_MFA_NOT_ENABLED", "当前账号未启用多因素验证", nil)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func outOfBandOTPChannelEnabled(user *model.User, channel string) bool {
|
||||
switch channel {
|
||||
case "email":
|
||||
return user.EmailOTPEnabled
|
||||
case "sms":
|
||||
return user.SMSOTPEnabled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) shouldClearPendingOTP(user *model.User, changedChannel string, contactChanged bool) bool {
|
||||
if !user.EmailOTPEnabled && !user.SMSOTPEnabled {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(user.OutOfBandOTPCiphertext) == "" {
|
||||
return false
|
||||
}
|
||||
var pending pendingOutOfBandOTP
|
||||
if err := s.twoFactorCipher.DecryptJSON(user.OutOfBandOTPCiphertext, &pending); err != nil {
|
||||
return true
|
||||
}
|
||||
return pending.Channel == changedChannel && (contactChanged || !outOfBandOTPChannelEnabled(user, changedChannel))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/security"
|
||||
"backupx/server/internal/storage/codec"
|
||||
)
|
||||
|
||||
type SetupInput struct {
|
||||
@@ -20,28 +22,47 @@ type SetupInput struct {
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=64"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
Username string `json:"username" binding:"required,min=3,max=64"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
TwoFactorCode string `json:"twoFactorCode" binding:"omitempty,min=6,max=32"`
|
||||
WebAuthnAssertion *security.WebAuthnLoginAssertion `json:"webAuthnAssertion"`
|
||||
TrustedDeviceToken string `json:"trustedDeviceToken"`
|
||||
RememberDevice bool `json:"rememberDevice"`
|
||||
TrustedDeviceName string `json:"trustedDeviceName" binding:"omitempty,max=128"`
|
||||
}
|
||||
|
||||
type AuthPayload struct {
|
||||
Token string `json:"token"`
|
||||
User *UserOutput `json:"user"`
|
||||
Token string `json:"token"`
|
||||
User *UserOutput `json:"user"`
|
||||
TrustedDeviceToken string `json:"trustedDeviceToken,omitempty"`
|
||||
TrustedDevice *TrustedDeviceOutput `json:"trustedDevice,omitempty"`
|
||||
}
|
||||
|
||||
type UserOutput struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
MFAEnabled bool `json:"mfaEnabled"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
TwoFactorRecoveryCodesRemaining int `json:"twoFactorRecoveryCodesRemaining"`
|
||||
WebAuthnEnabled bool `json:"webAuthnEnabled"`
|
||||
WebAuthnCredentialCount int `json:"webAuthnCredentialCount"`
|
||||
TrustedDeviceCount int `json:"trustedDeviceCount"`
|
||||
EmailOTPEnabled bool `json:"emailOtpEnabled"`
|
||||
SMSOTPEnabled bool `json:"smsOtpEnabled"`
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
users repository.UserRepository
|
||||
configs repository.SystemConfigRepository
|
||||
jwtManager *security.JWTManager
|
||||
rateLimiter *security.LoginRateLimiter
|
||||
auditService *AuditService
|
||||
users repository.UserRepository
|
||||
configs repository.SystemConfigRepository
|
||||
jwtManager *security.JWTManager
|
||||
rateLimiter *security.LoginRateLimiter
|
||||
twoFactorCipher *codec.ConfigCipher
|
||||
auditService *AuditService
|
||||
notificationService *NotificationService
|
||||
}
|
||||
|
||||
func NewAuthService(
|
||||
@@ -49,14 +70,25 @@ func NewAuthService(
|
||||
configs repository.SystemConfigRepository,
|
||||
jwtManager *security.JWTManager,
|
||||
rateLimiter *security.LoginRateLimiter,
|
||||
twoFactorCipher *codec.ConfigCipher,
|
||||
) *AuthService {
|
||||
return &AuthService{users: users, configs: configs, jwtManager: jwtManager, rateLimiter: rateLimiter}
|
||||
return &AuthService{
|
||||
users: users,
|
||||
configs: configs,
|
||||
jwtManager: jwtManager,
|
||||
rateLimiter: rateLimiter,
|
||||
twoFactorCipher: twoFactorCipher,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) SetAuditService(auditService *AuditService) {
|
||||
s.auditService = auditService
|
||||
}
|
||||
|
||||
func (s *AuthService) SetNotificationService(notificationService *NotificationService) {
|
||||
s.notificationService = notificationService
|
||||
}
|
||||
|
||||
func (s *AuthService) SetupStatus(ctx context.Context) (bool, error) {
|
||||
count, err := s.users.Count(ctx)
|
||||
if err != nil {
|
||||
@@ -130,7 +162,7 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey str
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
Category: "auth", Action: "login_failed",
|
||||
Detail: fmt.Sprintf("用户名不存在: %s", strings.TrimSpace(input.Username)),
|
||||
Detail: fmt.Sprintf("用户名不存在: %s", strings.TrimSpace(input.Username)),
|
||||
ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
@@ -156,6 +188,20 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey str
|
||||
}
|
||||
return nil, apperror.Unauthorized("AUTH_INVALID_CREDENTIALS", "用户名或密码错误", err)
|
||||
}
|
||||
mfaRequired := userMFAEnabled(user)
|
||||
trustedDeviceUsed := false
|
||||
if mfaRequired {
|
||||
trusted, err := s.verifyTrustedDevice(ctx, user, input.TrustedDeviceToken, clientKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trustedDeviceUsed = trusted
|
||||
if !trusted {
|
||||
if err := s.verifyLoginMFA(ctx, user, input, clientKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.rateLimiter.Reset(clientKey)
|
||||
token, err := s.jwtManager.Generate(user)
|
||||
@@ -163,6 +209,16 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey str
|
||||
return nil, apperror.Internal("AUTH_TOKEN_FAILED", "无法生成访问令牌", err)
|
||||
}
|
||||
|
||||
payload := &AuthPayload{Token: token, User: ToUserOutput(user)}
|
||||
if mfaRequired && !trustedDeviceUsed && input.RememberDevice {
|
||||
deviceToken, device, err := s.issueTrustedDevice(ctx, user, input.TrustedDeviceName, clientKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload.TrustedDeviceToken = deviceToken
|
||||
payload.TrustedDevice = device
|
||||
}
|
||||
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
@@ -171,10 +227,72 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey str
|
||||
})
|
||||
}
|
||||
|
||||
return &AuthPayload{Token: token, User: ToUserOutput(user)}, nil
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) GetCurrentUser(ctx context.Context, subject string) (*UserOutput, error) {
|
||||
func (s *AuthService) verifyLoginMFA(ctx context.Context, user *model.User, input LoginInput, clientKey string) error {
|
||||
if input.WebAuthnAssertion != nil {
|
||||
if err := s.VerifyWebAuthnLogin(ctx, user, *input.WebAuthnAssertion, clientKey); err != nil {
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "login_failed",
|
||||
Detail: "通行密钥校验失败", ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
code := strings.TrimSpace(input.TwoFactorCode)
|
||||
if code == "" {
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "two_factor_required",
|
||||
Detail: "登录需要多因素验证", ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
return apperror.Unauthorized("AUTH_2FA_REQUIRED", "请输入验证码、恢复码或使用通行密钥", nil)
|
||||
}
|
||||
if user.TwoFactorEnabled {
|
||||
secret, err := s.decryptTwoFactorSecret(user.TwoFactorSecretCiphertext)
|
||||
if err != nil {
|
||||
return apperror.Internal("AUTH_2FA_SECRET_INVALID", "TOTP 配置异常", err)
|
||||
}
|
||||
ok, err := security.ValidateTOTPCode(secret, code)
|
||||
if err == nil && ok {
|
||||
return nil
|
||||
}
|
||||
if consumed, err := s.consumeRecoveryCode(ctx, user, code); err != nil {
|
||||
return err
|
||||
} else if consumed {
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "two_factor_recovery_code_used",
|
||||
Detail: "使用恢复码完成登录", ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if consumed, err := s.consumeOutOfBandOTP(ctx, user, code, clientKey); err != nil {
|
||||
return err
|
||||
} else if consumed {
|
||||
return nil
|
||||
}
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "login_failed",
|
||||
Detail: "多因素验证码错误", ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
return apperror.Unauthorized("AUTH_2FA_INVALID", "验证码、恢复码或通行密钥错误", nil)
|
||||
}
|
||||
|
||||
func (s *AuthService) userBySubject(ctx context.Context, subject string) (*model.User, error) {
|
||||
userID, err := strconv.ParseUint(subject, 10, 64)
|
||||
if err != nil {
|
||||
return nil, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效用户身份", err)
|
||||
@@ -186,6 +304,14 @@ func (s *AuthService) GetCurrentUser(ctx context.Context, subject string) (*User
|
||||
if user == nil {
|
||||
return nil, apperror.Unauthorized("AUTH_USER_NOT_FOUND", "当前用户不存在", errors.New("user not found"))
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) GetCurrentUser(ctx context.Context, subject string) (*UserOutput, error) {
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ToUserOutput(user), nil
|
||||
}
|
||||
|
||||
@@ -195,16 +321,9 @@ type ChangePasswordInput struct {
|
||||
}
|
||||
|
||||
func (s *AuthService) ChangePassword(ctx context.Context, subject string, input ChangePasswordInput) error {
|
||||
userID, err := strconv.ParseUint(subject, 10, 64)
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效用户身份", err)
|
||||
}
|
||||
user, err := s.users.FindByID(ctx, uint(userID))
|
||||
if err != nil {
|
||||
return apperror.Internal("AUTH_LOOKUP_FAILED", "无法获取当前用户", err)
|
||||
}
|
||||
if user == nil {
|
||||
return apperror.Unauthorized("AUTH_USER_NOT_FOUND", "当前用户不存在", errors.New("user not found"))
|
||||
return err
|
||||
}
|
||||
if err := security.ComparePassword(user.PasswordHash, input.OldPassword); err != nil {
|
||||
return apperror.BadRequest("AUTH_WRONG_PASSWORD", "旧密码不正确", err)
|
||||
@@ -214,6 +333,9 @@ func (s *AuthService) ChangePassword(ctx context.Context, subject string, input
|
||||
return apperror.Internal("AUTH_HASH_FAILED", "无法处理密码", err)
|
||||
}
|
||||
user.PasswordHash = hash
|
||||
user.TrustedDevices = ""
|
||||
user.OutOfBandOTPCiphertext = ""
|
||||
user.WebAuthnChallengeCiphertext = ""
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return apperror.Internal("AUTH_UPDATE_FAILED", "密码修改失败", err)
|
||||
}
|
||||
@@ -229,15 +351,338 @@ func (s *AuthService) ChangePassword(ctx context.Context, subject string, input
|
||||
return nil
|
||||
}
|
||||
|
||||
type TwoFactorSetupInput struct {
|
||||
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
|
||||
}
|
||||
|
||||
type TwoFactorSetupOutput struct {
|
||||
Secret string `json:"secret"`
|
||||
OTPAuthURL string `json:"otpAuthUrl"`
|
||||
QRCodeDataURL string `json:"qrCodeDataUrl"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
TwoFactorConfirmed bool `json:"twoFactorConfirmed"`
|
||||
}
|
||||
|
||||
type EnableTwoFactorInput struct {
|
||||
Code string `json:"code" binding:"required,min=6,max=10"`
|
||||
}
|
||||
|
||||
type EnableTwoFactorOutput struct {
|
||||
User *UserOutput `json:"user"`
|
||||
RecoveryCodes []string `json:"recoveryCodes"`
|
||||
}
|
||||
|
||||
type DisableTwoFactorInput struct {
|
||||
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
|
||||
Code string `json:"code" binding:"required,min=6,max=32"`
|
||||
}
|
||||
|
||||
type RegenerateRecoveryCodesInput struct {
|
||||
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
|
||||
Code string `json:"code" binding:"required,min=6,max=10"`
|
||||
}
|
||||
|
||||
type RecoveryCodesOutput struct {
|
||||
User *UserOutput `json:"user"`
|
||||
RecoveryCodes []string `json:"recoveryCodes"`
|
||||
}
|
||||
|
||||
func (s *AuthService) PrepareTwoFactor(ctx context.Context, subject string, input TwoFactorSetupInput) (*TwoFactorSetupOutput, error) {
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.TwoFactorEnabled {
|
||||
return nil, apperror.Conflict("AUTH_2FA_ALREADY_ENABLED", "TOTP 已启用", nil)
|
||||
}
|
||||
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
|
||||
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
|
||||
}
|
||||
|
||||
enrollment, err := security.GenerateTOTPEnrollment(user.Username)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_2FA_SETUP_FAILED", "无法生成 TOTP 密钥", err)
|
||||
}
|
||||
ciphertext, err := s.encryptTwoFactorSecret(enrollment.Secret)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_2FA_SAVE_FAILED", "无法保存 TOTP 密钥", err)
|
||||
}
|
||||
user.TwoFactorSecretCiphertext = ciphertext
|
||||
user.TwoFactorEnabled = false
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return nil, apperror.Internal("AUTH_2FA_SAVE_FAILED", "无法保存 TOTP 密钥", err)
|
||||
}
|
||||
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "two_factor_setup",
|
||||
TargetType: "user", TargetID: fmt.Sprintf("%d", user.ID), TargetName: user.Username,
|
||||
Detail: "生成 TOTP 密钥",
|
||||
})
|
||||
}
|
||||
|
||||
return &TwoFactorSetupOutput{
|
||||
Secret: enrollment.Secret,
|
||||
OTPAuthURL: enrollment.OTPAuthURL,
|
||||
QRCodeDataURL: enrollment.QRCodeDataURL,
|
||||
TwoFactorEnabled: false,
|
||||
TwoFactorConfirmed: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) EnableTwoFactor(ctx context.Context, subject string, input EnableTwoFactorInput) (*EnableTwoFactorOutput, error) {
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.TwoFactorEnabled {
|
||||
return nil, apperror.Conflict("AUTH_2FA_ALREADY_ENABLED", "TOTP 已启用", nil)
|
||||
}
|
||||
if strings.TrimSpace(user.TwoFactorSecretCiphertext) == "" {
|
||||
return nil, apperror.BadRequest("AUTH_2FA_NOT_PREPARED", "请先生成 TOTP 密钥", nil)
|
||||
}
|
||||
secret, err := s.decryptTwoFactorSecret(user.TwoFactorSecretCiphertext)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_2FA_SECRET_INVALID", "TOTP 配置异常", err)
|
||||
}
|
||||
ok, err := security.ValidateTOTPCode(secret, input.Code)
|
||||
if err != nil {
|
||||
return nil, apperror.BadRequest("AUTH_2FA_INVALID", "TOTP 验证码格式不正确", err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, apperror.BadRequest("AUTH_2FA_INVALID", "TOTP 验证码错误", nil)
|
||||
}
|
||||
recoveryCodes, recoveryHashes, err := s.generateRecoveryCodeHashes()
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_2FA_RECOVERY_FAILED", "无法生成恢复码", err)
|
||||
}
|
||||
|
||||
user.TwoFactorEnabled = true
|
||||
user.TwoFactorRecoveryCodeHashes = recoveryHashes
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return nil, apperror.Internal("AUTH_2FA_ENABLE_FAILED", "无法启用 TOTP", err)
|
||||
}
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "two_factor_enable",
|
||||
TargetType: "user", TargetID: fmt.Sprintf("%d", user.ID), TargetName: user.Username,
|
||||
Detail: "启用 TOTP",
|
||||
})
|
||||
}
|
||||
return &EnableTwoFactorOutput{User: ToUserOutput(user), RecoveryCodes: recoveryCodes}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) DisableTwoFactor(ctx context.Context, subject string, input DisableTwoFactorInput) (*UserOutput, error) {
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !user.TwoFactorEnabled {
|
||||
return nil, apperror.BadRequest("AUTH_2FA_NOT_ENABLED", "TOTP 未启用", nil)
|
||||
}
|
||||
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
|
||||
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
|
||||
}
|
||||
secret, err := s.decryptTwoFactorSecret(user.TwoFactorSecretCiphertext)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_2FA_SECRET_INVALID", "TOTP 配置异常", err)
|
||||
}
|
||||
ok, err := security.ValidateTOTPCode(secret, input.Code)
|
||||
if err != nil {
|
||||
return nil, apperror.BadRequest("AUTH_2FA_INVALID", "TOTP 验证码格式不正确", err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, apperror.BadRequest("AUTH_2FA_INVALID", "TOTP 验证码错误", nil)
|
||||
}
|
||||
|
||||
user.TwoFactorEnabled = false
|
||||
user.TwoFactorSecretCiphertext = ""
|
||||
user.TwoFactorRecoveryCodeHashes = ""
|
||||
clearTrustedDevicesIfMFAOff(user)
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return nil, apperror.Internal("AUTH_2FA_DISABLE_FAILED", "无法关闭 TOTP", err)
|
||||
}
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "two_factor_disable",
|
||||
TargetType: "user", TargetID: fmt.Sprintf("%d", user.ID), TargetName: user.Username,
|
||||
Detail: "关闭 TOTP",
|
||||
})
|
||||
}
|
||||
return ToUserOutput(user), nil
|
||||
}
|
||||
|
||||
func (s *AuthService) verifyCurrentTOTP(user *model.User, code string) error {
|
||||
secret, err := s.decryptTwoFactorSecret(user.TwoFactorSecretCiphertext)
|
||||
if err != nil {
|
||||
return apperror.Internal("AUTH_2FA_SECRET_INVALID", "TOTP 配置异常", err)
|
||||
}
|
||||
ok, err := security.ValidateTOTPCode(secret, code)
|
||||
if err != nil {
|
||||
return apperror.BadRequest("AUTH_2FA_INVALID", "TOTP 验证码格式不正确", err)
|
||||
}
|
||||
if !ok {
|
||||
return apperror.BadRequest("AUTH_2FA_INVALID", "TOTP 验证码错误", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) RegenerateRecoveryCodes(ctx context.Context, subject string, input RegenerateRecoveryCodesInput) (*RecoveryCodesOutput, error) {
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !user.TwoFactorEnabled {
|
||||
return nil, apperror.BadRequest("AUTH_2FA_NOT_ENABLED", "TOTP 未启用", nil)
|
||||
}
|
||||
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
|
||||
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
|
||||
}
|
||||
if err := s.verifyCurrentTOTP(user, input.Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recoveryCodes, recoveryHashes, err := s.generateRecoveryCodeHashes()
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_2FA_RECOVERY_FAILED", "无法生成恢复码", err)
|
||||
}
|
||||
user.TwoFactorRecoveryCodeHashes = recoveryHashes
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return nil, apperror.Internal("AUTH_2FA_RECOVERY_FAILED", "无法更新恢复码", err)
|
||||
}
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "two_factor_recovery_codes_regenerate",
|
||||
TargetType: "user", TargetID: fmt.Sprintf("%d", user.ID), TargetName: user.Username,
|
||||
Detail: "重新生成 TOTP 恢复码",
|
||||
})
|
||||
}
|
||||
return &RecoveryCodesOutput{User: ToUserOutput(user), RecoveryCodes: recoveryCodes}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) generateRecoveryCodeHashes() ([]string, string, error) {
|
||||
codes, err := security.GenerateRecoveryCodes(security.RecoveryCodeCount)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
hashes := make([]string, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
hash, err := security.HashPassword(security.NormalizeRecoveryCode(code))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
hashes = append(hashes, hash)
|
||||
}
|
||||
encoded, err := encodeRecoveryCodeHashes(hashes)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return codes, encoded, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) consumeRecoveryCode(ctx context.Context, user *model.User, code string) (bool, error) {
|
||||
if !security.IsRecoveryCodeCandidate(code) {
|
||||
return false, nil
|
||||
}
|
||||
hashes, err := parseRecoveryCodeHashes(user.TwoFactorRecoveryCodeHashes)
|
||||
if err != nil {
|
||||
return false, apperror.Internal("AUTH_2FA_RECOVERY_INVALID", "恢复码配置异常", err)
|
||||
}
|
||||
if len(hashes) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
normalized := security.NormalizeRecoveryCode(code)
|
||||
for i, hash := range hashes {
|
||||
if security.ComparePassword(hash, normalized) != nil {
|
||||
continue
|
||||
}
|
||||
hashes = append(hashes[:i], hashes[i+1:]...)
|
||||
encoded, err := encodeRecoveryCodeHashes(hashes)
|
||||
if err != nil {
|
||||
return false, apperror.Internal("AUTH_2FA_RECOVERY_INVALID", "恢复码配置异常", err)
|
||||
}
|
||||
user.TwoFactorRecoveryCodeHashes = encoded
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return false, apperror.Internal("AUTH_2FA_RECOVERY_CONSUME_FAILED", "无法使用恢复码", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) encryptTwoFactorSecret(secret string) (string, error) {
|
||||
if s.twoFactorCipher == nil {
|
||||
return "", errors.New("two-factor cipher is not configured")
|
||||
}
|
||||
return s.twoFactorCipher.Encrypt([]byte(strings.TrimSpace(secret)))
|
||||
}
|
||||
|
||||
func (s *AuthService) decryptTwoFactorSecret(ciphertext string) (string, error) {
|
||||
if s.twoFactorCipher == nil {
|
||||
return "", errors.New("two-factor cipher is not configured")
|
||||
}
|
||||
raw, err := s.twoFactorCipher.Decrypt(strings.TrimSpace(ciphertext))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(raw)), nil
|
||||
}
|
||||
|
||||
func parseRecoveryCodeHashes(encoded string) ([]string, error) {
|
||||
if strings.TrimSpace(encoded) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var hashes []string
|
||||
if err := json.Unmarshal([]byte(encoded), &hashes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hashes, nil
|
||||
}
|
||||
|
||||
func encodeRecoveryCodeHashes(hashes []string) (string, error) {
|
||||
if len(hashes) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
encoded, err := json.Marshal(hashes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func recoveryCodeRemainingCount(user *model.User) int {
|
||||
if user == nil {
|
||||
return 0
|
||||
}
|
||||
hashes, err := parseRecoveryCodeHashes(user.TwoFactorRecoveryCodeHashes)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(hashes)
|
||||
}
|
||||
|
||||
func ToUserOutput(user *model.User) *UserOutput {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
return &UserOutput{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Role: user.Role,
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
Role: user.Role,
|
||||
MFAEnabled: userMFAEnabled(user),
|
||||
TwoFactorEnabled: user.TwoFactorEnabled,
|
||||
TwoFactorRecoveryCodesRemaining: recoveryCodeRemainingCount(user),
|
||||
WebAuthnEnabled: webAuthnCredentialCount(user) > 0,
|
||||
WebAuthnCredentialCount: webAuthnCredentialCount(user),
|
||||
TrustedDeviceCount: trustedDeviceCount(user),
|
||||
EmailOTPEnabled: user.EmailOTPEnabled,
|
||||
SMSOTPEnabled: user.SMSOTPEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/security"
|
||||
"backupx/server/internal/storage/codec"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
type fakeUserRepository struct {
|
||||
@@ -100,6 +103,7 @@ func TestAuthServiceSetupAndLogin(t *testing.T) {
|
||||
&fakeSystemConfigRepository{},
|
||||
security.NewJWTManager("test-secret", time.Hour),
|
||||
security.NewLoginRateLimiter(5, time.Minute),
|
||||
codec.NewConfigCipher("test-encryption-secret"),
|
||||
)
|
||||
|
||||
setupResult, err := service.Setup(context.Background(), SetupInput{
|
||||
@@ -133,6 +137,7 @@ func newTestAuthService() (*AuthService, *fakeUserRepository) {
|
||||
&fakeSystemConfigRepository{},
|
||||
security.NewJWTManager("test-secret", time.Hour),
|
||||
security.NewLoginRateLimiter(5, time.Minute),
|
||||
codec.NewConfigCipher("test-encryption-secret"),
|
||||
)
|
||||
return svc, users
|
||||
}
|
||||
@@ -188,3 +193,425 @@ func TestChangePasswordWrongOld(t *testing.T) {
|
||||
t.Fatalf("expected ChangePassword with wrong old password to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceLoginRequiresTwoFactorWhenEnabled(t *testing.T) {
|
||||
svc, _ := newTestAuthService()
|
||||
_, err := svc.Setup(context.Background(), SetupInput{
|
||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
|
||||
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
|
||||
CurrentPassword: "password-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareTwoFactor: %v", err)
|
||||
}
|
||||
if setup.Secret == "" || setup.QRCodeDataURL == "" || setup.OTPAuthURL == "" {
|
||||
t.Fatalf("expected populated 2FA enrollment, got %#v", setup)
|
||||
}
|
||||
|
||||
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
enabledUser, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code})
|
||||
if err != nil {
|
||||
t.Fatalf("EnableTwoFactor: %v", err)
|
||||
}
|
||||
if !enabledUser.User.TwoFactorEnabled {
|
||||
t.Fatalf("expected 2FA enabled")
|
||||
}
|
||||
if len(enabledUser.RecoveryCodes) != security.RecoveryCodeCount {
|
||||
t.Fatalf("expected %d recovery codes, got %d", security.RecoveryCodeCount, len(enabledUser.RecoveryCodes))
|
||||
}
|
||||
|
||||
_, err = svc.Login(context.Background(), LoginInput{
|
||||
Username: "admin", Password: "password-123",
|
||||
}, "127.0.0.1")
|
||||
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_REQUIRED" {
|
||||
t.Fatalf("expected AUTH_2FA_REQUIRED, got %v", err)
|
||||
}
|
||||
|
||||
loginCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode login: %v", err)
|
||||
}
|
||||
loginResult, err := svc.Login(context.Background(), LoginInput{
|
||||
Username: "admin", Password: "password-123", TwoFactorCode: loginCode,
|
||||
}, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Login with 2FA: %v", err)
|
||||
}
|
||||
if loginResult.Token == "" {
|
||||
t.Fatalf("expected non-empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceDisableTwoFactor(t *testing.T) {
|
||||
svc, _ := newTestAuthService()
|
||||
_, err := svc.Setup(context.Background(), SetupInput{
|
||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
|
||||
CurrentPassword: "password-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareTwoFactor: %v", err)
|
||||
}
|
||||
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
if _, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code}); err != nil {
|
||||
t.Fatalf("EnableTwoFactor: %v", err)
|
||||
}
|
||||
|
||||
disableCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode disable: %v", err)
|
||||
}
|
||||
user, err := svc.DisableTwoFactor(context.Background(), "1", DisableTwoFactorInput{
|
||||
CurrentPassword: "password-123",
|
||||
Code: disableCode,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DisableTwoFactor: %v", err)
|
||||
}
|
||||
if user.TwoFactorEnabled {
|
||||
t.Fatalf("expected 2FA disabled")
|
||||
}
|
||||
|
||||
loginResult, err := svc.Login(context.Background(), LoginInput{
|
||||
Username: "admin", Password: "password-123",
|
||||
}, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Login after disable: %v", err)
|
||||
}
|
||||
if loginResult.Token == "" {
|
||||
t.Fatalf("expected non-empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceRecoveryCodeLoginConsumesCode(t *testing.T) {
|
||||
svc, _ := newTestAuthService()
|
||||
_, err := svc.Setup(context.Background(), SetupInput{
|
||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
|
||||
CurrentPassword: "password-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareTwoFactor: %v", err)
|
||||
}
|
||||
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
enabled, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code})
|
||||
if err != nil {
|
||||
t.Fatalf("EnableTwoFactor: %v", err)
|
||||
}
|
||||
recoveryCode := enabled.RecoveryCodes[0]
|
||||
|
||||
loginResult, err := svc.Login(context.Background(), LoginInput{
|
||||
Username: "admin", Password: "password-123", TwoFactorCode: recoveryCode,
|
||||
}, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Login with recovery code: %v", err)
|
||||
}
|
||||
if loginResult.User.TwoFactorRecoveryCodesRemaining != security.RecoveryCodeCount-1 {
|
||||
t.Fatalf("expected one recovery code consumed, got remaining=%d", loginResult.User.TwoFactorRecoveryCodesRemaining)
|
||||
}
|
||||
|
||||
_, err = svc.Login(context.Background(), LoginInput{
|
||||
Username: "admin", Password: "password-123", TwoFactorCode: recoveryCode,
|
||||
}, "127.0.0.1")
|
||||
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_INVALID" {
|
||||
t.Fatalf("expected consumed recovery code to fail, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceRegenerateRecoveryCodesInvalidatesOldCodes(t *testing.T) {
|
||||
svc, _ := newTestAuthService()
|
||||
_, err := svc.Setup(context.Background(), SetupInput{
|
||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
|
||||
CurrentPassword: "password-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareTwoFactor: %v", err)
|
||||
}
|
||||
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
enabled, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code})
|
||||
if err != nil {
|
||||
t.Fatalf("EnableTwoFactor: %v", err)
|
||||
}
|
||||
oldRecoveryCode := enabled.RecoveryCodes[0]
|
||||
|
||||
regenerateCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode regenerate: %v", err)
|
||||
}
|
||||
regenerated, err := svc.RegenerateRecoveryCodes(context.Background(), "1", RegenerateRecoveryCodesInput{
|
||||
CurrentPassword: "password-123",
|
||||
Code: regenerateCode,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegenerateRecoveryCodes: %v", err)
|
||||
}
|
||||
if len(regenerated.RecoveryCodes) != security.RecoveryCodeCount {
|
||||
t.Fatalf("expected %d recovery codes, got %d", security.RecoveryCodeCount, len(regenerated.RecoveryCodes))
|
||||
}
|
||||
|
||||
_, err = svc.Login(context.Background(), LoginInput{
|
||||
Username: "admin", Password: "password-123", TwoFactorCode: oldRecoveryCode,
|
||||
}, "127.0.0.1")
|
||||
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_INVALID" {
|
||||
t.Fatalf("expected old recovery code to fail, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceTrustedDeviceSkipsMFA(t *testing.T) {
|
||||
svc, repo := newTestAuthService()
|
||||
_, err := svc.Setup(context.Background(), SetupInput{
|
||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
|
||||
CurrentPassword: "password-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareTwoFactor: %v", err)
|
||||
}
|
||||
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
if _, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code}); err != nil {
|
||||
t.Fatalf("EnableTwoFactor: %v", err)
|
||||
}
|
||||
loginCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode login: %v", err)
|
||||
}
|
||||
firstLogin, err := svc.Login(context.Background(), LoginInput{
|
||||
Username: "admin", Password: "password-123", TwoFactorCode: loginCode,
|
||||
RememberDevice: true, TrustedDeviceName: "test browser",
|
||||
}, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Login with 2FA: %v", err)
|
||||
}
|
||||
if firstLogin.TrustedDeviceToken == "" || firstLogin.TrustedDevice == nil {
|
||||
t.Fatalf("expected trusted device token")
|
||||
}
|
||||
secondLogin, err := svc.Login(context.Background(), LoginInput{
|
||||
Username: "admin", Password: "password-123", TrustedDeviceToken: firstLogin.TrustedDeviceToken,
|
||||
}, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Login with trusted device: %v", err)
|
||||
}
|
||||
if secondLogin.Token == "" {
|
||||
t.Fatalf("expected token")
|
||||
}
|
||||
disableCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode disable: %v", err)
|
||||
}
|
||||
if _, err := svc.DisableTwoFactor(context.Background(), "1", DisableTwoFactorInput{
|
||||
CurrentPassword: "password-123",
|
||||
Code: disableCode,
|
||||
}); err != nil {
|
||||
t.Fatalf("DisableTwoFactor: %v", err)
|
||||
}
|
||||
if repo.users[0].TrustedDevices != "" {
|
||||
t.Fatalf("expected trusted devices cleared after disabling last MFA method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceOutOfBandOTPLoginConsumesCode(t *testing.T) {
|
||||
svc, repo := newTestAuthService()
|
||||
_, err := svc.Setup(context.Background(), SetupInput{
|
||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
user := repo.users[0]
|
||||
user.Email = "admin@example.com"
|
||||
user.EmailOTPEnabled = true
|
||||
hash, err := security.HashPassword("123456")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
ciphertext, err := svc.twoFactorCipher.EncryptJSON(pendingOutOfBandOTP{
|
||||
Channel: "email", CodeHash: hash, ExpiresAt: time.Now().UTC().Add(time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptJSON: %v", err)
|
||||
}
|
||||
user.OutOfBandOTPCiphertext = ciphertext
|
||||
if err := repo.Update(context.Background(), user); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
loginResult, err := svc.Login(context.Background(), LoginInput{
|
||||
Username: "admin", Password: "password-123", TwoFactorCode: "123456",
|
||||
}, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Login with email OTP: %v", err)
|
||||
}
|
||||
if loginResult.Token == "" {
|
||||
t.Fatalf("expected token")
|
||||
}
|
||||
if repo.users[0].OutOfBandOTPCiphertext != "" {
|
||||
t.Fatalf("expected OTP to be consumed")
|
||||
}
|
||||
|
||||
_, err = svc.Login(context.Background(), LoginInput{
|
||||
Username: "admin", Password: "password-123", TwoFactorCode: "123456",
|
||||
}, "127.0.0.1")
|
||||
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_INVALID" {
|
||||
t.Fatalf("expected consumed OTP to fail, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceMFAStartIsRateLimited(t *testing.T) {
|
||||
svc, repo := newTestAuthService()
|
||||
_, err := svc.Setup(context.Background(), SetupInput{
|
||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
repo.users[0].Email = "admin@example.com"
|
||||
repo.users[0].EmailOTPEnabled = true
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
_ = svc.SendLoginOTP(context.Background(), LoginOTPInput{
|
||||
Username: "admin", Password: "wrong-password", Channel: "email",
|
||||
}, "127.0.0.1")
|
||||
}
|
||||
err = svc.SendLoginOTP(context.Background(), LoginOTPInput{
|
||||
Username: "admin", Password: "wrong-password", Channel: "email",
|
||||
}, "127.0.0.1")
|
||||
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_RATE_LIMITED" {
|
||||
t.Fatalf("expected AUTH_RATE_LIMITED, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceDisabledOTPChannelCannotConsumePendingCode(t *testing.T) {
|
||||
svc, repo := newTestAuthService()
|
||||
_, err := svc.Setup(context.Background(), SetupInput{
|
||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
user := repo.users[0]
|
||||
user.Email = "admin@example.com"
|
||||
user.EmailOTPEnabled = false
|
||||
user.SMSOTPEnabled = true
|
||||
hash, err := security.HashPassword("123456")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
ciphertext, err := svc.twoFactorCipher.EncryptJSON(pendingOutOfBandOTP{
|
||||
Channel: "email", CodeHash: hash, ExpiresAt: time.Now().UTC().Add(time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptJSON: %v", err)
|
||||
}
|
||||
user.OutOfBandOTPCiphertext = ciphertext
|
||||
if err := repo.Update(context.Background(), user); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Login(context.Background(), LoginInput{
|
||||
Username: "admin", Password: "password-123", TwoFactorCode: "123456",
|
||||
}, "127.0.0.1")
|
||||
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_INVALID" {
|
||||
t.Fatalf("expected disabled OTP channel to fail, got %v", err)
|
||||
}
|
||||
if repo.users[0].OutOfBandOTPCiphertext != "" {
|
||||
t.Fatalf("expected disabled channel OTP to be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceChangingOTPRecipientClearsPendingCode(t *testing.T) {
|
||||
svc, repo := newTestAuthService()
|
||||
_, err := svc.Setup(context.Background(), SetupInput{
|
||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
user := repo.users[0]
|
||||
user.Email = "old@example.com"
|
||||
user.EmailOTPEnabled = true
|
||||
hash, err := security.HashPassword("123456")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
ciphertext, err := svc.twoFactorCipher.EncryptJSON(pendingOutOfBandOTP{
|
||||
Channel: "email", CodeHash: hash, ExpiresAt: time.Now().UTC().Add(time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptJSON: %v", err)
|
||||
}
|
||||
user.OutOfBandOTPCiphertext = ciphertext
|
||||
if err := repo.Update(context.Background(), user); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
updated, err := svc.ConfigureOutOfBandOTP(context.Background(), "1", OTPConfigInput{
|
||||
CurrentPassword: "password-123",
|
||||
Channel: "email",
|
||||
Enabled: true,
|
||||
Email: "new@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigureOutOfBandOTP: %v", err)
|
||||
}
|
||||
if updated.Email != "new@example.com" {
|
||||
t.Fatalf("expected email updated, got %q", updated.Email)
|
||||
}
|
||||
if repo.users[0].OutOfBandOTPCiphertext != "" {
|
||||
t.Fatalf("expected pending email OTP to be cleared after recipient change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceCorruptWebAuthnCredentialsStillRequireMFA(t *testing.T) {
|
||||
svc, repo := newTestAuthService()
|
||||
_, err := svc.Setup(context.Background(), SetupInput{
|
||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
repo.users[0].WebAuthnCredentials = "{invalid-json"
|
||||
|
||||
_, err = svc.Login(context.Background(), LoginInput{
|
||||
Username: "admin", Password: "password-123",
|
||||
}, "127.0.0.1")
|
||||
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_REQUIRED" {
|
||||
t.Fatalf("expected corrupt WebAuthn credentials to require MFA, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
221
server/internal/service/auth_trusted_device.go
Normal file
221
server/internal/service/auth_trusted_device.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/security"
|
||||
)
|
||||
|
||||
func (s *AuthService) ListTrustedDevices(ctx context.Context, subject string) ([]TrustedDeviceOutput, error) {
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices, err := parseTrustedDevices(user.TrustedDevices)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
output := make([]TrustedDeviceOutput, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if device.ExpiresAt.Before(now) {
|
||||
continue
|
||||
}
|
||||
output = append(output, toTrustedDeviceOutput(device))
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
type TrustedDeviceRevokeInput struct {
|
||||
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
|
||||
}
|
||||
|
||||
func (s *AuthService) RevokeTrustedDevice(ctx context.Context, subject string, id string, input TrustedDeviceRevokeInput) error {
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
|
||||
return apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
|
||||
}
|
||||
devices, err := parseTrustedDevices(user.TrustedDevices)
|
||||
if err != nil {
|
||||
return apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
||||
}
|
||||
found := false
|
||||
filtered := make([]TrustedDeviceRecord, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if device.ID == strings.TrimSpace(id) {
|
||||
found = true
|
||||
} else {
|
||||
filtered = append(filtered, device)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return apperror.New(404, "AUTH_TRUSTED_DEVICE_NOT_FOUND", "可信设备不存在", nil)
|
||||
}
|
||||
encoded, err := encodeTrustedDevices(filtered)
|
||||
if err != nil {
|
||||
return apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
||||
}
|
||||
user.TrustedDevices = encoded
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return apperror.Internal("AUTH_TRUSTED_DEVICE_REVOKE_FAILED", "无法移除可信设备", err)
|
||||
}
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "trusted_device_revoke",
|
||||
TargetType: "trusted_device", TargetID: strings.TrimSpace(id),
|
||||
Detail: "移除可信设备",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) verifyTrustedDevice(ctx context.Context, user *model.User, token string, clientKey string) (bool, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return false, nil
|
||||
}
|
||||
devices, err := parseTrustedDevices(user.TrustedDevices)
|
||||
if err != nil {
|
||||
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
hash := trustedDeviceTokenHash(token)
|
||||
changed := false
|
||||
for i := range devices {
|
||||
device := &devices[i]
|
||||
if device.ExpiresAt.Before(now) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(device.TokenHash), []byte(hash)) != 1 {
|
||||
continue
|
||||
}
|
||||
device.LastUsedAt = now
|
||||
device.LastIP = clientKey
|
||||
changed = true
|
||||
encoded, err := encodeTrustedDevices(filterActiveTrustedDevices(devices, now))
|
||||
if err != nil {
|
||||
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
||||
}
|
||||
user.TrustedDevices = encoded
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_UPDATE_FAILED", "无法更新可信设备", err)
|
||||
}
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "trusted_device_used",
|
||||
TargetType: "trusted_device", TargetID: device.ID, TargetName: device.Name,
|
||||
Detail: "使用可信设备跳过多因素验证", ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if changed {
|
||||
encoded, err := encodeTrustedDevices(filterActiveTrustedDevices(devices, now))
|
||||
if err != nil {
|
||||
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
||||
}
|
||||
user.TrustedDevices = encoded
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_UPDATE_FAILED", "无法更新可信设备", err)
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) issueTrustedDevice(ctx context.Context, user *model.User, name string, clientKey string) (string, *TrustedDeviceOutput, error) {
|
||||
token, err := randomURLToken(32)
|
||||
if err != nil {
|
||||
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_CREATE_FAILED", "无法生成可信设备令牌", err)
|
||||
}
|
||||
id, err := randomURLToken(16)
|
||||
if err != nil {
|
||||
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_CREATE_FAILED", "无法生成可信设备编号", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
deviceName := normalizeTrustedDeviceName(name)
|
||||
device := TrustedDeviceRecord{
|
||||
ID: id,
|
||||
Name: deviceName,
|
||||
TokenHash: trustedDeviceTokenHash(token),
|
||||
CreatedAt: now,
|
||||
LastUsedAt: now,
|
||||
ExpiresAt: now.Add(trustedDeviceTTL),
|
||||
LastIP: clientKey,
|
||||
}
|
||||
devices, err := parseTrustedDevices(user.TrustedDevices)
|
||||
if err != nil {
|
||||
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
||||
}
|
||||
devices = append(filterActiveTrustedDevices(devices, now), device)
|
||||
if len(devices) > maxTrustedDevices {
|
||||
devices = devices[len(devices)-maxTrustedDevices:]
|
||||
}
|
||||
encoded, err := encodeTrustedDevices(devices)
|
||||
if err != nil {
|
||||
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
||||
}
|
||||
user.TrustedDevices = encoded
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_CREATE_FAILED", "无法保存可信设备", err)
|
||||
}
|
||||
output := toTrustedDeviceOutput(device)
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "trusted_device_create",
|
||||
TargetType: "trusted_device", TargetID: device.ID, TargetName: device.Name,
|
||||
Detail: fmt.Sprintf("添加可信设备,有效期至 %s", device.ExpiresAt.Format(time.RFC3339)), ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
return token, &output, nil
|
||||
}
|
||||
|
||||
func filterActiveTrustedDevices(devices []TrustedDeviceRecord, now time.Time) []TrustedDeviceRecord {
|
||||
active := make([]TrustedDeviceRecord, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if device.ExpiresAt.After(now) {
|
||||
active = append(active, device)
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
func trustedDeviceTokenHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func randomURLToken(size int) (string, error) {
|
||||
buf := make([]byte, size)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func normalizeTrustedDeviceName(name string) string {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return "当前设备"
|
||||
}
|
||||
if len([]rune(trimmed)) <= maxTrustedDeviceName {
|
||||
return trimmed
|
||||
}
|
||||
runes := []rune(trimmed)
|
||||
return string(runes[:maxTrustedDeviceName])
|
||||
}
|
||||
366
server/internal/service/auth_webauthn.go
Normal file
366
server/internal/service/auth_webauthn.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/security"
|
||||
)
|
||||
|
||||
type WebAuthnRequestContext struct {
|
||||
RPID string
|
||||
Origin string
|
||||
}
|
||||
|
||||
type WebAuthnRegistrationOptionsInput struct {
|
||||
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
|
||||
}
|
||||
|
||||
type WebAuthnRegistrationFinishInput struct {
|
||||
Name string `json:"name" binding:"omitempty,max=128"`
|
||||
Credential security.WebAuthnRegistrationResponse `json:"credential" binding:"required"`
|
||||
}
|
||||
|
||||
type WebAuthnCredentialDeleteInput struct {
|
||||
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
|
||||
}
|
||||
|
||||
type WebAuthnLoginOptionsInput struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=64"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
}
|
||||
|
||||
type webAuthnPublicKeyCredentialParam struct {
|
||||
Type string `json:"type"`
|
||||
Alg int `json:"alg"`
|
||||
}
|
||||
|
||||
type webAuthnRelyingParty struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type webAuthnUserEntity struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
type webAuthnCredentialDescriptor struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type webAuthnAuthenticatorSelection struct {
|
||||
UserVerification string `json:"userVerification"`
|
||||
}
|
||||
|
||||
type WebAuthnRegistrationOptions struct {
|
||||
Challenge string `json:"challenge"`
|
||||
RP webAuthnRelyingParty `json:"rp"`
|
||||
User webAuthnUserEntity `json:"user"`
|
||||
PubKeyCredParams []webAuthnPublicKeyCredentialParam `json:"pubKeyCredParams"`
|
||||
Timeout int `json:"timeout"`
|
||||
Attestation string `json:"attestation"`
|
||||
AuthenticatorSelection webAuthnAuthenticatorSelection `json:"authenticatorSelection"`
|
||||
ExcludeCredentials []webAuthnCredentialDescriptor `json:"excludeCredentials"`
|
||||
}
|
||||
|
||||
type WebAuthnLoginOptions struct {
|
||||
Challenge string `json:"challenge"`
|
||||
RPID string `json:"rpId"`
|
||||
Timeout int `json:"timeout"`
|
||||
UserVerification string `json:"userVerification"`
|
||||
AllowCredentials []webAuthnCredentialDescriptor `json:"allowCredentials"`
|
||||
}
|
||||
|
||||
func (s *AuthService) BeginWebAuthnRegistration(ctx context.Context, subject string, input WebAuthnRegistrationOptionsInput, request WebAuthnRequestContext) (*WebAuthnRegistrationOptions, error) {
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
|
||||
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
|
||||
}
|
||||
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
|
||||
}
|
||||
challenge, err := security.GenerateWebAuthnChallenge()
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_FAILED", "无法生成通行密钥挑战", err)
|
||||
}
|
||||
state := webAuthnChallengeState{
|
||||
Type: "register",
|
||||
Challenge: challenge,
|
||||
RPID: request.RPID,
|
||||
Origin: request.Origin,
|
||||
ExpiresAt: time.Now().UTC().Add(mfaChallengeTTL),
|
||||
}
|
||||
if err := s.saveWebAuthnChallenge(ctx, user, state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exclude := make([]webAuthnCredentialDescriptor, 0, len(credentials))
|
||||
for _, credential := range credentials {
|
||||
exclude = append(exclude, webAuthnCredentialDescriptor{Type: "public-key", ID: credential.CredentialID})
|
||||
}
|
||||
return &WebAuthnRegistrationOptions{
|
||||
Challenge: challenge,
|
||||
RP: webAuthnRelyingParty{Name: "BackupX", ID: request.RPID},
|
||||
User: webAuthnUserEntity{
|
||||
ID: security.EncodeBase64URL([]byte(fmt.Sprintf("%d", user.ID))),
|
||||
Name: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
},
|
||||
PubKeyCredParams: []webAuthnPublicKeyCredentialParam{
|
||||
{Type: "public-key", Alg: -7},
|
||||
},
|
||||
Timeout: int(mfaChallengeTTL / time.Millisecond),
|
||||
Attestation: "none",
|
||||
AuthenticatorSelection: webAuthnAuthenticatorSelection{UserVerification: "preferred"},
|
||||
ExcludeCredentials: exclude,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) FinishWebAuthnRegistration(ctx context.Context, subject string, input WebAuthnRegistrationFinishInput) (*UserOutput, error) {
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state, err := s.loadWebAuthnChallenge(user, "register")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := security.VerifyWebAuthnRegistration(input.Credential, state.Challenge, state.RPID, state.Origin)
|
||||
if err != nil {
|
||||
return nil, apperror.BadRequest("AUTH_WEBAUTHN_VERIFY_FAILED", "通行密钥注册校验失败", err)
|
||||
}
|
||||
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
|
||||
}
|
||||
for _, credential := range credentials {
|
||||
if credential.CredentialID == parsed.CredentialID {
|
||||
return nil, apperror.Conflict("AUTH_WEBAUTHN_EXISTS", "该通行密钥已注册", nil)
|
||||
}
|
||||
}
|
||||
id, err := randomURLToken(16)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法生成通行密钥编号", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
name = "通行密钥"
|
||||
}
|
||||
credentials = append(credentials, WebAuthnCredentialRecord{
|
||||
ID: id,
|
||||
Name: normalizeTrustedDeviceName(name),
|
||||
CredentialID: parsed.CredentialID,
|
||||
PublicKeyX: parsed.PublicKeyX,
|
||||
PublicKeyY: parsed.PublicKeyY,
|
||||
SignCount: parsed.SignCount,
|
||||
CreatedAt: now,
|
||||
})
|
||||
encoded, err := encodeWebAuthnCredentials(credentials)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法保存通行密钥", err)
|
||||
}
|
||||
user.WebAuthnCredentials = encoded
|
||||
user.WebAuthnChallengeCiphertext = ""
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法保存通行密钥", err)
|
||||
}
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "webauthn_register",
|
||||
TargetType: "webauthn_credential", TargetID: id, TargetName: name,
|
||||
Detail: "注册通行密钥",
|
||||
})
|
||||
}
|
||||
return ToUserOutput(user), nil
|
||||
}
|
||||
|
||||
func (s *AuthService) BeginWebAuthnLogin(ctx context.Context, input WebAuthnLoginOptionsInput, request WebAuthnRequestContext, clientKey string) (*WebAuthnLoginOptions, error) {
|
||||
user, err := s.verifyPasswordForMFAStart(ctx, input.Username, input.Password, clientKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
|
||||
}
|
||||
if len(credentials) == 0 {
|
||||
return nil, apperror.BadRequest("AUTH_WEBAUTHN_NOT_ENABLED", "当前账号未注册通行密钥", nil)
|
||||
}
|
||||
challenge, err := security.GenerateWebAuthnChallenge()
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_FAILED", "无法生成通行密钥挑战", err)
|
||||
}
|
||||
state := webAuthnChallengeState{
|
||||
Type: "login",
|
||||
Challenge: challenge,
|
||||
RPID: request.RPID,
|
||||
Origin: request.Origin,
|
||||
ExpiresAt: time.Now().UTC().Add(mfaChallengeTTL),
|
||||
}
|
||||
if err := s.saveWebAuthnChallenge(ctx, user, state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := make([]webAuthnCredentialDescriptor, 0, len(credentials))
|
||||
for _, credential := range credentials {
|
||||
allowed = append(allowed, webAuthnCredentialDescriptor{Type: "public-key", ID: credential.CredentialID})
|
||||
}
|
||||
return &WebAuthnLoginOptions{
|
||||
Challenge: challenge,
|
||||
RPID: request.RPID,
|
||||
Timeout: int(mfaChallengeTTL / time.Millisecond),
|
||||
UserVerification: "preferred",
|
||||
AllowCredentials: allowed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) VerifyWebAuthnLogin(ctx context.Context, user *model.User, assertion security.WebAuthnLoginAssertion, clientKey string) error {
|
||||
state, err := s.loadWebAuthnChallenge(user, "login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
||||
if err != nil {
|
||||
return apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
|
||||
}
|
||||
rawID := strings.TrimSpace(assertion.RawID)
|
||||
if rawID == "" {
|
||||
rawID = strings.TrimSpace(assertion.ID)
|
||||
}
|
||||
for i := range credentials {
|
||||
credential := &credentials[i]
|
||||
if credential.CredentialID != rawID {
|
||||
continue
|
||||
}
|
||||
nextSignCount, err := security.VerifyWebAuthnAssertion(assertion, state.Challenge, state.RPID, state.Origin, security.WebAuthnCredentialMaterial{
|
||||
CredentialID: credential.CredentialID,
|
||||
PublicKeyX: credential.PublicKeyX,
|
||||
PublicKeyY: credential.PublicKeyY,
|
||||
SignCount: credential.SignCount,
|
||||
})
|
||||
if err != nil {
|
||||
return apperror.Unauthorized("AUTH_WEBAUTHN_INVALID", "通行密钥校验失败", err)
|
||||
}
|
||||
credential.SignCount = nextSignCount
|
||||
credential.LastUsedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
encoded, err := encodeWebAuthnCredentials(credentials)
|
||||
if err != nil {
|
||||
return apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法更新通行密钥", err)
|
||||
}
|
||||
user.WebAuthnCredentials = encoded
|
||||
user.WebAuthnChallengeCiphertext = ""
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法更新通行密钥", err)
|
||||
}
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "webauthn_used",
|
||||
TargetType: "webauthn_credential", TargetID: credential.ID, TargetName: credential.Name,
|
||||
Detail: "使用通行密钥完成多因素验证", ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return apperror.Unauthorized("AUTH_WEBAUTHN_INVALID", "通行密钥不存在", nil)
|
||||
}
|
||||
|
||||
func (s *AuthService) ListWebAuthnCredentials(ctx context.Context, subject string) ([]WebAuthnCredentialOutput, error) {
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
|
||||
}
|
||||
output := make([]WebAuthnCredentialOutput, 0, len(credentials))
|
||||
for _, credential := range credentials {
|
||||
output = append(output, toWebAuthnCredentialOutput(credential))
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) DeleteWebAuthnCredential(ctx context.Context, subject string, id string, input WebAuthnCredentialDeleteInput) (*UserOutput, error) {
|
||||
user, err := s.userBySubject(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
|
||||
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
|
||||
}
|
||||
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
|
||||
}
|
||||
found := false
|
||||
filtered := make([]WebAuthnCredentialRecord, 0, len(credentials))
|
||||
for _, credential := range credentials {
|
||||
if credential.ID == strings.TrimSpace(id) {
|
||||
found = true
|
||||
} else {
|
||||
filtered = append(filtered, credential)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, apperror.New(404, "AUTH_WEBAUTHN_NOT_FOUND", "通行密钥不存在", nil)
|
||||
}
|
||||
encoded, err := encodeWebAuthnCredentials(filtered)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法更新通行密钥", err)
|
||||
}
|
||||
user.WebAuthnCredentials = encoded
|
||||
clearTrustedDevicesIfMFAOff(user)
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_DELETE_FAILED", "无法删除通行密钥", err)
|
||||
}
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "webauthn_delete",
|
||||
TargetType: "webauthn_credential", TargetID: strings.TrimSpace(id),
|
||||
Detail: "删除通行密钥",
|
||||
})
|
||||
}
|
||||
return ToUserOutput(user), nil
|
||||
}
|
||||
|
||||
func (s *AuthService) saveWebAuthnChallenge(ctx context.Context, user *model.User, state webAuthnChallengeState) error {
|
||||
ciphertext, err := s.twoFactorCipher.EncryptJSON(state)
|
||||
if err != nil {
|
||||
return apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_FAILED", "无法保存通行密钥挑战", err)
|
||||
}
|
||||
user.WebAuthnChallengeCiphertext = ciphertext
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_FAILED", "无法保存通行密钥挑战", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) loadWebAuthnChallenge(user *model.User, challengeType string) (*webAuthnChallengeState, error) {
|
||||
if strings.TrimSpace(user.WebAuthnChallengeCiphertext) == "" {
|
||||
return nil, apperror.BadRequest("AUTH_WEBAUTHN_CHALLENGE_MISSING", "请先发起通行密钥验证", nil)
|
||||
}
|
||||
var state webAuthnChallengeState
|
||||
if err := s.twoFactorCipher.DecryptJSON(user.WebAuthnChallengeCiphertext, &state); err != nil {
|
||||
return nil, apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_INVALID", "通行密钥挑战状态异常", err)
|
||||
}
|
||||
if state.Type != challengeType {
|
||||
return nil, apperror.BadRequest("AUTH_WEBAUTHN_CHALLENGE_INVALID", "通行密钥挑战类型不匹配", nil)
|
||||
}
|
||||
if state.ExpiresAt.Before(time.Now().UTC()) {
|
||||
return nil, apperror.BadRequest("AUTH_WEBAUTHN_CHALLENGE_EXPIRED", "通行密钥挑战已过期", nil)
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
)
|
||||
|
||||
type NotificationUpsertInput struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=100"`
|
||||
Type string `json:"type" binding:"required,oneof=email webhook telegram"`
|
||||
Enabled bool `json:"enabled"`
|
||||
OnSuccess bool `json:"onSuccess"`
|
||||
OnFailure bool `json:"onFailure"`
|
||||
Name string `json:"name" binding:"required,min=1,max=100"`
|
||||
Type string `json:"type" binding:"required,oneof=email webhook telegram"`
|
||||
Enabled bool `json:"enabled"`
|
||||
OnSuccess bool `json:"onSuccess"`
|
||||
OnFailure bool `json:"onFailure"`
|
||||
// EventTypes 订阅的扩展事件列表。与 OnSuccess/OnFailure 并存:
|
||||
// - 两者均空时,订阅"备份成功/失败"对应原有语义(兼容)。
|
||||
// - EventTypes 显式指定时优先按清单匹配。
|
||||
@@ -186,8 +186,8 @@ func (s *NotificationService) NotifyBackupResult(ctx context.Context, event Back
|
||||
// - eventType 对应 model.NotificationEvent* 常量,用于订阅匹配
|
||||
//
|
||||
// 订阅匹配规则:
|
||||
// 1) notification.EventTypes 非空:必须包含 eventType
|
||||
// 2) notification.EventTypes 为空:沿用 OnSuccess/OnFailure 开关(仅 backup_* 事件)
|
||||
// 1. notification.EventTypes 非空:必须包含 eventType
|
||||
// 2. notification.EventTypes 为空:沿用 OnSuccess/OnFailure 开关(仅 backup_* 事件)
|
||||
func (s *NotificationService) DispatchEvent(ctx context.Context, eventType string, title string, body string, fields map[string]any) error {
|
||||
// 同步广播到 SSE 订阅者(前端 Dashboard 实时推送)。
|
||||
// 非阻塞:即便广播器未注入或订阅者已满也不影响 Notification 持久渠道。
|
||||
@@ -209,6 +209,49 @@ func (s *NotificationService) DispatchEvent(ctx context.Context, eventType strin
|
||||
return s.deliver(ctx, items, message)
|
||||
}
|
||||
|
||||
func (s *NotificationService) SendAuthEmailOTP(ctx context.Context, to string, code string) error {
|
||||
return s.sendFirstByType(ctx, "email", map[string]any{"to": strings.TrimSpace(to)}, notify.Message{
|
||||
Title: "BackupX 登录验证码",
|
||||
Body: fmt.Sprintf("您的 BackupX 登录验证码为:%s\n验证码 5 分钟内有效。若非本人操作,请立即检查账号安全。", code),
|
||||
Fields: map[string]any{
|
||||
"purpose": "login_otp",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *NotificationService) SendAuthSMSOTP(ctx context.Context, phone string, code string) error {
|
||||
return s.sendFirstByType(ctx, "webhook", nil, notify.Message{
|
||||
Title: "BackupX 登录验证码",
|
||||
Body: fmt.Sprintf("BackupX 登录验证码:%s,5 分钟内有效。", code),
|
||||
Fields: map[string]any{
|
||||
"phone": strings.TrimSpace(phone),
|
||||
"code": code,
|
||||
"purpose": "login_otp",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *NotificationService) sendFirstByType(ctx context.Context, notificationType string, override map[string]any, message notify.Message) error {
|
||||
items, err := s.notifications.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if !item.Enabled || item.Type != notificationType {
|
||||
continue
|
||||
}
|
||||
configMap := map[string]any{}
|
||||
if err := s.cipher.DecryptJSON(item.ConfigCiphertext, &configMap); err != nil {
|
||||
return fmt.Errorf("decrypt notification %d config: %w", item.ID, err)
|
||||
}
|
||||
for key, value := range override {
|
||||
configMap[key] = value
|
||||
}
|
||||
return s.registry.Send(ctx, item.Type, configMap, message)
|
||||
}
|
||||
return fmt.Errorf("no enabled %s notification configured", notificationType)
|
||||
}
|
||||
|
||||
// collectSubscribers 按事件类型收集启用的订阅者。
|
||||
// 列出启用通知后按事件类型再过滤(避免引入新 repository 方法)。
|
||||
func (s *NotificationService) collectSubscribers(ctx context.Context, eventType string, fallbackSuccess bool) ([]model.Notification, error) {
|
||||
|
||||
@@ -22,13 +22,22 @@ func NewUserService(users repository.UserRepository) *UserService {
|
||||
|
||||
// UserSummary 用户列表项(不含密码哈希)。
|
||||
type UserSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
MFAEnabled bool `json:"mfaEnabled"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
TwoFactorRecoveryCodesRemaining int `json:"twoFactorRecoveryCodesRemaining"`
|
||||
WebAuthnEnabled bool `json:"webAuthnEnabled"`
|
||||
WebAuthnCredentialCount int `json:"webAuthnCredentialCount"`
|
||||
TrustedDeviceCount int `json:"trustedDeviceCount"`
|
||||
EmailOTPEnabled bool `json:"emailOtpEnabled"`
|
||||
SMSOTPEnabled bool `json:"smsOtpEnabled"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// UserUpsertInput 创建/更新用户的输入。
|
||||
@@ -37,6 +46,7 @@ type UserUpsertInput struct {
|
||||
Password string `json:"password" binding:"omitempty,min=8,max=128"`
|
||||
DisplayName string `json:"displayName" binding:"required,min=1,max=128"`
|
||||
Email string `json:"email" binding:"omitempty,max=255"`
|
||||
Phone string `json:"phone" binding:"omitempty,max=64"`
|
||||
Role string `json:"role" binding:"required,oneof=admin operator viewer"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
@@ -76,6 +86,7 @@ func (s *UserService) Create(ctx context.Context, input UserUpsertInput) (*UserS
|
||||
PasswordHash: hash,
|
||||
DisplayName: strings.TrimSpace(input.DisplayName),
|
||||
Email: strings.TrimSpace(input.Email),
|
||||
Phone: strings.TrimSpace(input.Phone),
|
||||
Role: input.Role,
|
||||
Disabled: input.Disabled,
|
||||
}
|
||||
@@ -107,18 +118,43 @@ func (s *UserService) Update(ctx context.Context, id uint, input UserUpsertInput
|
||||
return nil, apperror.Conflict("USER_USERNAME_EXISTS", "用户名已存在", nil)
|
||||
}
|
||||
}
|
||||
passwordChanged := strings.TrimSpace(input.Password) != ""
|
||||
disabledChanged := input.Disabled && !existing.Disabled
|
||||
emailChanged := strings.TrimSpace(input.Email) != strings.TrimSpace(existing.Email)
|
||||
phoneChanged := strings.TrimSpace(input.Phone) != strings.TrimSpace(existing.Phone)
|
||||
existing.Username = strings.TrimSpace(input.Username)
|
||||
existing.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
existing.Email = strings.TrimSpace(input.Email)
|
||||
existing.Phone = strings.TrimSpace(input.Phone)
|
||||
existing.Role = input.Role
|
||||
existing.Disabled = input.Disabled
|
||||
if strings.TrimSpace(input.Password) != "" {
|
||||
if passwordChanged {
|
||||
hash, err := security.HashPassword(input.Password)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("USER_HASH_FAILED", "无法处理密码", err)
|
||||
}
|
||||
existing.PasswordHash = hash
|
||||
existing.TrustedDevices = ""
|
||||
existing.OutOfBandOTPCiphertext = ""
|
||||
existing.WebAuthnChallengeCiphertext = ""
|
||||
}
|
||||
if strings.TrimSpace(existing.Email) == "" && existing.EmailOTPEnabled {
|
||||
existing.EmailOTPEnabled = false
|
||||
existing.OutOfBandOTPCiphertext = ""
|
||||
}
|
||||
if strings.TrimSpace(existing.Phone) == "" && existing.SMSOTPEnabled {
|
||||
existing.SMSOTPEnabled = false
|
||||
existing.OutOfBandOTPCiphertext = ""
|
||||
}
|
||||
if emailChanged || phoneChanged {
|
||||
existing.OutOfBandOTPCiphertext = ""
|
||||
}
|
||||
if disabledChanged {
|
||||
existing.TrustedDevices = ""
|
||||
existing.OutOfBandOTPCiphertext = ""
|
||||
existing.WebAuthnChallengeCiphertext = ""
|
||||
}
|
||||
clearTrustedDevicesIfMFAOff(existing)
|
||||
if err := s.users.Update(ctx, existing); err != nil {
|
||||
return nil, apperror.Internal("USER_UPDATE_FAILED", "无法更新用户", err)
|
||||
}
|
||||
@@ -147,14 +183,47 @@ func (s *UserService) Delete(ctx context.Context, id uint) error {
|
||||
return s.users.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *UserService) ResetTwoFactor(ctx context.Context, id uint) (*UserSummary, error) {
|
||||
existing, err := s.users.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("USER_GET_FAILED", "无法获取用户", err)
|
||||
}
|
||||
if existing == nil {
|
||||
return nil, apperror.New(404, "USER_NOT_FOUND", "用户不存在", nil)
|
||||
}
|
||||
existing.TwoFactorEnabled = false
|
||||
existing.TwoFactorSecretCiphertext = ""
|
||||
existing.TwoFactorRecoveryCodeHashes = ""
|
||||
existing.WebAuthnCredentials = ""
|
||||
existing.WebAuthnChallengeCiphertext = ""
|
||||
existing.TrustedDevices = ""
|
||||
existing.EmailOTPEnabled = false
|
||||
existing.SMSOTPEnabled = false
|
||||
existing.OutOfBandOTPCiphertext = ""
|
||||
if err := s.users.Update(ctx, existing); err != nil {
|
||||
return nil, apperror.Internal("USER_2FA_RESET_FAILED", "无法重置 MFA", err)
|
||||
}
|
||||
summary := toUserSummary(existing)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func toUserSummary(u *model.User) UserSummary {
|
||||
return UserSummary{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
DisplayName: u.DisplayName,
|
||||
Email: u.Email,
|
||||
Role: u.Role,
|
||||
Disabled: u.Disabled,
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
DisplayName: u.DisplayName,
|
||||
Email: u.Email,
|
||||
Phone: u.Phone,
|
||||
Role: u.Role,
|
||||
Disabled: u.Disabled,
|
||||
MFAEnabled: userMFAEnabled(u),
|
||||
TwoFactorEnabled: u.TwoFactorEnabled,
|
||||
TwoFactorRecoveryCodesRemaining: recoveryCodeRemainingCount(u),
|
||||
WebAuthnEnabled: webAuthnCredentialCount(u) > 0,
|
||||
WebAuthnCredentialCount: webAuthnCredentialCount(u),
|
||||
TrustedDeviceCount: trustedDeviceCount(u),
|
||||
EmailOTPEnabled: u.EmailOTPEnabled,
|
||||
SMSOTPEnabled: u.SMSOTPEnabled,
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
}
|
||||
|
||||
124
server/internal/service/user_service_test.go
Normal file
124
server/internal/service/user_service_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/security"
|
||||
)
|
||||
|
||||
func TestUserServiceUpdatePasswordClearsTrustedDeviceState(t *testing.T) {
|
||||
hash, err := security.HashPassword("old-password")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
repo := &fakeUserRepository{users: []*model.User{{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
PasswordHash: hash,
|
||||
DisplayName: "Admin",
|
||||
Email: "admin@example.com",
|
||||
Role: model.UserRoleAdmin,
|
||||
TwoFactorEnabled: true,
|
||||
TrustedDevices: `[{"id":"device"}]`,
|
||||
OutOfBandOTPCiphertext: "pending",
|
||||
WebAuthnChallengeCiphertext: "challenge",
|
||||
}}}
|
||||
svc := NewUserService(repo)
|
||||
|
||||
if _, err := svc.Update(context.Background(), 1, UserUpsertInput{
|
||||
Username: "admin",
|
||||
Password: "new-password",
|
||||
DisplayName: "Admin",
|
||||
Email: "admin@example.com",
|
||||
Role: model.UserRoleAdmin,
|
||||
}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
updated := repo.users[0]
|
||||
if security.ComparePassword(updated.PasswordHash, "new-password") != nil {
|
||||
t.Fatalf("expected password hash to be updated")
|
||||
}
|
||||
if updated.TrustedDevices != "" || updated.OutOfBandOTPCiphertext != "" || updated.WebAuthnChallengeCiphertext != "" {
|
||||
t.Fatalf("expected password update to clear trusted device state, got trusted=%q otp=%q challenge=%q", updated.TrustedDevices, updated.OutOfBandOTPCiphertext, updated.WebAuthnChallengeCiphertext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserServiceUpdateContactClearsUnavailableOTP(t *testing.T) {
|
||||
hash, err := security.HashPassword("password-123")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
repo := &fakeUserRepository{users: []*model.User{{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
PasswordHash: hash,
|
||||
DisplayName: "Admin",
|
||||
Email: "admin@example.com",
|
||||
Phone: "+15550000000",
|
||||
Role: model.UserRoleAdmin,
|
||||
EmailOTPEnabled: true,
|
||||
SMSOTPEnabled: true,
|
||||
TrustedDevices: `[{"id":"device"}]`,
|
||||
OutOfBandOTPCiphertext: "pending",
|
||||
}}}
|
||||
svc := NewUserService(repo)
|
||||
|
||||
summary, err := svc.Update(context.Background(), 1, UserUpsertInput{
|
||||
Username: "admin",
|
||||
DisplayName: "Admin",
|
||||
Role: model.UserRoleAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
updated := repo.users[0]
|
||||
if updated.EmailOTPEnabled || updated.SMSOTPEnabled || summary.MFAEnabled {
|
||||
t.Fatalf("expected unavailable OTP channels to be disabled")
|
||||
}
|
||||
if updated.TrustedDevices != "" || updated.OutOfBandOTPCiphertext != "" || updated.WebAuthnChallengeCiphertext != "" {
|
||||
t.Fatalf("expected last MFA removal to clear temporary state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserServiceUpdateContactChangeClearsPendingOTP(t *testing.T) {
|
||||
hash, err := security.HashPassword("password-123")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
repo := &fakeUserRepository{users: []*model.User{{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
PasswordHash: hash,
|
||||
DisplayName: "Admin",
|
||||
Email: "old@example.com",
|
||||
Role: model.UserRoleAdmin,
|
||||
EmailOTPEnabled: true,
|
||||
OutOfBandOTPCiphertext: "pending",
|
||||
}}}
|
||||
svc := NewUserService(repo)
|
||||
|
||||
summary, err := svc.Update(context.Background(), 1, UserUpsertInput{
|
||||
Username: "admin",
|
||||
DisplayName: "Admin",
|
||||
Email: "new@example.com",
|
||||
Role: model.UserRoleAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
updated := repo.users[0]
|
||||
if updated.Email != "new@example.com" || summary.Email != "new@example.com" {
|
||||
t.Fatalf("expected email to be updated")
|
||||
}
|
||||
if !updated.EmailOTPEnabled {
|
||||
t.Fatalf("expected email OTP to remain enabled")
|
||||
}
|
||||
if updated.OutOfBandOTPCiphertext != "" {
|
||||
t.Fatalf("expected contact change to clear pending OTP")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ describe('notification field config', () => {
|
||||
it('returns readable type labels', () => {
|
||||
expect(getNotificationTypeLabel('email')).toBe('Email')
|
||||
expect(getNotificationTypeLabel('telegram')).toBe('Telegram')
|
||||
expect(getNotificationTypeLabel('webhook')).toBe('Webhook')
|
||||
})
|
||||
|
||||
it('returns required fields for each notification type', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Avatar, Button, Dropdown, Layout, Menu, Message, Modal, Form, Input, Space, Typography } from '@arco-design/web-react'
|
||||
import { Alert, Avatar, Button, Divider, Dropdown, Layout, Menu, Message, Modal, Form, Input, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconDashboard,
|
||||
IconStorage,
|
||||
@@ -23,7 +23,27 @@ import {
|
||||
} from '@arco-design/web-react/icon'
|
||||
import { useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { changePassword, type ChangePasswordPayload } from '../services/auth'
|
||||
import {
|
||||
changePassword,
|
||||
beginWebAuthnRegistration,
|
||||
clearTrustedDeviceToken,
|
||||
configureOtp,
|
||||
deleteWebAuthnCredential,
|
||||
disableTwoFactor,
|
||||
enableTwoFactor,
|
||||
finishWebAuthnRegistration,
|
||||
listTrustedDevices,
|
||||
listWebAuthnCredentials,
|
||||
prepareTwoFactor,
|
||||
regenerateRecoveryCodes,
|
||||
revokeTrustedDevice,
|
||||
type ChangePasswordPayload,
|
||||
type TrustedDevice,
|
||||
type UserInfo,
|
||||
type WebAuthnCredential,
|
||||
type TwoFactorSetupResult,
|
||||
} from '../services/auth'
|
||||
import { createWebAuthnCredential } from '../utils/webauthn'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { resolveErrorMessage } from '../utils/error'
|
||||
import { isAdmin, roleLabel } from '../utils/permissions'
|
||||
@@ -105,11 +125,27 @@ export function AppLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [pwdVisible, setPwdVisible] = useState(false)
|
||||
const [pwdLoading, setPwdLoading] = useState(false)
|
||||
const [twoFactorVisible, setTwoFactorVisible] = useState(false)
|
||||
const [twoFactorLoading, setTwoFactorLoading] = useState(false)
|
||||
const [twoFactorSetup, setTwoFactorSetup] = useState<TwoFactorSetupResult | null>(null)
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
||||
const [webAuthnCredentials, setWebAuthnCredentials] = useState<WebAuthnCredential[]>([])
|
||||
const [trustedDevices, setTrustedDevices] = useState<TrustedDevice[]>([])
|
||||
const [securityDetailsLoading, setSecurityDetailsLoading] = useState(false)
|
||||
const [pwdForm] = Form.useForm<ChangePasswordPayload & { confirmPassword: string }>()
|
||||
const [twoFactorForm] = Form.useForm<{ currentPassword: string; code: string; email: string; phone: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const logout = useAuthStore((state) => state.logout)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
|
||||
function applySecurityUserUpdate(updated: UserInfo) {
|
||||
setUser(updated)
|
||||
if (!updated.mfaEnabled) {
|
||||
clearTrustedDeviceToken(updated.username)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
try {
|
||||
@@ -120,6 +156,7 @@ export function AppLayout() {
|
||||
}
|
||||
setPwdLoading(true)
|
||||
await changePassword({ oldPassword: values.oldPassword, newPassword: values.newPassword })
|
||||
clearTrustedDeviceToken(user?.username)
|
||||
Message.success('密码修改成功')
|
||||
setPwdVisible(false)
|
||||
pwdForm.resetFields()
|
||||
@@ -132,15 +169,227 @@ export function AppLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
function closeTwoFactorModal() {
|
||||
setTwoFactorVisible(false)
|
||||
setTwoFactorSetup(null)
|
||||
setRecoveryCodes([])
|
||||
setWebAuthnCredentials([])
|
||||
setTrustedDevices([])
|
||||
twoFactorForm.resetFields()
|
||||
}
|
||||
|
||||
async function openSecurityModal() {
|
||||
setTwoFactorVisible(true)
|
||||
twoFactorForm.setFieldValue('email', user?.email ?? '')
|
||||
twoFactorForm.setFieldValue('phone', user?.phone ?? '')
|
||||
await loadSecurityDetails()
|
||||
}
|
||||
|
||||
async function loadSecurityDetails() {
|
||||
setSecurityDetailsLoading(true)
|
||||
try {
|
||||
const [credentials, devices] = await Promise.all([listWebAuthnCredentials(), listTrustedDevices()])
|
||||
setWebAuthnCredentials(credentials)
|
||||
setTrustedDevices(devices)
|
||||
} catch (err) {
|
||||
Message.error(resolveErrorMessage(err, '加载安全配置失败'))
|
||||
} finally {
|
||||
setSecurityDetailsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRecoveryCodes() {
|
||||
if (recoveryCodes.length === 0) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(recoveryCodes.join('\n'))
|
||||
Message.success('已复制到剪贴板')
|
||||
} catch {
|
||||
Message.info('请手动选择文本复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTwoFactorSetupAction() {
|
||||
try {
|
||||
const values = await twoFactorForm.validate()
|
||||
setTwoFactorLoading(true)
|
||||
if (!twoFactorSetup) {
|
||||
const setup = await prepareTwoFactor({ currentPassword: values.currentPassword })
|
||||
setTwoFactorSetup(setup)
|
||||
Message.success('TOTP 密钥已生成')
|
||||
return
|
||||
}
|
||||
const result = await enableTwoFactor({ code: values.code })
|
||||
setUser(result.user)
|
||||
setRecoveryCodes(result.recoveryCodes)
|
||||
Message.success('TOTP 已启用')
|
||||
} catch (err) {
|
||||
if (err) {
|
||||
Message.error(resolveErrorMessage(err, 'TOTP 操作失败'))
|
||||
}
|
||||
} finally {
|
||||
setTwoFactorLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerateRecoveryCodes() {
|
||||
try {
|
||||
const values = await twoFactorForm.validate()
|
||||
setTwoFactorLoading(true)
|
||||
const result = await regenerateRecoveryCodes({
|
||||
currentPassword: values.currentPassword,
|
||||
code: values.code,
|
||||
})
|
||||
setUser(result.user)
|
||||
setRecoveryCodes(result.recoveryCodes)
|
||||
twoFactorForm.resetFields()
|
||||
Message.success('恢复码已重新生成')
|
||||
} catch (err) {
|
||||
if (err) {
|
||||
Message.error(resolveErrorMessage(err, '恢复码生成失败'))
|
||||
}
|
||||
} finally {
|
||||
setTwoFactorLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisableTwoFactor() {
|
||||
try {
|
||||
const values = await twoFactorForm.validate()
|
||||
setTwoFactorLoading(true)
|
||||
const updated = await disableTwoFactor({
|
||||
currentPassword: values.currentPassword,
|
||||
code: values.code,
|
||||
})
|
||||
applySecurityUserUpdate(updated)
|
||||
Message.success('TOTP 已关闭')
|
||||
closeTwoFactorModal()
|
||||
} catch (err) {
|
||||
if (err) {
|
||||
Message.error(resolveErrorMessage(err, '关闭 TOTP 失败'))
|
||||
}
|
||||
} finally {
|
||||
setTwoFactorLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function readCurrentPassword() {
|
||||
const currentPassword = String(twoFactorForm.getFieldValue('currentPassword') ?? '')
|
||||
if (currentPassword.trim().length < 8) {
|
||||
Message.error('请输入当前密码')
|
||||
return ''
|
||||
}
|
||||
return currentPassword
|
||||
}
|
||||
|
||||
async function handleRegisterWebAuthn() {
|
||||
const currentPassword = readCurrentPassword()
|
||||
if (!currentPassword) return
|
||||
try {
|
||||
setTwoFactorLoading(true)
|
||||
const options = await beginWebAuthnRegistration({ currentPassword })
|
||||
const credential = await createWebAuthnCredential(options)
|
||||
const updated = await finishWebAuthnRegistration({ name: navigator.userAgent.slice(0, 120), credential })
|
||||
applySecurityUserUpdate(updated)
|
||||
await loadSecurityDetails()
|
||||
Message.success('通行密钥已注册')
|
||||
} catch (err) {
|
||||
Message.error(resolveErrorMessage(err, '通行密钥注册失败'))
|
||||
} finally {
|
||||
setTwoFactorLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteWebAuthnCredential(id: string) {
|
||||
const currentPassword = readCurrentPassword()
|
||||
if (!currentPassword) return
|
||||
try {
|
||||
setTwoFactorLoading(true)
|
||||
const updated = await deleteWebAuthnCredential(id, { currentPassword })
|
||||
applySecurityUserUpdate(updated)
|
||||
await loadSecurityDetails()
|
||||
Message.success('通行密钥已删除')
|
||||
} catch (err) {
|
||||
Message.error(resolveErrorMessage(err, '删除通行密钥失败'))
|
||||
} finally {
|
||||
setTwoFactorLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfigureOtp(channel: 'email' | 'sms', enabled: boolean) {
|
||||
const currentPassword = readCurrentPassword()
|
||||
if (!currentPassword) return
|
||||
const email = String(twoFactorForm.getFieldValue('email') ?? '')
|
||||
const phone = String(twoFactorForm.getFieldValue('phone') ?? '')
|
||||
try {
|
||||
setTwoFactorLoading(true)
|
||||
const updated = await configureOtp({ currentPassword, channel, enabled, email, phone })
|
||||
applySecurityUserUpdate(updated)
|
||||
twoFactorForm.setFieldValue('email', updated.email ?? '')
|
||||
twoFactorForm.setFieldValue('phone', updated.phone ?? '')
|
||||
Message.success(enabled ? 'OTP 已启用' : 'OTP 已关闭')
|
||||
} catch (err) {
|
||||
Message.error(resolveErrorMessage(err, 'OTP 配置失败'))
|
||||
} finally {
|
||||
setTwoFactorLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevokeTrustedDevice(id: string) {
|
||||
const currentPassword = readCurrentPassword()
|
||||
if (!currentPassword) return
|
||||
try {
|
||||
setTwoFactorLoading(true)
|
||||
await revokeTrustedDevice(id, { currentPassword })
|
||||
clearTrustedDeviceToken(user?.username)
|
||||
await loadSecurityDetails()
|
||||
Message.success('可信设备已移除')
|
||||
} catch (err) {
|
||||
Message.error(resolveErrorMessage(err, '移除可信设备失败'))
|
||||
} finally {
|
||||
setTwoFactorLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function renderTwoFactorFooter() {
|
||||
if (recoveryCodes.length > 0) {
|
||||
return (
|
||||
<Space>
|
||||
<Button onClick={() => void copyRecoveryCodes()}>复制恢复码</Button>
|
||||
<Button type="primary" onClick={closeTwoFactorModal}>完成</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
if (user?.twoFactorEnabled) {
|
||||
return (
|
||||
<Space>
|
||||
<Button onClick={closeTwoFactorModal}>取消</Button>
|
||||
<Button loading={twoFactorLoading} onClick={() => void handleRegenerateRecoveryCodes()}>重新生成恢复码</Button>
|
||||
<Button status="danger" loading={twoFactorLoading} onClick={() => void handleDisableTwoFactor()}>关闭 TOTP</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Space>
|
||||
<Button onClick={closeTwoFactorModal}>取消</Button>
|
||||
<Button type="primary" loading={twoFactorLoading} onClick={() => void handleTwoFactorSetupAction()}>
|
||||
{twoFactorSetup ? '启用 TOTP' : '生成 TOTP 二维码'}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
const userDroplist = (
|
||||
<Menu onClickMenuItem={(key) => {
|
||||
if (key === 'password') {
|
||||
setPwdVisible(true)
|
||||
} else if (key === 'two-factor') {
|
||||
void openSecurityModal()
|
||||
} else if (key === 'logout') {
|
||||
logout()
|
||||
}
|
||||
}}>
|
||||
<Menu.Item key="password"><IconLock style={{ marginRight: 8 }} />修改密码</Menu.Item>
|
||||
<Menu.Item key="two-factor"><IconSafe style={{ marginRight: 8 }} />多因素认证</Menu.Item>
|
||||
<Menu.Item key="logout"><IconPoweroff style={{ marginRight: 8 }} />退出登录</Menu.Item>
|
||||
</Menu>
|
||||
)
|
||||
@@ -217,6 +466,138 @@ export function AppLayout() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="多因素认证"
|
||||
visible={twoFactorVisible}
|
||||
onCancel={closeTwoFactorModal}
|
||||
footer={renderTwoFactorFooter()}
|
||||
unmountOnExit
|
||||
>
|
||||
{recoveryCodes.length > 0 ? (
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Alert type="warning" content="恢复码只会显示一次。请立即保存;每个恢复码只能使用一次。" />
|
||||
<Input.TextArea value={recoveryCodes.join('\n')} autoSize readOnly />
|
||||
</Space>
|
||||
) : (
|
||||
<Form form={twoFactorForm} layout="vertical">
|
||||
{user?.twoFactorEnabled ? (
|
||||
<>
|
||||
<Alert
|
||||
type="success"
|
||||
content={`当前账号已启用 TOTP,恢复码剩余 ${user.twoFactorRecoveryCodesRemaining ?? 0} 个。`}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form.Item field="currentPassword" label="当前密码" rules={[{ required: true, minLength: 8 }]}>
|
||||
<Input.Password placeholder="请输入当前密码" />
|
||||
</Form.Item>
|
||||
<Form.Item field="code" label="TOTP 验证码" rules={[{ required: true, minLength: 6, maxLength: 10 }]}>
|
||||
<Input placeholder="请输入 6 位验证码" maxLength={10} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!twoFactorSetup ? (
|
||||
<>
|
||||
<Alert type="info" content="启用前需要验证当前密码。" style={{ marginBottom: 16 }} />
|
||||
<Form.Item field="currentPassword" label="当前密码" rules={[{ required: true, minLength: 8 }]}>
|
||||
<Input.Password placeholder="请输入当前密码" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Alert type="warning" content="密钥仅在本次启用流程中显示。启用后会生成一次性恢复码。" style={{ marginBottom: 16 }} />
|
||||
<div style={{ display: 'flex', gap: 20, alignItems: 'center', marginBottom: 16 }}>
|
||||
<img
|
||||
src={twoFactorSetup.qrCodeDataUrl}
|
||||
alt="TOTP 二维码"
|
||||
style={{ width: 160, height: 160, border: '1px solid var(--color-border)', borderRadius: 8 }}
|
||||
/>
|
||||
<Space direction="vertical" size={8} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text type="secondary">手动密钥</Typography.Text>
|
||||
<Input value={twoFactorSetup.secret} readOnly />
|
||||
</Space>
|
||||
</div>
|
||||
<Form.Item field="code" label="TOTP 验证码" rules={[{ required: true, minLength: 6, maxLength: 10 }]}>
|
||||
<Input placeholder="请输入 6 位验证码" maxLength={10} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Typography.Title heading={6} style={{ margin: 0 }}>通行密钥</Typography.Title>
|
||||
<Tag color={webAuthnCredentials.length > 0 ? 'green' : 'gray'} bordered>
|
||||
{webAuthnCredentials.length > 0 ? `${webAuthnCredentials.length} 个` : '未注册'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
|
||||
支持浏览器 Passkey、平台验证器或安全密钥,用于登录时替代验证码。
|
||||
</Typography.Paragraph>
|
||||
<Button loading={twoFactorLoading} onClick={() => void handleRegisterWebAuthn()}>注册当前设备通行密钥</Button>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{securityDetailsLoading ? <Typography.Text type="secondary">正在加载通行密钥...</Typography.Text> : null}
|
||||
{webAuthnCredentials.map((item) => (
|
||||
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', padding: '8px 0', borderTop: '1px solid var(--color-border)' }}>
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{item.name}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{item.lastUsedAt ? `最近使用 ${item.lastUsedAt}` : `创建于 ${item.createdAt}`}</Typography.Text>
|
||||
</Space>
|
||||
<Button size="small" status="danger" onClick={() => void handleDeleteWebAuthnCredential(item.id)}>删除</Button>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Space>
|
||||
<Divider />
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Typography.Title heading={6} style={{ margin: 0 }}>邮件 / 短信 OTP</Typography.Title>
|
||||
<Alert type="info" content="邮件 OTP 使用已启用的 Email 通知配置发送;短信 OTP 使用 Webhook 通知配置发送,payload 会包含 phone/code/purpose 字段。" />
|
||||
<Space wrap>
|
||||
<Tag color={user?.emailOtpEnabled ? 'green' : 'gray'} bordered>邮件 OTP {user?.emailOtpEnabled ? '已启用' : '未启用'}</Tag>
|
||||
<Tag color={user?.smsOtpEnabled ? 'green' : 'gray'} bordered>短信 OTP {user?.smsOtpEnabled ? '已启用' : '未启用'}</Tag>
|
||||
</Space>
|
||||
<Form.Item field="email" label="邮箱">
|
||||
<Input placeholder="启用邮件 OTP 时填写" />
|
||||
</Form.Item>
|
||||
<Form.Item field="phone" label="手机号">
|
||||
<Input placeholder="启用短信 OTP 时填写" />
|
||||
</Form.Item>
|
||||
<Space wrap>
|
||||
<Button loading={twoFactorLoading} onClick={() => void handleConfigureOtp('email', !user?.emailOtpEnabled)}>
|
||||
{user?.emailOtpEnabled ? '关闭邮件 OTP' : '启用邮件 OTP'}
|
||||
</Button>
|
||||
<Button loading={twoFactorLoading} onClick={() => void handleConfigureOtp('sms', !user?.smsOtpEnabled)}>
|
||||
{user?.smsOtpEnabled ? '关闭短信 OTP' : '启用短信 OTP'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
<Divider />
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Typography.Title heading={6} style={{ margin: 0 }}>可信设备</Typography.Title>
|
||||
<Tag color={trustedDevices.length > 0 ? 'green' : 'gray'} bordered>{trustedDevices.length} 个</Tag>
|
||||
</Space>
|
||||
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
|
||||
登录时勾选“信任此设备”后,30 天内该设备可在密码校验通过后跳过多因素验证。
|
||||
</Typography.Paragraph>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{trustedDevices.map((item) => (
|
||||
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', padding: '8px 0', borderTop: '1px solid var(--color-border)' }}>
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{item.name}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>最近使用 {item.lastUsedAt || '-'},到期 {item.expiresAt}</Typography.Text>
|
||||
</Space>
|
||||
<Button size="small" status="danger" onClick={() => void handleRevokeTrustedDevice(item.id)}>移除</Button>
|
||||
</div>
|
||||
))}
|
||||
{!securityDetailsLoading && trustedDevices.length === 0 ? <Typography.Text type="secondary">暂无可信设备</Typography.Text> : null}
|
||||
</Space>
|
||||
</Space>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Alert, Button, Card, Empty, Form, Input, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createUser, deleteUser, listUsers, updateUser, type UserRole, type UserSummary, type UserUpsertPayload } from '../../services/users'
|
||||
import { createUser, deleteUser, listUsers, resetUserTwoFactor, updateUser, type UserRole, type UserSummary, type UserUpsertPayload } from '../../services/users'
|
||||
import { clearTrustedDeviceToken } from '../../services/auth'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { isAdmin, roleLabel } from '../../utils/permissions'
|
||||
@@ -12,12 +13,13 @@ const roleOptions = [
|
||||
]
|
||||
|
||||
function createEmpty(): UserUpsertPayload {
|
||||
return { username: '', password: '', displayName: '', email: '', role: 'operator', disabled: false }
|
||||
return { username: '', password: '', displayName: '', email: '', phone: '', role: 'operator', disabled: false }
|
||||
}
|
||||
|
||||
// UsersPage admin 用户管理。非 admin 角色进入路由会被路由守卫拦截。
|
||||
export function UsersPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const setUser = useAuthStore((s) => s.setUser)
|
||||
const [items, setItems] = useState<UserSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -55,6 +57,7 @@ export function UsersPage() {
|
||||
password: '',
|
||||
displayName: item.displayName,
|
||||
email: item.email,
|
||||
phone: item.phone,
|
||||
role: item.role,
|
||||
disabled: item.disabled,
|
||||
})
|
||||
@@ -73,7 +76,13 @@ export function UsersPage() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await updateUser(editing.id, draft)
|
||||
const updated = await updateUser(editing.id, draft)
|
||||
if (updated.id === user?.id) {
|
||||
if (draft.password?.trim()) {
|
||||
clearTrustedDeviceToken(updated.username)
|
||||
}
|
||||
setUser(updated)
|
||||
}
|
||||
Message.success('用户已更新')
|
||||
} else {
|
||||
await createUser(draft)
|
||||
@@ -99,6 +108,21 @@ export function UsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetTwoFactor(item: UserSummary) {
|
||||
if (!window.confirm(`确定重置用户「${item.username}」的全部 MFA 配置吗?该用户之后可仅凭密码登录。`)) return
|
||||
try {
|
||||
const updated = await resetUserTwoFactor(item.id)
|
||||
if (updated.id === user?.id) {
|
||||
clearTrustedDeviceToken(updated.username)
|
||||
setUser(updated)
|
||||
}
|
||||
Message.success('MFA 已重置')
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '重置 MFA 失败'))
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
return <Alert type="warning" content="当前账号无权访问用户管理(仅 admin)" />
|
||||
}
|
||||
@@ -132,12 +156,27 @@ export function UsersPage() {
|
||||
</Space>
|
||||
) },
|
||||
{ title: '角色', dataIndex: 'role', render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag> },
|
||||
{ title: '邮箱', dataIndex: 'email', render: (v: string) => v || '-' },
|
||||
{ title: '邮箱 / 手机', dataIndex: 'email', render: (_: string, row: UserSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{row.email || '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.phone || '-'}</Typography.Text>
|
||||
</Space>
|
||||
) },
|
||||
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered>已停用</Tag> : <Tag color="green" bordered>启用</Tag> },
|
||||
{ title: 'MFA', dataIndex: 'mfaEnabled', render: (_: boolean, row: UserSummary) => row.mfaEnabled ? (
|
||||
<Space wrap size={4}>
|
||||
{row.twoFactorEnabled ? <Tag color="green" bordered>TOTP</Tag> : null}
|
||||
{row.webAuthnEnabled ? <Tag color="arcoblue" bordered>Passkey {row.webAuthnCredentialCount}</Tag> : null}
|
||||
{row.emailOtpEnabled ? <Tag color="purple" bordered>邮件</Tag> : null}
|
||||
{row.smsOtpEnabled ? <Tag color="orange" bordered>短信</Tag> : null}
|
||||
{row.twoFactorEnabled ? <Typography.Text type="secondary" style={{ fontSize: 12 }}>恢复码 {row.twoFactorRecoveryCodesRemaining}</Typography.Text> : null}
|
||||
</Space>
|
||||
) : <Tag bordered>未启用</Tag> },
|
||||
{ title: '创建时间', dataIndex: 'createdAt' },
|
||||
{ title: '操作', width: 180, render: (_: unknown, row: UserSummary) => (
|
||||
{ title: '操作', width: 260, render: (_: unknown, row: UserSummary) => (
|
||||
<Space>
|
||||
<Button size="small" type="text" onClick={() => openEdit(row)}>编辑</Button>
|
||||
{row.mfaEnabled && <Button size="small" type="text" onClick={() => void handleResetTwoFactor(row)}>重置 MFA</Button>}
|
||||
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(row)} disabled={row.id === user?.id}>删除</Button>
|
||||
</Space>
|
||||
) },
|
||||
@@ -163,6 +202,9 @@ export function UsersPage() {
|
||||
<Form.Item label="邮箱">
|
||||
<Input value={draft.email} onChange={(v) => setDraft({ ...draft, email: v })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="手机号">
|
||||
<Input value={draft.phone} onChange={(v) => setDraft({ ...draft, phone: v })} />
|
||||
</Form.Item>
|
||||
<Form.Item label={editing ? '新密码(留空不修改)' : '初始密码'} required={!editing}>
|
||||
<Input.Password value={draft.password} onChange={(v) => setDraft({ ...draft, password: v })} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -26,6 +26,23 @@ const categoryLabels: Record<string, string> = {
|
||||
const actionLabels: Record<string, string> = {
|
||||
login_success: '登录成功',
|
||||
login_failed: '登录失败',
|
||||
two_factor_required: '需要 MFA',
|
||||
two_factor_setup: '生成 TOTP',
|
||||
two_factor_enable: '启用 TOTP',
|
||||
two_factor_disable: '关闭 TOTP',
|
||||
two_factor_recovery_code_used: '使用恢复码',
|
||||
two_factor_recovery_codes_regenerate: '重建恢复码',
|
||||
webauthn_register: '注册通行密钥',
|
||||
webauthn_used: '使用通行密钥',
|
||||
webauthn_delete: '删除通行密钥',
|
||||
trusted_device_create: '信任设备',
|
||||
trusted_device_used: '使用可信设备',
|
||||
trusted_device_revoke: '移除可信设备',
|
||||
otp_enable: '启用 OTP',
|
||||
otp_disable: '关闭 OTP',
|
||||
otp_send: '发送 OTP',
|
||||
otp_used: '使用 OTP',
|
||||
reset_two_factor: '重置 MFA',
|
||||
setup: '系统初始化',
|
||||
change_password: '修改密码',
|
||||
create: '创建',
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Alert, Button, Card, Form, Input, Space, Typography, Message } from '@arco-design/web-react'
|
||||
import { IconCloud, IconLock, IconUser } from '@arco-design/web-react/icon'
|
||||
import { Button, Checkbox, Form, Input, Space, Typography, Message } from '@arco-design/web-react'
|
||||
import { IconCloud, IconLock, IconSafe, IconUser } from '@arco-design/web-react/icon'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { fetchSetupStatus } from '../../services/auth'
|
||||
import { beginWebAuthnLogin, fetchSetupStatus, sendLoginOtp } from '../../services/auth'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { getWebAuthnAssertion } from '../../utils/webauthn'
|
||||
|
||||
interface SetupFormValues {
|
||||
username: string
|
||||
@@ -15,12 +16,17 @@ interface SetupFormValues {
|
||||
interface LoginFormValues {
|
||||
username: string
|
||||
password: string
|
||||
twoFactorCode?: string
|
||||
rememberDevice?: boolean
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return error.response?.data?.message ?? '请求失败,请稍后重试'
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
@@ -29,8 +35,20 @@ export function LoginPage() {
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const doLogin = useAuthStore((state) => state.login)
|
||||
const doSetup = useAuthStore((state) => state.setup)
|
||||
const [loginForm] = Form.useForm<LoginFormValues>()
|
||||
const [initialized, setInitialized] = useState<boolean | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [mfaActionLoading, setMfaActionLoading] = useState('')
|
||||
const [twoFactorRequired, setTwoFactorRequired] = useState(false)
|
||||
|
||||
function resetTwoFactorPrompt() {
|
||||
if (!twoFactorRequired) {
|
||||
return
|
||||
}
|
||||
setTwoFactorRequired(false)
|
||||
loginForm.setFieldValue('twoFactorCode', undefined)
|
||||
loginForm.setFieldValue('rememberDevice', false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (authStatus === 'authenticated') {
|
||||
@@ -73,13 +91,77 @@ export function LoginPage() {
|
||||
const handleLogin = async (values: LoginFormValues) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await doLogin(values)
|
||||
await doLogin({
|
||||
...values,
|
||||
trustedDeviceName: values.rememberDevice ? navigator.userAgent.slice(0, 120) : undefined,
|
||||
})
|
||||
setTwoFactorRequired(false)
|
||||
Message.success('登录成功')
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const code = error.response?.data?.code
|
||||
if (code === 'AUTH_2FA_REQUIRED' || code === 'AUTH_2FA_INVALID') {
|
||||
setTwoFactorRequired(true)
|
||||
Message.error(resolveErrorMessage(error))
|
||||
return
|
||||
}
|
||||
}
|
||||
Message.error(resolveErrorMessage(error))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function readLoginCredentials(): (LoginFormValues & { username: string; password: string }) | null {
|
||||
const values = loginForm.getFieldsValue()
|
||||
if (!values.username?.trim() || !values.password?.trim()) {
|
||||
Message.error('请先输入用户名和密码')
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...values,
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendOTP(channel: 'email' | 'sms') {
|
||||
const values = readLoginCredentials()
|
||||
if (!values) return
|
||||
setMfaActionLoading(channel)
|
||||
try {
|
||||
await sendLoginOtp({ username: values.username, password: values.password, channel })
|
||||
Message.success(channel === 'email' ? '邮件验证码已发送' : '短信验证码已发送')
|
||||
} catch (error) {
|
||||
Message.error(resolveErrorMessage(error))
|
||||
} finally {
|
||||
setMfaActionLoading('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebAuthnLogin() {
|
||||
const values = readLoginCredentials()
|
||||
if (!values) return
|
||||
setMfaActionLoading('webauthn')
|
||||
try {
|
||||
const options = await beginWebAuthnLogin({ username: values.username, password: values.password })
|
||||
const assertion = await getWebAuthnAssertion(options)
|
||||
await doLogin({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
webAuthnAssertion: assertion,
|
||||
trustedDeviceToken: '',
|
||||
rememberDevice: values.rememberDevice,
|
||||
trustedDeviceName: navigator.userAgent.slice(0, 120),
|
||||
})
|
||||
setTwoFactorRequired(false)
|
||||
Message.success('登录成功')
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (error) {
|
||||
Message.error(resolveErrorMessage(error))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setMfaActionLoading('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,15 +263,30 @@ export function LoginPage() {
|
||||
</Button>
|
||||
</Form>
|
||||
) : (
|
||||
<Form<LoginFormValues> layout="vertical" onSubmit={handleLogin}>
|
||||
<Form<LoginFormValues> form={loginForm} layout="vertical" onSubmit={handleLogin}>
|
||||
<Form.Item field="username" label="用户名" rules={[{ required: true, minLength: 3 }]}>
|
||||
<Input placeholder="请输入用户名" prefix={<IconUser />} size="large" />
|
||||
<Input placeholder="请输入用户名" prefix={<IconUser />} size="large" onChange={resetTwoFactorPrompt} />
|
||||
</Form.Item>
|
||||
<Form.Item field="password" label="密码" rules={[{ required: true, minLength: 8 }]}>
|
||||
<Input.Password placeholder="请输入密码" prefix={<IconLock />} size="large" />
|
||||
<Input.Password placeholder="请输入密码" prefix={<IconLock />} size="large" onChange={resetTwoFactorPrompt} />
|
||||
</Form.Item>
|
||||
{twoFactorRequired && (
|
||||
<>
|
||||
<Form.Item field="twoFactorCode" label="验证码或恢复码" rules={[{ required: true, minLength: 6, maxLength: 32 }]}>
|
||||
<Input placeholder="请输入 TOTP、恢复码、邮件或短信验证码" prefix={<IconSafe />} size="large" maxLength={32} />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ marginTop: -8, marginBottom: 8 }}>
|
||||
<Button loading={mfaActionLoading === 'email'} onClick={() => void handleSendOTP('email')}>发送邮件验证码</Button>
|
||||
<Button loading={mfaActionLoading === 'sms'} onClick={() => void handleSendOTP('sms')}>发送短信验证码</Button>
|
||||
<Button loading={mfaActionLoading === 'webauthn'} onClick={() => void handleWebAuthnLogin()}>使用通行密钥</Button>
|
||||
</Space>
|
||||
<Form.Item field="rememberDevice" triggerPropName="checked">
|
||||
<Checkbox>信任此设备 30 天</Checkbox>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 8, height: 44, marginTop: 16 }}>
|
||||
登录
|
||||
{twoFactorRequired ? '验证并登录' : '登录'}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
@@ -9,18 +9,39 @@ export interface SetupPayload {
|
||||
export interface LoginPayload {
|
||||
username: string
|
||||
password: string
|
||||
twoFactorCode?: string
|
||||
webAuthnAssertion?: WebAuthnAssertion
|
||||
trustedDeviceToken?: string
|
||||
rememberDevice?: boolean
|
||||
trustedDeviceName?: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
displayName: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role: string
|
||||
mfaEnabled?: boolean
|
||||
twoFactorEnabled?: boolean
|
||||
twoFactorRecoveryCodesRemaining?: number
|
||||
webAuthnEnabled?: boolean
|
||||
webAuthnCredentialCount?: number
|
||||
trustedDeviceCount?: number
|
||||
emailOtpEnabled?: boolean
|
||||
smsOtpEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
token: string
|
||||
user: UserInfo
|
||||
trustedDeviceToken?: string
|
||||
trustedDevice?: TrustedDevice
|
||||
}
|
||||
|
||||
export function clearTrustedDeviceToken(_username?: string) {
|
||||
// 可信设备 token 由后端写入 HttpOnly cookie,前端不能也不应该读取。
|
||||
}
|
||||
|
||||
export async function fetchSetupStatus() {
|
||||
@@ -53,6 +74,177 @@ export async function changePassword(payload: ChangePasswordPayload) {
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export interface TwoFactorSetupPayload {
|
||||
currentPassword: string
|
||||
}
|
||||
|
||||
export interface TwoFactorSetupResult {
|
||||
secret: string
|
||||
otpAuthUrl: string
|
||||
qrCodeDataUrl: string
|
||||
twoFactorEnabled: boolean
|
||||
twoFactorConfirmed: boolean
|
||||
}
|
||||
|
||||
export interface TwoFactorCodesResult {
|
||||
user: UserInfo
|
||||
recoveryCodes: string[]
|
||||
}
|
||||
|
||||
export interface EnableTwoFactorPayload {
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface DisableTwoFactorPayload {
|
||||
currentPassword: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export type RegenerateRecoveryCodesPayload = DisableTwoFactorPayload
|
||||
|
||||
export type OTPChannel = 'email' | 'sms'
|
||||
|
||||
export interface OTPConfigPayload {
|
||||
currentPassword: string
|
||||
channel: OTPChannel
|
||||
enabled: boolean
|
||||
email?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
export interface SendLoginOTPPayload {
|
||||
username: string
|
||||
password: string
|
||||
channel: OTPChannel
|
||||
}
|
||||
|
||||
export interface WebAuthnCredentialDescriptor {
|
||||
type: 'public-key'
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface WebAuthnRegistrationOptions {
|
||||
challenge: string
|
||||
rp: { name: string; id: string }
|
||||
user: { id: string; name: string; displayName: string }
|
||||
pubKeyCredParams: Array<{ type: 'public-key'; alg: number }>
|
||||
timeout: number
|
||||
attestation: 'none'
|
||||
authenticatorSelection: { userVerification: UserVerificationRequirement }
|
||||
excludeCredentials: WebAuthnCredentialDescriptor[]
|
||||
}
|
||||
|
||||
export interface WebAuthnLoginOptions {
|
||||
challenge: string
|
||||
rpId: string
|
||||
timeout: number
|
||||
userVerification: UserVerificationRequirement
|
||||
allowCredentials: WebAuthnCredentialDescriptor[]
|
||||
}
|
||||
|
||||
export interface WebAuthnAttestation {
|
||||
id: string
|
||||
rawId: string
|
||||
type: 'public-key'
|
||||
response: {
|
||||
clientDataJSON: string
|
||||
attestationObject: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebAuthnAssertion {
|
||||
id: string
|
||||
rawId: string
|
||||
type: 'public-key'
|
||||
response: {
|
||||
clientDataJSON: string
|
||||
authenticatorData: string
|
||||
signature: string
|
||||
userHandle?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebAuthnCredential {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
lastUsedAt?: string
|
||||
}
|
||||
|
||||
export interface TrustedDevice {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
lastUsedAt: string
|
||||
expiresAt: string
|
||||
lastIp: string
|
||||
}
|
||||
|
||||
export async function prepareTwoFactor(payload: TwoFactorSetupPayload) {
|
||||
const response = await http.post<{ code: string; message: string; data: TwoFactorSetupResult }>('/auth/2fa/setup', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function enableTwoFactor(payload: EnableTwoFactorPayload) {
|
||||
const response = await http.post<{ code: string; message: string; data: TwoFactorCodesResult }>('/auth/2fa/enable', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function regenerateRecoveryCodes(payload: RegenerateRecoveryCodesPayload) {
|
||||
const response = await http.post<{ code: string; message: string; data: TwoFactorCodesResult }>('/auth/2fa/recovery-codes', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function disableTwoFactor(payload: DisableTwoFactorPayload) {
|
||||
const response = await http.delete<{ code: string; message: string; data: UserInfo }>('/auth/2fa', { data: payload })
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function configureOtp(payload: OTPConfigPayload) {
|
||||
const response = await http.put<{ code: string; message: string; data: UserInfo }>('/auth/otp/config', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function sendLoginOtp(payload: SendLoginOTPPayload) {
|
||||
const response = await http.post<{ code: string; message: string; data: { sent: boolean } }>('/auth/otp/send', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function beginWebAuthnRegistration(payload: { currentPassword: string }) {
|
||||
const response = await http.post<{ code: string; message: string; data: WebAuthnRegistrationOptions }>('/auth/webauthn/register/options', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function finishWebAuthnRegistration(payload: { name?: string; credential: WebAuthnAttestation }) {
|
||||
const response = await http.post<{ code: string; message: string; data: UserInfo }>('/auth/webauthn/register/finish', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function beginWebAuthnLogin(payload: { username: string; password: string }) {
|
||||
const response = await http.post<{ code: string; message: string; data: WebAuthnLoginOptions }>('/auth/webauthn/login/options', payload)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function listWebAuthnCredentials() {
|
||||
const response = await http.get<{ code: string; message: string; data: WebAuthnCredential[] }>('/auth/webauthn/credentials')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function deleteWebAuthnCredential(id: string, payload: { currentPassword: string }) {
|
||||
const response = await http.delete<{ code: string; message: string; data: UserInfo }>(`/auth/webauthn/credentials/${id}`, { data: payload })
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function listTrustedDevices() {
|
||||
const response = await http.get<{ code: string; message: string; data: TrustedDevice[] }>('/auth/trusted-devices')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function revokeTrustedDevice(id: string, payload: { currentPassword: string }) {
|
||||
const response = await http.delete<{ code: string; message: string; data: { deleted: boolean } }>(`/auth/trusted-devices/${id}`, { data: payload })
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const response = await http.post<{ code: string; message: string; data: { loggedOut: boolean } }>('/auth/logout')
|
||||
return response.data.data
|
||||
|
||||
@@ -12,6 +12,7 @@ let unauthorizedHandler: (() => void) | null = null
|
||||
export const http = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
export function setAccessToken(token: string) {
|
||||
|
||||
@@ -7,8 +7,17 @@ export interface UserSummary {
|
||||
username: string
|
||||
displayName: string
|
||||
email: string
|
||||
phone: string
|
||||
role: UserRole
|
||||
disabled: boolean
|
||||
mfaEnabled: boolean
|
||||
twoFactorEnabled: boolean
|
||||
twoFactorRecoveryCodesRemaining: number
|
||||
webAuthnEnabled: boolean
|
||||
webAuthnCredentialCount: number
|
||||
trustedDeviceCount: number
|
||||
emailOtpEnabled: boolean
|
||||
smsOtpEnabled: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -17,6 +26,7 @@ export interface UserUpsertPayload {
|
||||
password?: string
|
||||
displayName: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role: UserRole
|
||||
disabled: boolean
|
||||
}
|
||||
@@ -40,3 +50,8 @@ export async function deleteUser(id: number) {
|
||||
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/users/${id}`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function resetUserTwoFactor(id: number) {
|
||||
const response = await http.post<ApiEnvelope<UserSummary>>(`/users/${id}/2fa/reset`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface AuthState {
|
||||
setup: (payload: SetupPayload) => Promise<void>
|
||||
logout: () => void
|
||||
applyAuth: (token: string, user: UserInfo) => void
|
||||
setUser: (user: UserInfo) => void
|
||||
}
|
||||
|
||||
function clearAuthState(set: (partial: Partial<AuthState>) => void) {
|
||||
@@ -65,6 +66,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
setAccessToken(token)
|
||||
set({ token, user, status: 'authenticated', bootstrapped: true })
|
||||
},
|
||||
setUser: (user) => {
|
||||
set({ user })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'backupx-auth',
|
||||
|
||||
@@ -2,12 +2,27 @@ export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
mfaEnabled?: boolean;
|
||||
twoFactorEnabled?: boolean;
|
||||
twoFactorRecoveryCodesRemaining?: number;
|
||||
webAuthnEnabled?: boolean;
|
||||
webAuthnCredentialCount?: number;
|
||||
trustedDeviceCount?: number;
|
||||
emailOtpEnabled?: boolean;
|
||||
smsOtpEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
twoFactorCode?: string;
|
||||
webAuthnAssertion?: unknown;
|
||||
trustedDeviceToken?: string;
|
||||
rememberDevice?: boolean;
|
||||
trustedDeviceName?: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
|
||||
88
web/src/utils/webauthn.ts
Normal file
88
web/src/utils/webauthn.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { WebAuthnAssertion, WebAuthnAttestation, WebAuthnLoginOptions, WebAuthnRegistrationOptions } from '../services/auth'
|
||||
|
||||
function base64UrlToBuffer(value: string) {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=')
|
||||
const binary = atob(padded)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
function bufferToBase64Url(buffer: ArrayBuffer) {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let index = 0; index < bytes.byteLength; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index])
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function assertWebAuthnAvailable() {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error('当前浏览器不支持通行密钥')
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWebAuthnCredential(options: WebAuthnRegistrationOptions): Promise<WebAuthnAttestation> {
|
||||
assertWebAuthnAvailable()
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
...options,
|
||||
challenge: base64UrlToBuffer(options.challenge),
|
||||
user: {
|
||||
...options.user,
|
||||
id: base64UrlToBuffer(options.user.id),
|
||||
},
|
||||
excludeCredentials: options.excludeCredentials.map((item) => ({
|
||||
...item,
|
||||
id: base64UrlToBuffer(item.id),
|
||||
})),
|
||||
},
|
||||
}) as PublicKeyCredential | null
|
||||
if (!credential) {
|
||||
throw new Error('通行密钥创建已取消')
|
||||
}
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url(credential.rawId),
|
||||
type: 'public-key',
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
attestationObject: bufferToBase64Url(response.attestationObject),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWebAuthnAssertion(options: WebAuthnLoginOptions): Promise<WebAuthnAssertion> {
|
||||
assertWebAuthnAvailable()
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: base64UrlToBuffer(options.challenge),
|
||||
rpId: options.rpId,
|
||||
timeout: options.timeout,
|
||||
userVerification: options.userVerification,
|
||||
allowCredentials: options.allowCredentials.map((item) => ({
|
||||
...item,
|
||||
id: base64UrlToBuffer(item.id),
|
||||
})),
|
||||
},
|
||||
}) as PublicKeyCredential | null
|
||||
if (!credential) {
|
||||
throw new Error('通行密钥验证已取消')
|
||||
}
|
||||
const response = credential.response as AuthenticatorAssertionResponse
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url(credential.rawId),
|
||||
type: 'public-key',
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
authenticatorData: bufferToBase64Url(response.authenticatorData),
|
||||
signature: bufferToBase64Url(response.signature),
|
||||
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user