From 5aaac0b1665984ae908da57fa853862bd80cce9e Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 8 Jul 2026 23:37:19 +0800 Subject: [PATCH] feat: add auth provider boundary --- README.md | 4 +- docs/04-architecture.md | 25 +- docs/06-tasks.md | 2 +- docs/api.md | 19 +- docs/current-state.md | 20 +- docs/method-map.md | 14 +- docs/routes.md | 4 +- .../bindings/cmbone/internal/models/models.js | 126 ++++++++ .../cmbone/internal/services/authservice.js | 80 +++++ .../cmbone/internal/services/index.js | 2 + frontend/src/layouts/MainLayout.vue | 12 +- frontend/src/modules/auth/LoginPage.vue | 51 +++- internal/models/auth.go | 29 ++ internal/services/auth_service.go | 284 ++++++++++++++++++ internal/services/store.go | 21 ++ internal/services/store_test.go | 94 ++++++ internal/services/systeminfo.go | 14 +- main.go | 1 + progress.md | 26 ++ 19 files changed, 788 insertions(+), 40 deletions(-) create mode 100644 frontend/bindings/cmbone/internal/services/authservice.js create mode 100644 internal/models/auth.go create mode 100644 internal/services/auth_service.go diff --git a/README.md b/README.md index 0f28548..eb0497c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - 后端:Go 1.23.0、Wails v3.0.0-alpha.9、SQLite - 前端:Vue 3、TypeScript、Vite、Tailwind CSS、Ant Design Vue -- 当前功能:多语言、主题切换、本地配置、快捷键、OCR 示例 +- 当前功能:多语言、主题切换、本地配置、快捷键、本地登录 provider、OCR 示例 - 目标模板模块:登录、设置、主业务、审计日志、运行日志、数据统计、常用组件示例 ## 环境准备 @@ -69,6 +69,8 @@ Windows 下也可以直接运行: `dev.bat` 会从 `9245` 开始选择一个空闲 Vite 端口,避免已有 dev server 占用端口时无法打开 GUI。 开发启动使用快速前端 dev 构建以尽快打开桌面窗口;提交前仍以 `npm run build` 做完整 TypeScript 检查和生产构建。 +本地演示登录账号为 `admin` / `admin123`;密码由后端初始化为 hash,不写在前端页面里。 + ## 维护约定 - Go 模块名统一为 `cmbone`。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index cf5d171..ec77baf 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -13,7 +13,7 @@ main.go ├─ HotkeyService ├─ SystemInfo ├─ OCRService # 当前存量示例 - ├─ AuthService # 目标模块 + ├─ AuthService # 已实现 provider 边界 ├─ AuditLogService # 目标模块 ├─ AppLogService # 目标模块 ├─ ImageOptimizationService # 目标业务样例 @@ -42,9 +42,12 @@ frontend/src - `internal/services/ocr_service.go`:当前 OCR 存量示例,后续可演进为图片 AI 能力的一部分。 - `platform/`:隔离 Windows / macOS 热键和系统能力差异。 +已实现边界: + +- `AuthService`:登录、退出、会话读取;内部通过 `AuthProvider` 接入本地固定账号或真实后端。当前 `LocalAuthProvider` 可用,`RemoteAuthProvider` 保留边界并返回未配置错误。 + 目标新增边界: -- `AuthService`:登录、退出、会话读取;内部通过 `AuthProvider` 接入本地固定账号或真实后端。 - `AuditLogService`:记录和查询用户操作审计日志。 - `AppLogService`:记录和查询运行错误,必要时可落本地滚动日志文件。 - `ImageOptimizationService`:商品图片 AI 优化任务的创建、状态、结果和失败原因。 @@ -69,7 +72,7 @@ frontend/src ## 登录架构 -登录必须通过 provider 边界实现: +登录已经通过 provider 边界实现: ```text AuthService @@ -79,8 +82,10 @@ AuthService ``` - `LocalAuthProvider`:用于模板演示和离线模式,固定账号密码由后端配置或安全哈希保存,不写死在前端。 -- `RemoteAuthProvider`:用于真实后端,负责请求登录接口并保存 token / session。 +- `RemoteAuthProvider`:用于真实后端,负责请求登录接口并保存 token / session;当前只保留接口边界并返回未配置错误。 - 前端只关心登录、退出和当前会话,不关心 provider 细节。 +- 默认本地账号由 `appconfig` 初始化,用户名为 `admin`,默认密码为 `admin123`,密码以 salt + SHA-256 hash 形式保存。 +- 登录成功、失败和退出会写入 `audit_logs`。 ## 日志架构 @@ -118,8 +123,6 @@ appconfig( ) ``` -目标新增表: - ```sql auth_sessions( id INTEGER PRIMARY KEY, @@ -127,8 +130,8 @@ auth_sessions( username TEXT, provider TEXT, token TEXT, - created_at DATETIME, - expires_at DATETIME, + created_at TEXT, + expires_at TEXT, active INTEGER ) ``` @@ -141,10 +144,12 @@ audit_logs( target_type TEXT, target_id TEXT, detail TEXT, - created_at DATETIME + created_at TEXT ) ``` +目标新增表: + ```sql app_logs( id INTEGER PRIMARY KEY, @@ -208,7 +213,7 @@ image_task_results( - 第二窗口保持当前小窗定位,不参与最大化、尺寸恢复和主布局填充逻辑。 - 后续增强再保存主窗口尺寸和最大化状态,下次启动恢复用户选择。 -实现状态:主窗口初始尺寸、居中、可调整大小、根容器和主布局填充已落地;主应用路由骨架已落地。窗口尺寸持久化依赖设置 schema 后续实现。 +实现状态:主窗口初始尺寸、居中、可调整大小、根容器和主布局填充已落地;主应用路由骨架已落地;登录 provider 边界已落地。窗口尺寸持久化依赖设置 schema 后续实现。 ## 关键风险 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index b41d881..bd20588 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -29,7 +29,7 @@ | T-010 | 确定 Windows-first 窗口与布局策略 | T-002 | 主窗口默认不最大化,初始 `1280x800`,最小 `1024x720`,居中,可调整大小;页面根容器 `width: 100%`、`height: 100vh`;内部用 flex/grid 填满窗口;第二窗口保持小窗 | DONE | | T-011 | 实现主窗口启动参数与根布局策略 | T-010 | Go 侧设置主窗口初始宽高、居中、可调整大小;前端根容器和主布局填满窗口;阅读型内容有最大宽度,列表/仪表盘/设置页可自适应拉伸 | DONE | | T-012 | 搭建主应用路由骨架 | T-011 | `login`、`dashboard`、`image-optimizer`、`logs`、`statistics`、`settings`、`components` 路由可访问 | DONE | -| T-013 | 实现登录 provider 边界 | T-012 | 支持本地固定账号 provider;预留 remote provider;账号密码不写死在前端;登录事件写审计日志 | TODO | +| T-013 | 实现登录 provider 边界 | T-012 | 支持本地固定账号 provider;预留 remote provider;账号密码不写死在前端;登录事件写审计日志 | DONE | | T-014 | 收敛设置模块 schema | T-012 | 主题、语言、窗口行为、快捷键、数据目录、日志策略都有统一配置定义和默认值 | TODO | | T-015 | 持久化窗口尺寸和最大化状态 | T-014 | 保存主窗口尺寸和最大化状态;下次启动恢复用户上次选择;第二窗口不参与该逻辑 | TODO | diff --git a/docs/api.md b/docs/api.md index dd59b5f..361f151 100644 --- a/docs/api.md +++ b/docs/api.md @@ -61,24 +61,27 @@ - 失败时返回错误给前端。 - 平台依赖或能力缺失时,不应导致主窗口不可启动。 -## 目标新增服务 - ### AuthService -目标方法: +文件:`internal/services/auth_service.go` -- `Login(username string, password string) (Session, error)`:登录并返回会话。 -- `Logout() error`:退出当前会话。 -- `GetCurrentSession() (Session, error)`:读取当前会话。 +- `GetAuthProviders() []models.AuthProviderOption`:返回可用 provider 列表。 +- `GetAuthMode() string`:读取当前 auth provider。 - `SetAuthMode(mode string) error`:切换 `local` 或 `remote` provider。 +- `Login(provider string, username string, password string) (models.AuthSession, error)`:登录并返回会话。 +- `Logout() error`:退出当前会话。 +- `GetCurrentSession() (models.AuthSession, error)`:读取当前有效会话;无会话时返回空 session。 约束: - 前端不得写死固定账号密码。 -- `LocalAuthProvider` 只用于演示和离线模式。 -- `RemoteAuthProvider` 用于真实后端登录。 +- `LocalAuthProvider` 只用于演示和离线模式,默认账号来自后端初始化配置。 +- 默认本地演示账号:`admin` / `admin123`;密码在数据库中以 salt + SHA-256 hash 保存,不在前端页面或绑定中保存。 +- `RemoteAuthProvider` 用于真实后端登录,目前保留边界并返回未配置错误。 - 登录成功、失败和退出必须写审计日志。 +## 目标新增服务 + ### AuditLogService 目标方法: diff --git a/docs/current-state.md b/docs/current-state.md index 14c7705..01f20b7 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,7 +6,7 @@ 项目处于可复用桌面应用模板的产品定位校准阶段。目标已经从迁移后的基础工程调整为“Windows-first、可扩展到其他平台的桌面端应用模板”。 -当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例和模板级模块入口。登录、商品图片 AI 优化、审计日志、运行日志和数据统计仍是骨架页面,尚未接入真实后端服务和数据模型。 +当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架和登录 provider 边界阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口和本地登录能力。商品图片 AI 优化、完整审计日志查询、运行日志和数据统计仍是骨架页面,尚未接入真实后端服务和业务数据模型。 ## 已确认事实 @@ -14,7 +14,7 @@ - 主要运行场景:Windows 10 以及以后的 Windows 操作系统。 - 跨平台策略:Windows-first,保留 macOS / Linux 适配边界,不在 MVP 承诺完整体验。 - 第一批业务样例:电商商品图片 AI 优化。 -- 登录策略:本地固定账号 provider + 真实后端 provider 的可替换边界。 +- 登录策略:本地固定账号 provider 已实现,真实后端 provider 已预留边界。 - 日志策略:审计日志用于用户动作追踪,运行日志用于系统排错。 - 第一批统计指标:今日处理图片数、AI 优化成功率、平均处理耗时、失败任务数、今日操作日志数、最近 7 天处理趋势。 - Go 模块名:`cmbone` @@ -24,6 +24,7 @@ - 前端框架:Vue 3、Vite、TypeScript、Tailwind CSS、Ant Design Vue - 当前 Wails 前端 bindings:由 `wails3 build` / `wails3 generate bindings` 生成的 `.js` 文件。 - 本地数据库:SQLite,用户配置目录下 `cmbone/cmbone.db` +- 当前本地演示账号:`admin` / `admin123`,密码由后端初始化为 salt + SHA-256 hash,不写死在前端。 - 当前主窗口路由:`/#/` 重定向到 `/#/dashboard`。 - 当前主应用模块路由:`/#/login`、`/#/dashboard`、`/#/image-optimizer`、`/#/logs`、`/#/statistics`、`/#/settings`、`/#/components`。 - 当前兼容重定向路由:`/#/business/image-optimizer`、`/#/logs/audit`、`/#/logs/app`。 @@ -32,6 +33,8 @@ - 已实现页面布局策略:根容器 `width: 100%`、`height: 100vh`,内部 flex/grid 填满窗口。 - 已实现内容区策略:阅读型内容保留最大宽度;列表、仪表盘、设置页自适应拉伸。 - 已保留第二窗口策略:保持小窗,不参与主窗口最大化和尺寸恢复逻辑。 +- 已实现 AuthService:`GetAuthProviders`、`GetAuthMode`、`SetAuthMode`、`Login`、`Logout`、`GetCurrentSession`。 +- 已实现登录审计留痕:登录成功、登录失败和退出写入 `audit_logs`。 ## 当前命令 @@ -66,7 +69,7 @@ wails3 build - 模板范围容易膨胀,必须按任务小步实现。 - Wails v3 alpha 版本需要保持 CLI、Go 模块和前端 runtime 匹配。 -- 登录 provider、AI provider、日志和统计模块尚未实现,当前只是文档目标。 +- 真实后端登录 provider、AI provider、完整日志查询和统计模块尚未实现,当前仍是后续任务。 - OCR 和热键依赖平台实现,跨平台修改需要分别验证。 - 当前前端以构建检查为主,暂未引入专门的前端单元测试。 - bindings 生成依赖本机安装的 `wails3`。 @@ -124,9 +127,18 @@ wails3 build - `frontend/src/router/index.ts` 已切换为主应用模块路由,第二窗口继续保留 `/#/second`。 - `npm run typecheck`、`npm run build`、`npm run build:dev`、`go test ./...`、`go build -o bin\cmbone.exe .`、`cmd /c build.bat` 和 `dev.bat` 烟测通过。 +同日完成登录 provider 边界: + +- 新增 `AuthService`、`LocalAuthProvider` 和 `RemoteAuthProvider`。 +- 新增 `auth_sessions` 和 `audit_logs` 迁移。 +- 登录页通过 Wails bindings 调用后端 AuthService,不在前端写固定账号密码。 +- 默认本地演示账号为 `admin` / `admin123`,密码以后端配置 hash 形式保存。 +- 登录成功、失败和退出会写审计日志。 +- `go test ./...`、`npm run typecheck`、`npm run build`、`npm run build:dev`、`go build -o bin\cmbone.exe .`、`cmd /c build.bat` 和 `dev.bat` 烟测通过。 + ## 下一步 -从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-013`:实现登录 provider 边界。 +从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-014`:收敛设置模块 schema。 ## 当前 blocker diff --git a/docs/method-map.md b/docs/method-map.md index ccea04b..14d5e3d 100644 --- a/docs/method-map.md +++ b/docs/method-map.md @@ -24,11 +24,21 @@ | `internal/services/appconfig_service.go` | `GetAppConfig` / `SetAppConfig` | 前端配置读写入口 | | `internal/services/appconfig_service.go` | `GetLanguage` / `SetLanguage` | 语言配置读写入口 | +## 登录 + +| 文件 | 方法 / 内容 | 说明 | +| --- | --- | --- | +| `internal/services/auth_service.go` | `AuthService` | 登录、退出、当前会话和 auth mode | +| `internal/services/auth_service.go` | `LocalAuthProvider` | 校验后端初始化的本地固定账号 | +| `internal/services/auth_service.go` | `RemoteAuthProvider` | 真实后端登录 provider 预留边界 | +| `internal/models/auth.go` | `AuthSession` / `AuthProviderOption` | 前端绑定模型 | +| `frontend/src/modules/auth/LoginPage.vue` | 登录页 | 调用后端 AuthService,不写死账号密码 | + ## 目标模板模块 | 模块 | 目标服务 | 说明 | | --- | --- | --- | -| 登录 | `AuthService` | 本地固定账号 provider 和真实后端 provider | +| 登录 | `AuthService` | 已实现本地固定账号 provider,真实后端 provider 保留边界 | | 审计日志 | `AuditLogService` | 记录登录、设置变更、任务操作、导出等用户动作 | | 运行日志 | `AppLogService` | 记录 AI 调用、数据库、文件、热键等错误 | | 商品图片 AI 优化 | `ImageOptimizationService` | 创建任务、查询状态、查看结果和失败原因 | @@ -51,7 +61,7 @@ | `frontend/src/router/index.ts` | routes | 登录、主应用模块和第二窗口路由 | | `frontend/src/layouts/MainLayout.vue` | 主窗口布局 | 侧边导航、顶部窗口区和 `RouterView` 内容区 | | `frontend/src/components/template/ModulePageShell.vue` | 模块页共享壳 | 模块标题、状态和摘要卡片 | -| `frontend/src/modules/auth/LoginPage.vue` | 登录页骨架 | 后续接入 AuthProvider | +| `frontend/src/modules/auth/LoginPage.vue` | 登录页 | 调用 AuthService 登录并处理错误 | | `frontend/src/modules/dashboard/DashboardPage.vue` | 工作台骨架 | P0 指标和近期任务入口 | | `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` | 主业务骨架 | 商品图片 AI 优化任务入口 | | `frontend/src/modules/logs/LogsPage.vue` | 日志页骨架 | 审计日志和运行日志入口 | diff --git a/docs/routes.md b/docs/routes.md index c964895..5e1c285 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -9,7 +9,7 @@ | 路由 | 组件 | 窗口 | 职责 | | --- | --- | --- | --- | | `#/` | `frontend/src/layouts/MainLayout.vue` | 主窗口 | 重定向到 `#/dashboard` | -| `#/login` | `frontend/src/modules/auth/LoginPage.vue` | 主窗口 | 登录入口骨架,后续接入本地固定账号 provider 和真实后端 provider | +| `#/login` | `frontend/src/modules/auth/LoginPage.vue` | 主窗口 | 登录入口,已接入本地固定账号 provider,真实后端 provider 保留边界 | | `#/dashboard` | `frontend/src/modules/dashboard/DashboardPage.vue` | 主窗口 | 工作台,展示 P0 指标和近期任务入口 | | `#/image-optimizer` | `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` | 主窗口 | 商品图片 AI 优化业务样例骨架 | | `#/logs` | `frontend/src/modules/logs/LogsPage.vue` | 主窗口 | 审计日志和运行日志统一入口骨架 | @@ -25,7 +25,7 @@ | 路由 | 页面 | 职责 | | --- | --- | --- | -| `#/login` | 登录页 | `T-013` 接入本地固定账号登录或真实后端登录 | +| `#/login` | 登录页 | 已接入本地固定账号登录;真实后端登录待后续接入 | | `#/image-optimizer` | 商品图片 AI 优化 | `T-020` / `T-023` 接入任务模型、任务列表、状态、结果和失败原因 | | `#/logs` | 操作日志 | `T-021` 接入审计日志和运行日志查询 | | `#/statistics` | 数据统计 | `T-022` 接入趋势、成功率、耗时和失败任务 | diff --git a/frontend/bindings/cmbone/internal/models/models.js b/frontend/bindings/cmbone/internal/models/models.js index 1aad08d..58bd29a 100644 --- a/frontend/bindings/cmbone/internal/models/models.js +++ b/frontend/bindings/cmbone/internal/models/models.js @@ -6,6 +6,132 @@ // @ts-ignore: Unused imports import {Create as $Create} from "@wailsio/runtime"; +export class AuthProviderOption { + /** + * Creates a new AuthProviderOption instance. + * @param {Partial} [$$source = {}] - The source object to create the AuthProviderOption. + */ + constructor($$source = {}) { + if (!("value" in $$source)) { + /** + * @member + * @type {string} + */ + this["value"] = ""; + } + if (!("label" in $$source)) { + /** + * @member + * @type {string} + */ + this["label"] = ""; + } + if (!("description" in $$source)) { + /** + * @member + * @type {string} + */ + this["description"] = ""; + } + if (!("enabled" in $$source)) { + /** + * @member + * @type {boolean} + */ + this["enabled"] = false; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new AuthProviderOption instance from a string or object. + * @param {any} [$$source = {}] + * @returns {AuthProviderOption} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new AuthProviderOption(/** @type {Partial} */($$parsedSource)); + } +} + +export class AuthSession { + /** + * Creates a new AuthSession instance. + * @param {Partial} [$$source = {}] - The source object to create the AuthSession. + */ + constructor($$source = {}) { + if (!("id" in $$source)) { + /** + * @member + * @type {number} + */ + this["id"] = 0; + } + if (!("userId" in $$source)) { + /** + * @member + * @type {string} + */ + this["userId"] = ""; + } + if (!("username" in $$source)) { + /** + * @member + * @type {string} + */ + this["username"] = ""; + } + if (!("provider" in $$source)) { + /** + * @member + * @type {string} + */ + this["provider"] = ""; + } + if (!("token" in $$source)) { + /** + * @member + * @type {string} + */ + this["token"] = ""; + } + if (!("createdAt" in $$source)) { + /** + * @member + * @type {string} + */ + this["createdAt"] = ""; + } + if (!("expiresAt" in $$source)) { + /** + * @member + * @type {string} + */ + this["expiresAt"] = ""; + } + if (!("active" in $$source)) { + /** + * @member + * @type {boolean} + */ + this["active"] = false; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new AuthSession instance from a string or object. + * @param {any} [$$source = {}] + * @returns {AuthSession} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new AuthSession(/** @type {Partial} */($$parsedSource)); + } +} + export class Hotkey { /** * Creates a new Hotkey instance. diff --git a/frontend/bindings/cmbone/internal/services/authservice.js b/frontend/bindings/cmbone/internal/services/authservice.js new file mode 100644 index 0000000..43e90eb --- /dev/null +++ b/frontend/bindings/cmbone/internal/services/authservice.js @@ -0,0 +1,80 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import {Call as $Call, Create as $Create} from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as models$0 from "../models/models.js"; + +/** + * @returns {Promise & { cancel(): void }} + */ +export function GetAuthMode() { + let $resultPromise = /** @type {any} */($Call.ByID(1527507258)); + return $resultPromise; +} + +/** + * @returns {Promise & { cancel(): void }} + */ +export function GetAuthProviders() { + let $resultPromise = /** @type {any} */($Call.ByID(2710846997)); + let $typingPromise = /** @type {any} */($resultPromise.then(($result) => { + return $$createType1($result); + })); + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +/** + * @returns {Promise & { cancel(): void }} + */ +export function GetCurrentSession() { + let $resultPromise = /** @type {any} */($Call.ByID(2579662840)); + let $typingPromise = /** @type {any} */($resultPromise.then(($result) => { + return $$createType2($result); + })); + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +/** + * @param {string} provider + * @param {string} username + * @param {string} password + * @returns {Promise & { cancel(): void }} + */ +export function Login(provider, username, password) { + let $resultPromise = /** @type {any} */($Call.ByID(4113748438, provider, username, password)); + let $typingPromise = /** @type {any} */($resultPromise.then(($result) => { + return $$createType2($result); + })); + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +/** + * @returns {Promise & { cancel(): void }} + */ +export function Logout() { + let $resultPromise = /** @type {any} */($Call.ByID(3585850903)); + return $resultPromise; +} + +/** + * @param {string} mode + * @returns {Promise & { cancel(): void }} + */ +export function SetAuthMode(mode) { + let $resultPromise = /** @type {any} */($Call.ByID(3911115982, mode)); + return $resultPromise; +} + +// Private type creation functions +const $$createType0 = models$0.AuthProviderOption.createFrom; +const $$createType1 = $Create.Array($$createType0); +const $$createType2 = models$0.AuthSession.createFrom; diff --git a/frontend/bindings/cmbone/internal/services/index.js b/frontend/bindings/cmbone/internal/services/index.js index 7bb1b23..7b01fb3 100644 --- a/frontend/bindings/cmbone/internal/services/index.js +++ b/frontend/bindings/cmbone/internal/services/index.js @@ -4,12 +4,14 @@ import * as AppConfigService from "./appconfigservice.js"; import * as AppService from "./appservice.js"; +import * as AuthService from "./authservice.js"; import * as HotkeyService from "./hotkeyservice.js"; import * as OCRService from "./ocrservice.js"; import * as SystemInfo from "./systeminfo.js"; export { AppConfigService, AppService, + AuthService, HotkeyService, OCRService, SystemInfo diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 505561a..306df54 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -17,6 +17,7 @@ import { } from '@ant-design/icons-vue' import { Window } from '@wailsio/runtime' import { OpenSecondWindow } from '../../bindings/cmbone/internal/services/appservice' +import { GetCurrentSession } from '../../bindings/cmbone/internal/services/authservice' import { getOS, OS_READY } from '../utils/osinfo' type NavItem = { @@ -30,6 +31,8 @@ const route = useRoute() const isWindows = ref(false) const isMacOS = ref(false) const isMaximized = ref(false) +const sessionUsername = ref('未登录') +const sessionProvider = ref('Auth Provider') const navItems: NavItem[] = [ { path: '/dashboard', label: '工作台', detail: '指标与近期任务', icon: DashboardOutlined }, @@ -59,6 +62,11 @@ onMounted(async () => { if (isWindows.value) { isMaximized.value = await Window.IsMaximised() } + const session = await GetCurrentSession() + if (session.active) { + sessionUsername.value = session.username + sessionProvider.value = session.provider + } }) @@ -102,10 +110,10 @@ onMounted(async () => {

