From 5af5f97efb0ccc561be235a5d30bbd5ca7417abc Mon Sep 17 00:00:00 2001 From: Wu Qing <3184394176@qq.com> Date: Sat, 25 Apr 2026 22:14:50 +0800 Subject: [PATCH] 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. --- docs-site/docusaurus.config.ts | 28 +- docs-site/i18n/zh-CN/code.json | 92 +++- .../docusaurus-theme-classic/footer.json | 10 +- .../docusaurus-theme-classic/navbar.json | 8 + .../components/HomepageCommunity/index.tsx | 329 ++++++++++++ .../HomepageCommunity/styles.module.css | 429 +++++++++++++++ .../src/components/HomepageFeatures/index.tsx | 2 +- .../HomepageFeatures/styles.module.css | 51 +- .../src/components/HomepageShowcase/index.tsx | 2 +- .../HomepageShowcase/styles.module.css | 109 +++- docs-site/src/css/custom.css | 54 +- docs-site/src/pages/community.tsx | 19 + docs-site/src/pages/index.module.css | 469 +++++++++++----- docs-site/src/pages/index.tsx | 119 +++-- docs-site/src/pages/sponsors.tsx | 39 ++ server/go.mod | 2 +- server/internal/app/app.go | 51 +- server/internal/http/auth_handler.go | 324 +++++++++++ server/internal/http/install_flow_test.go | 10 +- server/internal/http/router.go | 22 +- server/internal/http/router_test.go | 185 +++++-- server/internal/http/user_handler.go | 15 + server/internal/model/user.go | 25 +- server/internal/security/otp_code.go | 23 + server/internal/security/recovery_code.go | 49 ++ server/internal/security/totp.go | 68 +++ server/internal/security/webauthn.go | 447 ++++++++++++++++ server/internal/service/auth_methods.go | 179 +++++++ server/internal/service/auth_otp.go | 252 +++++++++ server/internal/service/auth_service.go | 505 ++++++++++++++++-- server/internal/service/auth_service_test.go | 427 +++++++++++++++ .../internal/service/auth_trusted_device.go | 221 ++++++++ server/internal/service/auth_webauthn.go | 366 +++++++++++++ .../internal/service/notification_service.go | 57 +- server/internal/service/user_service.go | 99 +++- server/internal/service/user_service_test.go | 124 +++++ .../notifications/field-config.test.ts | 1 + web/src/layouts/AppLayout.tsx | 385 ++++++++++++- web/src/pages/admin/UsersPage.tsx | 52 +- web/src/pages/audit/AuditLogsPage.tsx | 17 + web/src/pages/login/LoginPage.tsx | 115 +++- web/src/services/auth.ts | 192 +++++++ web/src/services/http.ts | 1 + web/src/services/users.ts | 15 + web/src/stores/auth.ts | 4 + web/src/types/auth.ts | 15 + web/src/utils/webauthn.ts | 88 +++ 47 files changed, 5718 insertions(+), 378 deletions(-) create mode 100644 docs-site/src/components/HomepageCommunity/index.tsx create mode 100644 docs-site/src/components/HomepageCommunity/styles.module.css create mode 100644 docs-site/src/pages/community.tsx create mode 100644 docs-site/src/pages/sponsors.tsx create mode 100644 server/internal/security/otp_code.go create mode 100644 server/internal/security/recovery_code.go create mode 100644 server/internal/security/totp.go create mode 100644 server/internal/security/webauthn.go create mode 100644 server/internal/service/auth_methods.go create mode 100644 server/internal/service/auth_otp.go create mode 100644 server/internal/service/auth_trusted_device.go create mode 100644 server/internal/service/auth_webauthn.go create mode 100644 server/internal/service/user_service_test.go create mode 100644 web/src/utils/webauthn.ts diff --git a/docs-site/docusaurus.config.ts b/docs-site/docusaurus.config.ts index acbda64..2f0d964 100644 --- a/docs-site/docusaurus.config.ts +++ b/docs-site/docusaurus.config.ts @@ -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`, }, diff --git a/docs-site/i18n/zh-CN/code.json b/docs-site/i18n/zh-CN/code.json index 57998b0..e95c02f 100644 --- a/docs-site/i18n/zh-CN/code.json +++ b/docs-site/i18n/zh-CN/code.json @@ -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 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、可靠发布、恢复信心和更完善的文档。"} } diff --git a/docs-site/i18n/zh-CN/docusaurus-theme-classic/footer.json b/docs-site/i18n/zh-CN/docusaurus-theme-classic/footer.json index 07576ba..1009cfb 100644 --- a/docs-site/i18n/zh-CN/docusaurus-theme-classic/footer.json +++ b/docs-site/i18n/zh-CN/docusaurus-theme-classic/footer.json @@ -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": "赞助层级"} } diff --git a/docs-site/i18n/zh-CN/docusaurus-theme-classic/navbar.json b/docs-site/i18n/zh-CN/docusaurus-theme-classic/navbar.json index 78dbace..96c2044 100644 --- a/docs-site/i18n/zh-CN/docusaurus-theme-classic/navbar.json +++ b/docs-site/i18n/zh-CN/docusaurus-theme-classic/navbar.json @@ -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" diff --git a/docs-site/src/components/HomepageCommunity/index.tsx b/docs-site/src/components/HomepageCommunity/index.tsx new file mode 100644 index 0000000..b363eee --- /dev/null +++ b/docs-site/src/components/HomepageCommunity/index.tsx @@ -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> + ); +} diff --git a/docs-site/src/components/HomepageCommunity/styles.module.css b/docs-site/src/components/HomepageCommunity/styles.module.css new file mode 100644 index 0000000..3753a33 --- /dev/null +++ b/docs-site/src/components/HomepageCommunity/styles.module.css @@ -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; + } +} diff --git a/docs-site/src/components/HomepageFeatures/index.tsx b/docs-site/src/components/HomepageFeatures/index.tsx index 41010c2..d0a4202 100644 --- a/docs-site/src/components/HomepageFeatures/index.tsx +++ b/docs-site/src/components/HomepageFeatures/index.tsx @@ -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> )} </> diff --git a/docs-site/src/components/HomepageFeatures/styles.module.css b/docs-site/src/components/HomepageFeatures/styles.module.css index cb50d11..9659f03 100644 --- a/docs-site/src/components/HomepageFeatures/styles.module.css +++ b/docs-site/src/components/HomepageFeatures/styles.module.css @@ -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; + } +} diff --git a/docs-site/src/components/HomepageShowcase/index.tsx b/docs-site/src/components/HomepageShowcase/index.tsx index 4a869ca..44378ab 100644 --- a/docs-site/src/components/HomepageShowcase/index.tsx +++ b/docs-site/src/components/HomepageShowcase/index.tsx @@ -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> diff --git a/docs-site/src/components/HomepageShowcase/styles.module.css b/docs-site/src/components/HomepageShowcase/styles.module.css index c40b229..22de943 100644 --- a/docs-site/src/components/HomepageShowcase/styles.module.css +++ b/docs-site/src/components/HomepageShowcase/styles.module.css @@ -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; + } } diff --git a/docs-site/src/css/custom.css b/docs-site/src/css/custom.css index 981db85..1721433 100644 --- a/docs-site/src/css/custom.css +++ b/docs-site/src/css/custom.css @@ -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; + } +} diff --git a/docs-site/src/pages/community.tsx b/docs-site/src/pages/community.tsx new file mode 100644 index 0000000..4e2b08c --- /dev/null +++ b/docs-site/src/pages/community.tsx @@ -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> + ); +} diff --git a/docs-site/src/pages/index.module.css b/docs-site/src/pages/index.module.css index bf2d549..80d6410 100644 --- a/docs-site/src/pages/index.module.css +++ b/docs-site/src/pages/index.module.css @@ -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; + } } diff --git a/docs-site/src/pages/index.tsx b/docs-site/src/pages/index.tsx index c7049e8..4a8971a 100644 --- a/docs-site/src/pages/index.tsx +++ b/docs-site/src/pages/index.tsx @@ -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> ); diff --git a/docs-site/src/pages/sponsors.tsx b/docs-site/src/pages/sponsors.tsx new file mode 100644 index 0000000..5626991 --- /dev/null +++ b/docs-site/src/pages/sponsors.tsx @@ -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> + ); +} diff --git a/server/go.mod b/server/go.mod index 4f912e2..a3a289a 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/internal/app/app.go b/server/internal/app/app.go index 762afb6..3aaa838 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -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, diff --git a/server/internal/http/auth_handler.go b/server/internal/http/auth_handler.go index 6b25b73..ff76c21 100644 --- a/server/internal/http/auth_handler.go +++ b/server/internal/http/auth_handler.go @@ -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") +} diff --git a/server/internal/http/install_flow_test.go b/server/internal/http/install_flow_test.go index c14786a..ecf39c3 100644 --- a/server/internal/http/install_flow_test.go +++ b/server/internal/http/install_flow_test.go @@ -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) diff --git a/server/internal/http/router.go b/server/internal/http/router.go index a911b03..f1ca3a2 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -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 { diff --git a/server/internal/http/router_test.go b/server/internal/http/router_test.go index 3520817..f71fb41 100644 --- a/server/internal/http/router_test.go +++ b/server/internal/http/router_test.go @@ -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 +} diff --git a/server/internal/http/user_handler.go b/server/internal/http/user_handler.go index 0dfc441..6963120 100644 --- a/server/internal/http/user_handler.go +++ b/server/internal/http/user_handler.go @@ -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) +} diff --git a/server/internal/model/user.go b/server/internal/model/user.go index 69785a3..0d75096 100644 --- a/server/internal/model/user.go +++ b/server/internal/model/user.go @@ -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"` diff --git a/server/internal/security/otp_code.go b/server/internal/security/otp_code.go new file mode 100644 index 0000000..d26669c --- /dev/null +++ b/server/internal/security/otp_code.go @@ -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) +} diff --git a/server/internal/security/recovery_code.go b/server/internal/security/recovery_code.go new file mode 100644 index 0000000..895e4af --- /dev/null +++ b/server/internal/security/recovery_code.go @@ -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 +} diff --git a/server/internal/security/totp.go b/server/internal/security/totp.go new file mode 100644 index 0000000..c21f53e --- /dev/null +++ b/server/internal/security/totp.go @@ -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)) +} diff --git a/server/internal/security/webauthn.go b/server/internal/security/webauthn.go new file mode 100644 index 0000000..d52eb46 --- /dev/null +++ b/server/internal/security/webauthn.go @@ -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 +} diff --git a/server/internal/service/auth_methods.go b/server/internal/service/auth_methods.go new file mode 100644 index 0000000..ed2c7fe --- /dev/null +++ b/server/internal/service/auth_methods.go @@ -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, + } +} diff --git a/server/internal/service/auth_otp.go b/server/internal/service/auth_otp.go new file mode 100644 index 0000000..2c4e6cc --- /dev/null +++ b/server/internal/service/auth_otp.go @@ -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)) +} diff --git a/server/internal/service/auth_service.go b/server/internal/service/auth_service.go index fd2633a..0b3ce63 100644 --- a/server/internal/service/auth_service.go +++ b/server/internal/service/auth_service.go @@ -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, } } diff --git a/server/internal/service/auth_service_test.go b/server/internal/service/auth_service_test.go index 940072b..33048b9 100644 --- a/server/internal/service/auth_service_test.go +++ b/server/internal/service/auth_service_test.go @@ -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) + } +} diff --git a/server/internal/service/auth_trusted_device.go b/server/internal/service/auth_trusted_device.go new file mode 100644 index 0000000..310adc2 --- /dev/null +++ b/server/internal/service/auth_trusted_device.go @@ -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]) +} diff --git a/server/internal/service/auth_webauthn.go b/server/internal/service/auth_webauthn.go new file mode 100644 index 0000000..e2d827e --- /dev/null +++ b/server/internal/service/auth_webauthn.go @@ -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 +} diff --git a/server/internal/service/notification_service.go b/server/internal/service/notification_service.go index be241bc..a37d576 100644 --- a/server/internal/service/notification_service.go +++ b/server/internal/service/notification_service.go @@ -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) { diff --git a/server/internal/service/user_service.go b/server/internal/service/user_service.go index 6107950..1df1f3e 100644 --- a/server/internal/service/user_service.go +++ b/server/internal/service/user_service.go @@ -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"), } } diff --git a/server/internal/service/user_service_test.go b/server/internal/service/user_service_test.go new file mode 100644 index 0000000..9d9b28a --- /dev/null +++ b/server/internal/service/user_service_test.go @@ -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") + } +} diff --git a/web/src/components/notifications/field-config.test.ts b/web/src/components/notifications/field-config.test.ts index 78782de..34b3943 100644 --- a/web/src/components/notifications/field-config.test.ts +++ b/web/src/components/notifications/field-config.test.ts @@ -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', () => { diff --git a/web/src/layouts/AppLayout.tsx b/web/src/layouts/AppLayout.tsx index cc6b045..02b18c0 100644 --- a/web/src/layouts/AppLayout.tsx +++ b/web/src/layouts/AppLayout.tsx @@ -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> ) } diff --git a/web/src/pages/admin/UsersPage.tsx b/web/src/pages/admin/UsersPage.tsx index 62bafc6..61b18c8 100644 --- a/web/src/pages/admin/UsersPage.tsx +++ b/web/src/pages/admin/UsersPage.tsx @@ -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> diff --git a/web/src/pages/audit/AuditLogsPage.tsx b/web/src/pages/audit/AuditLogsPage.tsx index 182c648..b82c8b4 100644 --- a/web/src/pages/audit/AuditLogsPage.tsx +++ b/web/src/pages/audit/AuditLogsPage.tsx @@ -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: '创建', diff --git a/web/src/pages/login/LoginPage.tsx b/web/src/pages/login/LoginPage.tsx index 22ad35b..038a8dd 100644 --- a/web/src/pages/login/LoginPage.tsx +++ b/web/src/pages/login/LoginPage.tsx @@ -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> )} diff --git a/web/src/services/auth.ts b/web/src/services/auth.ts index dcfbd61..b7b4ad2 100644 --- a/web/src/services/auth.ts +++ b/web/src/services/auth.ts @@ -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 diff --git a/web/src/services/http.ts b/web/src/services/http.ts index 5bac3d0..70891cd 100644 --- a/web/src/services/http.ts +++ b/web/src/services/http.ts @@ -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) { diff --git a/web/src/services/users.ts b/web/src/services/users.ts index 5519c86..7dab525 100644 --- a/web/src/services/users.ts +++ b/web/src/services/users.ts @@ -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) +} diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 8290eab..910176c 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -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', diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts index 8850e72..c945f1e 100644 --- a/web/src/types/auth.ts +++ b/web/src/types/auth.ts @@ -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 { diff --git a/web/src/utils/webauthn.ts b/web/src/utils/webauthn.ts new file mode 100644 index 0000000..03bd670 --- /dev/null +++ b/web/src/utils/webauthn.ts @@ -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, + }, + } +}