feat: add auth provider boundary
This commit is contained in:
@@ -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`。
|
||||
|
||||
+15
-10
@@ -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 后续实现。
|
||||
|
||||
## 关键风险
|
||||
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
+11
-8
@@ -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
|
||||
|
||||
目标方法:
|
||||
|
||||
+16
-4
@@ -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
|
||||
|
||||
|
||||
+12
-2
@@ -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` | 日志页骨架 | 审计日志和运行日志入口 |
|
||||
|
||||
+2
-2
@@ -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` 接入趋势、成功率、耗时和失败任务 |
|
||||
|
||||
@@ -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<AuthProviderOption>} [$$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<AuthProviderOption>} */($$parsedSource));
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthSession {
|
||||
/**
|
||||
* Creates a new AuthSession instance.
|
||||
* @param {Partial<AuthSession>} [$$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<AuthSession>} */($$parsedSource));
|
||||
}
|
||||
}
|
||||
|
||||
export class Hotkey {
|
||||
/**
|
||||
* Creates a new Hotkey instance.
|
||||
|
||||
@@ -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<string> & { cancel(): void }}
|
||||
*/
|
||||
export function GetAuthMode() {
|
||||
let $resultPromise = /** @type {any} */($Call.ByID(1527507258));
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<models$0.AuthProviderOption[]> & { 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<models$0.AuthSession> & { 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<models$0.AuthSession> & { 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<void> & { cancel(): void }}
|
||||
*/
|
||||
export function Logout() {
|
||||
let $resultPromise = /** @type {any} */($Call.ByID(3585850903));
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} mode
|
||||
* @returns {Promise<void> & { 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -102,10 +110,10 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="mb-0 truncate text-sm font-medium text-slate-900 dark:text-white">
|
||||
本地演示用户
|
||||
{{ sessionUsername }}
|
||||
</p>
|
||||
<p class="mb-0 truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
Local Provider
|
||||
{{ sessionProvider }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { DashboardOutlined } from '@ant-design/icons-vue'
|
||||
import { GetAuthMode, GetAuthProviders, Login } from '../../../bindings/cmbone/internal/services/authservice'
|
||||
|
||||
type ProviderOption = {
|
||||
value: string
|
||||
label: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const provider = ref('local')
|
||||
const providers = ref<ProviderOption[]>([])
|
||||
const errorMessage = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
function submitLogin() {
|
||||
router.push('/dashboard')
|
||||
onMounted(async () => {
|
||||
providers.value = await GetAuthProviders()
|
||||
provider.value = await GetAuthMode()
|
||||
})
|
||||
|
||||
async function submitLogin() {
|
||||
errorMessage.value = ''
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await Login(provider.value, username.value, password.value)
|
||||
await router.push('/dashboard')
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -34,23 +59,31 @@ function submitLogin() {
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm font-medium">Provider</span>
|
||||
<select v-model="provider" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none focus:border-slate-500 dark:border-slate-700 dark:bg-slate-950">
|
||||
<option value="local">Local</option>
|
||||
<option value="remote">Remote</option>
|
||||
<option v-for="item in providers" :key="item.value" :value="item.value" :disabled="!item.enabled">
|
||||
{{ item.label }}{{ item.enabled ? '' : '(未配置)' }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="mt-1 block text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ providers.find((item) => item.value === provider)?.description || '登录 provider' }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm font-medium">账号</span>
|
||||
<input v-model="username" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none focus:border-slate-500 dark:border-slate-700 dark:bg-slate-950" autocomplete="username" />
|
||||
<input v-model="username" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none focus:border-slate-500 dark:border-slate-700 dark:bg-slate-950" autocomplete="username" placeholder="请输入账号" />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm font-medium">密码</span>
|
||||
<input v-model="password" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none focus:border-slate-500 dark:border-slate-700 dark:bg-slate-950" type="password" autocomplete="current-password" />
|
||||
<input v-model="password" class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none focus:border-slate-500 dark:border-slate-700 dark:bg-slate-950" type="password" autocomplete="current-password" placeholder="请输入密码" />
|
||||
</label>
|
||||
|
||||
<button class="h-10 w-full rounded-md bg-slate-950 text-sm font-medium text-white hover:bg-slate-800 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200" type="submit">
|
||||
进入应用
|
||||
<p v-if="errorMessage" class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700 dark:border-rose-900/50 dark:bg-rose-950/40 dark:text-rose-200">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<button class="h-10 w-full rounded-md bg-slate-950 text-sm font-medium text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200" type="submit" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? '登录中' : '进入应用' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', '本地演示账号密码哈希');`
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
+26
@@ -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 警告仍存在。
|
||||
|
||||
Reference in New Issue
Block a user