- 本地演示用户 + {{ sessionUsername }}

- Local Provider + {{ sessionProvider }}

diff --git a/frontend/src/modules/auth/LoginPage.vue b/frontend/src/modules/auth/LoginPage.vue index fa18a4b..4dd13b5 100644 --- a/frontend/src/modules/auth/LoginPage.vue +++ b/frontend/src/modules/auth/LoginPage.vue @@ -1,15 +1,40 @@ @@ -34,23 +59,31 @@ function submitLogin() { - diff --git a/internal/models/auth.go b/internal/models/auth.go new file mode 100644 index 0000000..96c3d7f --- /dev/null +++ b/internal/models/auth.go @@ -0,0 +1,29 @@ +package models + +type AuthProviderOption struct { + Value string `json:"value"` + Label string `json:"label"` + Description string `json:"description"` + Enabled bool `json:"enabled"` +} + +type AuthSession struct { + ID int64 `json:"id"` + UserID string `json:"userId"` + Username string `json:"username"` + Provider string `json:"provider"` + Token string `json:"token"` + CreatedAt string `json:"createdAt"` + ExpiresAt string `json:"expiresAt"` + Active bool `json:"active"` +} + +type AuditLog struct { + ID int64 `json:"id"` + Actor string `json:"actor"` + Action string `json:"action"` + TargetType string `json:"targetType"` + TargetID string `json:"targetId"` + Detail string `json:"detail"` + CreatedAt string `json:"createdAt"` +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go new file mode 100644 index 0000000..da19439 --- /dev/null +++ b/internal/services/auth_service.go @@ -0,0 +1,284 @@ +package services + +import ( + "cmbone/internal/models" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" +) + +const ( + authProviderLocal = "local" + authProviderRemote = "remote" + + auditActionLoginSuccess = "auth.login.success" + auditActionLoginFailure = "auth.login.failure" + auditActionLogout = "auth.logout" +) + +var ( + errInvalidCredentials = errors.New("用户名或密码错误") + errRemoteProviderNotReady = errors.New("remote auth provider is not configured") + errUnsupportedAuthProvider = errors.New("unsupported auth provider") + errMissingUsernameOrPassword = errors.New("用户名和密码不能为空") +) + +type AuthService struct { + store *SuiStore + providers map[string]AuthProvider +} + +type AuthProvider interface { + Mode() string + Authenticate(username string, password string) (authPrincipal, error) +} + +type authPrincipal struct { + UserID string + Username string + Provider string +} + +func NewAuthService(store *SuiStore) *AuthService { + service := &AuthService{store: store} + service.providers = map[string]AuthProvider{ + authProviderLocal: &LocalAuthProvider{store: store}, + authProviderRemote: &RemoteAuthProvider{}, + } + return service +} + +func (s *AuthService) GetAuthProviders() []models.AuthProviderOption { + return []models.AuthProviderOption{ + { + Value: authProviderLocal, + Label: "Local", + Description: "本地固定账号 provider,用于模板演示和离线模式", + Enabled: true, + }, + { + Value: authProviderRemote, + Label: "Remote", + Description: "真实后端 provider 预留入口", + Enabled: false, + }, + } +} + +func (s *AuthService) GetAuthMode() string { + mode, err := NewAppConfigService(s.store).GetAppConfig("auth.provider") + if err != nil || mode == "" { + return authProviderLocal + } + return mode +} + +func (s *AuthService) SetAuthMode(mode string) error { + if _, ok := s.providers[mode]; !ok { + return fmt.Errorf("%w: %s", errUnsupportedAuthProvider, mode) + } + return NewAppConfigService(s.store).SetAppConfig("auth.provider", mode) +} + +func (s *AuthService) Login(provider string, username string, password string) (models.AuthSession, error) { + provider = strings.TrimSpace(provider) + username = strings.TrimSpace(username) + if provider == "" { + provider = s.GetAuthMode() + } + if username == "" || password == "" { + _ = s.recordAudit(username, auditActionLoginFailure, "auth_session", "", "missing username or password") + return models.AuthSession{}, errMissingUsernameOrPassword + } + + authProvider, ok := s.providers[provider] + if !ok { + _ = s.recordAudit(username, auditActionLoginFailure, "auth_session", provider, "unsupported provider") + return models.AuthSession{}, fmt.Errorf("%w: %s", errUnsupportedAuthProvider, provider) + } + + principal, err := authProvider.Authenticate(username, password) + if err != nil { + _ = s.recordAudit(username, auditActionLoginFailure, "auth_session", provider, err.Error()) + return models.AuthSession{}, err + } + + session, err := s.createSession(principal) + if err != nil { + return models.AuthSession{}, err + } + _ = s.SetAuthMode(provider) + _ = s.recordAudit(principal.Username, auditActionLoginSuccess, "auth_session", fmt.Sprint(session.ID), "login succeeded") + return session, nil +} + +func (s *AuthService) Logout() error { + session, err := s.GetCurrentSession() + if err != nil { + return err + } + if !session.Active { + return nil + } + _, err = s.store.DB.Exec("UPDATE auth_sessions SET active = 0 WHERE id = ?", session.ID) + if err != nil { + return err + } + return s.recordAudit(session.Username, auditActionLogout, "auth_session", fmt.Sprint(session.ID), "logout succeeded") +} + +func (s *AuthService) GetCurrentSession() (models.AuthSession, error) { + now := time.Now().UTC().Format(time.RFC3339) + row := s.store.DB.QueryRow(` + SELECT id, user_id, username, provider, token, created_at, expires_at, active + FROM auth_sessions + WHERE active = 1 AND expires_at > ? + ORDER BY id DESC + LIMIT 1 + `, now) + return scanAuthSession(row) +} + +func (s *AuthService) createSession(principal authPrincipal) (models.AuthSession, error) { + token, err := newSessionToken() + if err != nil { + return models.AuthSession{}, err + } + now := time.Now().UTC() + expiresAt := now.Add(24 * time.Hour) + + tx, err := s.store.DB.Begin() + if err != nil { + return models.AuthSession{}, err + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + if _, err = tx.Exec("UPDATE auth_sessions SET active = 0 WHERE active = 1"); err != nil { + return models.AuthSession{}, err + } + + result, err := tx.Exec(` + INSERT INTO auth_sessions (user_id, username, provider, token, created_at, expires_at, active) + VALUES (?, ?, ?, ?, ?, ?, 1) + `, principal.UserID, principal.Username, principal.Provider, token, now.Format(time.RFC3339), expiresAt.Format(time.RFC3339)) + if err != nil { + return models.AuthSession{}, err + } + + id, err := result.LastInsertId() + if err != nil { + return models.AuthSession{}, err + } + if err = tx.Commit(); err != nil { + return models.AuthSession{}, err + } + + return models.AuthSession{ + ID: id, + UserID: principal.UserID, + Username: principal.Username, + Provider: principal.Provider, + Token: token, + CreatedAt: now.Format(time.RFC3339), + ExpiresAt: expiresAt.Format(time.RFC3339), + Active: true, + }, nil +} + +func (s *AuthService) recordAudit(actor string, action string, targetType string, targetID string, detail string) error { + if strings.TrimSpace(actor) == "" { + actor = "anonymous" + } + _, err := s.store.DB.Exec(` + INSERT INTO audit_logs (actor, action, target_type, target_id, detail, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `, actor, action, targetType, targetID, detail, time.Now().UTC().Format(time.RFC3339)) + return err +} + +func scanAuthSession(row *sql.Row) (models.AuthSession, error) { + var session models.AuthSession + var active int + err := row.Scan( + &session.ID, + &session.UserID, + &session.Username, + &session.Provider, + &session.Token, + &session.CreatedAt, + &session.ExpiresAt, + &active, + ) + if errors.Is(err, sql.ErrNoRows) { + return models.AuthSession{}, nil + } + if err != nil { + return models.AuthSession{}, err + } + session.Active = active == 1 + return session, nil +} + +func newSessionToken() (string, error) { + token := make([]byte, 32) + if _, err := rand.Read(token); err != nil { + return "", err + } + return hex.EncodeToString(token), nil +} + +type LocalAuthProvider struct { + store *SuiStore +} + +func (p *LocalAuthProvider) Mode() string { + return authProviderLocal +} + +func (p *LocalAuthProvider) Authenticate(username string, password string) (authPrincipal, error) { + config := NewAppConfigService(p.store) + expectedUsername, err := config.GetAppConfig("auth.local.username") + if err != nil { + return authPrincipal{}, err + } + salt, err := config.GetAppConfig("auth.local.password_salt") + if err != nil { + return authPrincipal{}, err + } + expectedHash, err := config.GetAppConfig("auth.local.password_hash") + if err != nil { + return authPrincipal{}, err + } + if username != expectedUsername || passwordHash(salt, password) != expectedHash { + return authPrincipal{}, errInvalidCredentials + } + return authPrincipal{ + UserID: "local:" + username, + Username: username, + Provider: p.Mode(), + }, nil +} + +type RemoteAuthProvider struct{} + +func (p *RemoteAuthProvider) Mode() string { + return authProviderRemote +} + +func (p *RemoteAuthProvider) Authenticate(username string, password string) (authPrincipal, error) { + return authPrincipal{}, errRemoteProviderNotReady +} + +func passwordHash(salt string, password string) string { + sum := sha256.Sum256([]byte(salt + ":" + password)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/services/store.go b/internal/services/store.go index 6ba8afe..f6dbe28 100644 --- a/internal/services/store.go +++ b/internal/services/store.go @@ -81,6 +81,27 @@ func Migrate(db *sql.DB) error { description TEXT, state INTEGER DEFAULT 0 ); + + CREATE TABLE IF NOT EXISTS auth_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + username TEXT NOT NULL, + provider TEXT NOT NULL, + token TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + detail TEXT NOT NULL, + created_at TEXT NOT NULL + ); `) if err != nil { return err diff --git a/internal/services/store_test.go b/internal/services/store_test.go index 9034219..646d087 100644 --- a/internal/services/store_test.go +++ b/internal/services/store_test.go @@ -2,6 +2,7 @@ package services import ( "database/sql" + "errors" "testing" ) @@ -62,3 +63,96 @@ func TestAppConfigServiceLanguage(t *testing.T) { t.Fatalf("updated language = %q, want en", got) } } + +func TestAuthServiceLocalLoginLifecycle(t *testing.T) { + store := newTestStore(t) + service := NewAuthService(store) + + session, err := service.Login("local", "admin", "admin123") + if err != nil { + t.Fatalf("login: %v", err) + } + if !session.Active { + t.Fatal("session is not active") + } + if session.Username != "admin" { + t.Fatalf("username = %q, want admin", session.Username) + } + if session.Provider != "local" { + t.Fatalf("provider = %q, want local", session.Provider) + } + if session.Token == "" { + t.Fatal("session token is empty") + } + + current, err := service.GetCurrentSession() + if err != nil { + t.Fatalf("get current session: %v", err) + } + if current.ID != session.ID { + t.Fatalf("current session id = %d, want %d", current.ID, session.ID) + } + + var loginAuditCount int + if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.login.success' AND actor = 'admin'").Scan(&loginAuditCount); err != nil { + t.Fatalf("count login audit logs: %v", err) + } + if loginAuditCount != 1 { + t.Fatalf("login audit count = %d, want 1", loginAuditCount) + } + + if err := service.Logout(); err != nil { + t.Fatalf("logout: %v", err) + } + current, err = service.GetCurrentSession() + if err != nil { + t.Fatalf("get current session after logout: %v", err) + } + if current.Active { + t.Fatal("current session is active after logout") + } + + var logoutAuditCount int + if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.logout' AND actor = 'admin'").Scan(&logoutAuditCount); err != nil { + t.Fatalf("count logout audit logs: %v", err) + } + if logoutAuditCount != 1 { + t.Fatalf("logout audit count = %d, want 1", logoutAuditCount) + } +} + +func TestAuthServiceLocalLoginFailureWritesAuditLog(t *testing.T) { + store := newTestStore(t) + service := NewAuthService(store) + + _, err := service.Login("local", "admin", "bad-password") + if !errors.Is(err, errInvalidCredentials) { + t.Fatalf("login error = %v, want invalid credentials", err) + } + + var auditCount int + if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.login.failure' AND actor = 'admin'").Scan(&auditCount); err != nil { + t.Fatalf("count audit logs: %v", err) + } + if auditCount != 1 { + t.Fatalf("audit count = %d, want 1", auditCount) + } +} + +func TestAuthServiceRemoteProviderReserved(t *testing.T) { + store := newTestStore(t) + service := NewAuthService(store) + + _, err := service.Login("remote", "admin", "admin123") + if !errors.Is(err, errRemoteProviderNotReady) { + t.Fatalf("login error = %v, want remote provider not ready", err) + } + + var auditCount int + if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.login.failure' AND target_id = 'remote'").Scan(&auditCount); err != nil { + t.Fatalf("count audit logs: %v", err) + } + if auditCount != 1 { + t.Fatalf("audit count = %d, want 1", auditCount) + } +} diff --git a/internal/services/systeminfo.go b/internal/services/systeminfo.go index 0c4459c..f6d2cc6 100644 --- a/internal/services/systeminfo.go +++ b/internal/services/systeminfo.go @@ -40,5 +40,17 @@ func defaultHotkeysSQL() string { func defaultAppConfigSQL() string { return ` INSERT OR IGNORE INTO appconfig (key, type, value, description) - VALUES ('language', 'system', 'zh', '应用语言,支持 zh/en');` + VALUES ('language', 'system', 'zh', '应用语言,支持 zh/en'); + + INSERT OR IGNORE INTO appconfig (key, type, value, description) + VALUES ('auth.provider', 'system', 'local', '登录 provider,支持 local/remote'); + + INSERT OR IGNORE INTO appconfig (key, type, value, description) + VALUES ('auth.local.username', 'system', 'admin', '本地演示账号用户名'); + + INSERT OR IGNORE INTO appconfig (key, type, value, description) + VALUES ('auth.local.password_salt', 'system', 'cmbone-local-auth-v1', '本地演示账号密码盐'); + + INSERT OR IGNORE INTO appconfig (key, type, value, description) + VALUES ('auth.local.password_hash', 'system', '3e0bfcda604b19fef1f596bcade7469ff1ab8b427fdc998e9a8216101b002f4a', '本地演示账号密码哈希');` } diff --git a/main.go b/main.go index 0b958cd..4a71aeb 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,7 @@ func main() { application.NewService(services.NewHotkeyService(store)), application.NewService(services.NewSystemInfo()), application.NewService(services.NewAppConfigService(store)), + application.NewService(services.NewAuthService(store)), application.NewService(&services.OCRService{}), }, Assets: application.AssetOptions{ diff --git a/progress.md b/progress.md index 8f1cf86..e0e3324 100644 --- a/progress.md +++ b/progress.md @@ -173,3 +173,29 @@ - 并行运行 `npm run build:dev` 和 `go test/go build` 会因为 `frontend/dist` 被重写导致 Go embed 读取旧 hash 文件失败;正确做法是前端构建完成后再运行 Go 命令。 - `dev.bat` 日志仍有 `SetProcessDpiAwarenessContext failed ... Access is denied`,不影响窗口创建和页面加载。 - Browserslist 数据过期、Vite chunk 体积警告和 `wails3 build` alias 警告仍存在。 + +### 实现登录 provider 边界 + +- 任务:完成 `T-013`,支持本地固定账号 provider,预留 remote provider,账号密码不写死在前端,登录事件写审计日志。 +- 结果: + - 新增 `internal/services/auth_service.go`,提供 `AuthService`、`LocalAuthProvider` 和 `RemoteAuthProvider`。 + - 新增 `internal/models/auth.go`,提供 `AuthSession` 和 `AuthProviderOption` 前端绑定模型。 + - SQLite 迁移新增 `auth_sessions` 和 `audit_logs`,并初始化本地演示账号配置。 + - `main.go` 注册 `AuthService`,并重新生成 Wails `.js` bindings。 + - 登录页通过 `AuthService.Login` 登录,Provider 列表来自后端,前端不保存固定账号密码。 + - 主布局读取当前 session,并在侧边栏底部展示登录状态。 +- 验证: + - `go test ./...` 通过,覆盖本地登录成功、登录失败审计、退出和 remote provider 预留错误。 + - `cd frontend && npm run typecheck` 通过。 + - `cd frontend && npm run build` 通过。 + - `cd frontend && npm run build:dev` 通过。 + - `go build -o bin\cmbone.exe .` 通过。 + - `cmd /c build.bat` 通过,并重新生成 Wails `.js` bindings。 + - `dev.bat` 烟测通过,主窗口可见,日志显示加载 `authservice.js` 并调用 `AuthService.GetCurrentSession`。 +- 默认本地演示账号: + - 用户名:`admin` + - 密码:`admin123` + - 密码以后端配置的 salt + SHA-256 hash 保存,不写入前端页面。 +- 非阻塞警告: + - `dev.bat` 日志仍有 `SetProcessDpiAwarenessContext failed ... Access is denied`,不影响窗口创建和页面加载。 + - Browserslist 数据过期、Vite chunk 体积警告和 `wails3 build` alias 警告仍存在。