feat: add database-backed console login
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/buildinfo"
|
"git.yuntuo.hk/k2safe/orbit/internal/buildinfo"
|
||||||
@@ -26,13 +27,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
func run() error {
|
func run() error {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
return errors.New("usage: orbit-admin release manifest|bootstrap|verify|observe snapshot|retention inventory|credential status|rotate|revoke|apply")
|
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|credential status|rotate|revoke|apply")
|
||||||
}
|
}
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "release":
|
case "release":
|
||||||
return runRelease(os.Args[2:])
|
return runRelease(os.Args[2:])
|
||||||
case "bootstrap":
|
case "bootstrap":
|
||||||
return runBootstrap(os.Args[2:])
|
return runBootstrap(os.Args[2:])
|
||||||
|
case "console-user":
|
||||||
|
return runConsoleUser(os.Args[2:])
|
||||||
case "verify":
|
case "verify":
|
||||||
return runVerify(os.Args[2:])
|
return runVerify(os.Args[2:])
|
||||||
case "observe":
|
case "observe":
|
||||||
@@ -42,7 +45,7 @@ func run() error {
|
|||||||
case "credential":
|
case "credential":
|
||||||
return runCredential(os.Args[2:])
|
return runCredential(os.Args[2:])
|
||||||
}
|
}
|
||||||
return errors.New("usage: orbit-admin release manifest|bootstrap|verify|observe snapshot|retention inventory|credential status|rotate|revoke|apply")
|
return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|credential status|rotate|revoke|apply")
|
||||||
}
|
}
|
||||||
|
|
||||||
// releaseManifest 是发布物自身的静态身份清单,不读取数据库、不包含 DSN、凭据或业务数据。
|
// releaseManifest 是发布物自身的静态身份清单,不读取数据库、不包含 DSN、凭据或业务数据。
|
||||||
@@ -324,3 +327,78 @@ func runBootstrap(args []string) error {
|
|||||||
fmt.Println("Bootstrap committed. Credentials saved in the requested private file; valid for 30 days.")
|
fmt.Println("Bootstrap committed. Credentials saved in the requested private file; valid for 30 days.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runConsoleUser 创建一个控制台登录账号。密码默认从标准输入读取,避免出现在
|
||||||
|
// shell 历史和进程列表;账号只绑定已有业务凭据,不会生成或输出业务 token。
|
||||||
|
func runConsoleUser(args []string) error {
|
||||||
|
if len(args) == 0 || args[0] != "create" {
|
||||||
|
return errors.New("usage: orbit-admin console-user create --credential-file path --username name [--display-name name] [--password-stdin]")
|
||||||
|
}
|
||||||
|
flags := flag.NewFlagSet("console-user create", flag.ContinueOnError)
|
||||||
|
var namespace, credential, credentialFile, username, displayName, password string
|
||||||
|
var passwordStdin bool
|
||||||
|
flags.StringVar(&namespace, "namespace-id", "", "namespace id; can be read from --credential-file")
|
||||||
|
flags.StringVar(&credential, "credential-id", "", "credential id; can be read from --credential-file")
|
||||||
|
flags.StringVar(&credentialFile, "credential-file", "", "private bootstrap credential JSON file")
|
||||||
|
flags.StringVar(&username, "username", "", "console login username")
|
||||||
|
flags.StringVar(&displayName, "display-name", "", "console display name")
|
||||||
|
flags.StringVar(&password, "password", "", "password; prefer --password-stdin")
|
||||||
|
flags.BoolVar(&passwordStdin, "password-stdin", false, "read password from stdin")
|
||||||
|
if err := flags.Parse(args[1:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 || username == "" {
|
||||||
|
return errors.New("--username and principal information are required")
|
||||||
|
}
|
||||||
|
if credentialFile != "" {
|
||||||
|
raw, err := os.ReadFile(credentialFile)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("credential file could not be read")
|
||||||
|
}
|
||||||
|
var file struct {
|
||||||
|
NamespaceID core.ID `json:"namespace_id"`
|
||||||
|
CredentialID core.ID `json:"credential_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &file); err != nil {
|
||||||
|
return errors.New("credential file is invalid")
|
||||||
|
}
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = string(file.NamespaceID)
|
||||||
|
}
|
||||||
|
if credential == "" {
|
||||||
|
credential = string(file.CredentialID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if passwordStdin {
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(os.Stdin, 2049))
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("password could not be read")
|
||||||
|
}
|
||||||
|
password = strings.TrimRight(string(raw), "\r\n")
|
||||||
|
}
|
||||||
|
ns, cred := core.ID(namespace), core.ID(credential)
|
||||||
|
if ns.Validate() != nil || cred.Validate() != nil || password == "" {
|
||||||
|
return errors.New("valid namespace, credential and password are required")
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("database unavailable")
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
user, err := mysql.NewConsoleAuthStore(db).CreateConsoleUser(ctx, mysql.ConsoleUserInput{NamespaceID: ns, CredentialID: cred, Username: username, DisplayName: displayName, Password: password})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(encoded))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ func run() error {
|
|||||||
txStats := mysql.ReadTransactionStats()
|
txStats := mysql.ReadTransactionStats()
|
||||||
metrics.RecordTransactionStats(observability.TransactionStats{Retries: txStats.Retries, Exhausted: txStats.Exhausted})
|
metrics.RecordTransactionStats(observability.TransactionStats{Retries: txStats.Retries, Exhausted: txStats.Exhausted})
|
||||||
}
|
}
|
||||||
server := httpserver.New(cfg.HTTPAddr, readiness.Check, service.New(mysql.NewTaskStore(db)))
|
consoleAuth := mysql.NewConsoleAuthStore(db)
|
||||||
|
server := httpserver.NewWithConsoleAuth(cfg.HTTPAddr, readiness.Check, consoleAuth, service.New(mysql.NewTaskStore(db)))
|
||||||
httpserver.RegisterApplicationResources(server, resources.New(mysql.NewTaskStore(db)))
|
httpserver.RegisterApplicationResources(server, resources.New(mysql.NewTaskStore(db)))
|
||||||
node, err := core.NewID()
|
node, err := core.NewID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,6 +121,7 @@ func run() error {
|
|||||||
httpserver.RegisterSchedules(server, schedule.New(schedules))
|
httpserver.RegisterSchedules(server, schedule.New(schedules))
|
||||||
httpserver.RegisterWorkflowDefinitions(server, definition.New(mysql.NewWorkflowDefinitionStore(db)))
|
httpserver.RegisterWorkflowDefinitions(server, definition.New(mysql.NewWorkflowDefinitionStore(db)))
|
||||||
httpserver.RegisterWorkflows(server, instance.New(mysql.NewWorkflowInstanceStore(db)), control.New(mysql.NewWorkflowControlStore(db)), weavesignal.New(mysql.NewWorkflowSignalStore(db)), recovery.New(mysql.NewWorkflowRecoveryStore(db)))
|
httpserver.RegisterWorkflows(server, instance.New(mysql.NewWorkflowInstanceStore(db)), control.New(mysql.NewWorkflowControlStore(db)), weavesignal.New(mysql.NewWorkflowSignalStore(db)), recovery.New(mysql.NewWorkflowRecoveryStore(db)))
|
||||||
|
httpserver.WrapConsoleAuth(server, consoleAuth)
|
||||||
httpserver.WrapMetrics(server, metrics)
|
httpserver.WrapMetrics(server, metrics)
|
||||||
workflows, err := mysql.NewWorkflowRuntime(db, node)
|
workflows, err := mysql.NewWorkflowRuntime(db, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ http://127.0.0.1:8080/console/
|
|||||||
## 当前能力
|
## 当前能力
|
||||||
|
|
||||||
- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。
|
- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。
|
||||||
- 通过用户选择的本地凭据 JSON 或手工 Token/队列范围调用现有授权 API;队列范围优先从凭据 JSON 或服务端默认配置自动填充。
|
- 通过数据库中的控制台账号登录;服务端使用 HttpOnly 会话 Cookie 调用现有授权 API,浏览器不接触业务 Token。
|
||||||
- 总览页直接展示当前查询窗口的任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;连接参数收进“连接设置”抽屉,不占用统计区域。
|
- 总览页直接展示当前查询窗口的任务成功率、任务状态分布、活跃计划、待核查流程和待交付结果;登录和权限信息不会占用统计区域。
|
||||||
- 展示任务、定时计划、流程实例和结果订阅的最近摘要。
|
- 展示任务、定时计划、流程实例和结果订阅的最近摘要。
|
||||||
- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
|
- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。
|
||||||
- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
|
- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。
|
||||||
@@ -21,29 +21,39 @@ http://127.0.0.1:8080/console/
|
|||||||
- `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。
|
- `NEEDS_REVIEW` 流程会显示按步骤类型划分的人工恢复入口:任务核查、步骤结论、继续、步骤重试、PENDING 接续和等待重建;每次提交都要求当前修订、证据引用和显式确认,服务端恢复 API 负责最终门控。
|
||||||
- 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。
|
- 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。
|
||||||
- 左侧五个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows` 和 `#/subscriptions` 定位;切换视图不会把所有模块堆在一张长页面上。
|
- 左侧五个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows` 和 `#/subscriptions` 定位;切换视图不会把所有模块堆在一张长页面上。
|
||||||
- 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。
|
- 任务状态按 10 秒自动刷新;页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。
|
||||||
- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
|
- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
|
||||||
|
|
||||||
## 本地打开
|
## 本地打开
|
||||||
|
|
||||||
启动 Orbit 后直接访问 `/console/`。当前 Docker 开发环境可用:
|
启动 Orbit 后访问 `/console/`。未登录时,任何控制台业务页面都会跳转到 `/console/login`;登录成功后进入控制台,登录会话过期或服务端返回 401 时也会重新跳转。当前 Docker 开发环境入口:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://127.0.0.1:8080/console/
|
http://127.0.0.1:8080/console/
|
||||||
```
|
```
|
||||||
|
|
||||||
启动 Orbit 后,首页先展示运行统计;需要切换环境或加载凭据时,点击顶部齿轮、首页“连接设置”或统计旁的设置入口。API 地址默认使用当前控制台页面所在的地址,不需要手工填写。点击“选择凭据 JSON”,选择 `orbit-admin bootstrap` 生成的私有凭据文件,例如 `.local/local.credentials.json`,队列范围和 Token 会自动读取,再点击“连接”。浏览器不能在没有用户选择的情况下读取本机文件,这是浏览器的安全边界;文件只在当前页面内解析,Token 不会上传到 Orbit Console,也不写入 Orbit 数据库。
|
首次使用需要由数据库管理员创建一个控制台账号。账号绑定一个已有的命名空间凭据,账号权限就是该凭据的权限;这不会轮换或输出业务 Token。推荐从私有凭据文件读取命名空间和凭据标识,并从标准输入交付密码:
|
||||||
|
|
||||||
如果一个部署只服务一个默认队列,可以在服务端配置队列范围,页面打开后会自动使用它:
|
```sh
|
||||||
|
./bin/orbit-admin console-user create \
|
||||||
|
--credential-file .local/local.credentials.json \
|
||||||
|
--username operator \
|
||||||
|
--display-name 'Orbit 管理员' \
|
||||||
|
--password-stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
命令会把密码写成 bcrypt 哈希,只输出账号的脱敏信息。生产环境应通过受保护的密钥管道提供标准输入;不要把密码写进命令历史、镜像或仓库。账号创建后直接打开控制台,用账号密码登录。
|
||||||
|
|
||||||
|
`ORBIT_CONSOLE_QUEUE_ID` 只作为单一默认队列的展示和查询范围配置,页面不再要求填写 API 地址、队列 ID、Bearer Token,也不再选择凭据 JSON:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export ORBIT_CONSOLE_QUEUE_ID='queue_xxx'
|
export ORBIT_CONSOLE_QUEUE_ID='queue_xxx'
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /console/config` 只返回这个非敏感的默认队列 ID,不返回 Token、不读取数据库。凭据 JSON 中的 `queue_id` 优先级更高,可以覆盖默认值。没有队列配置时仍可查看健康和版本;读取任务、计划、流程及订阅需要同时提供 Bearer Token 和队列范围。平台不会根据 Token 猜测队列,因为一个 Token 可能被授予多个队列,自动猜测会造成数据范围错误。
|
`GET /console/config` 只对已登录会话返回这个非敏感的默认队列 ID。健康检查和版本探针保持公开,业务 API、控制台 HTML、配置和所有业务模块均受登录会话与原有权限双重保护。
|
||||||
|
|
||||||
## 安全边界
|
## 安全边界
|
||||||
|
|
||||||
控制台不直接访问数据库,也不绕过 Bearer Token。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台不创建独立管理员身份,不支持跨命名空间聚合,不代替平台的权限与审计 API。
|
控制台不直接访问数据库,也不绕过业务凭据权限。登录会话只保存令牌摘要,服务端每次请求都会校验账号、会话、命名空间、应用启停和凭据权限。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台账号绑定业务凭据,不自动获得跨命名空间权限;不支持跨命名空间聚合,不代替平台的权限与审计 API。
|
||||||
|
|
||||||
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。
|
不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。计划版本发布、跨命名空间管理身份和其他高风险写操作仍应通过受保护 API/SDK 设计后再接入页面。流程恢复入口只调用已经定义的恢复 API,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -7,9 +7,10 @@ require (
|
|||||||
github.com/go-sql-driver/mysql v1.10.1
|
github.com/go-sql-driver/mysql v1.10.1
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -10,5 +10,7 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
|||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
|||||||
31
internal/consoleauth/auth.go
Normal file
31
internal/consoleauth/auth.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Package consoleauth defines the small boundary between the HTTP console and
|
||||||
|
// the database-backed account/session implementation.
|
||||||
|
package consoleauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User is the safe, browser-visible part of a console account.
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResult contains the short-lived internal bearer and its browser session metadata.
|
||||||
|
type LoginResult struct {
|
||||||
|
User User
|
||||||
|
Token string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is implemented by the production database adapter.
|
||||||
|
type Store interface {
|
||||||
|
Login(ctx context.Context, username, password, userAgent, remoteAddr string) (LoginResult, error)
|
||||||
|
ValidateSession(ctx context.Context, token auth.Token) error
|
||||||
|
RevokeSession(ctx context.Context, token auth.Token) error
|
||||||
|
}
|
||||||
@@ -814,3 +814,41 @@ CREATE TABLE `weave_waits` (
|
|||||||
CONSTRAINT `ck_weave_waits_1` CHECK ((`state` in (_utf8mb4'WAITING',_utf8mb4'SATISFIED',_utf8mb4'EXPIRED',_utf8mb4'CANCELLED'))),
|
CONSTRAINT `ck_weave_waits_1` CHECK ((`state` in (_utf8mb4'WAITING',_utf8mb4'SATISFIED',_utf8mb4'EXPIRED',_utf8mb4'CANCELLED'))),
|
||||||
CONSTRAINT `ck_weave_waits_2` CHECK ((`kind` in (_utf8mb4'SIGNAL',_utf8mb4'TIMER',_utf8mb4'POLL')))
|
CONSTRAINT `ck_weave_waits_2` CHECK ((`kind` in (_utf8mb4'SIGNAL',_utf8mb4'TIMER',_utf8mb4'POLL')))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='可恢复的信号或时间等待,不长期占用业务 Worker';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='可恢复的信号或时间等待,不长期占用业务 Worker';
|
||||||
|
|
||||||
|
|
||||||
|
-- Orbit 控制台账号与会话;账号只用于人登录,业务 SDK 仍使用命名空间凭据。
|
||||||
|
CREATE TABLE orbit_console_users (
|
||||||
|
id BINARY(16) NOT NULL COMMENT '控制台账号标识,全局唯一',
|
||||||
|
namespace_id BINARY(16) NOT NULL COMMENT '登录后可访问的命名空间',
|
||||||
|
credential_id BINARY(16) NOT NULL COMMENT '登录后代表该账号调用 API 的业务凭据',
|
||||||
|
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始',
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC',
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后更新时间,统一 UTC',
|
||||||
|
username VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '登录名,大小写敏感',
|
||||||
|
display_name VARCHAR(128) NOT NULL COMMENT '展示名称',
|
||||||
|
password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'bcrypt 密码哈希,不保存明文',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否允许登录',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_console_username (username),
|
||||||
|
KEY ix_console_user_principal (namespace_id, credential_id),
|
||||||
|
CONSTRAINT ck_console_users_0 CHECK (revision > 0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='控制台人类账号,不替代业务 SDK 凭据';
|
||||||
|
|
||||||
|
CREATE TABLE orbit_console_sessions (
|
||||||
|
id BINARY(16) NOT NULL COMMENT '会话记录标识',
|
||||||
|
user_id BINARY(16) NOT NULL COMMENT '控制台账号标识',
|
||||||
|
namespace_id BINARY(16) NOT NULL COMMENT '会话对应命名空间',
|
||||||
|
credential_id BINARY(16) NOT NULL COMMENT '会话对应业务凭据',
|
||||||
|
token_digest BINARY(32) NOT NULL COMMENT '会话令牌摘要,不保存令牌原文',
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC',
|
||||||
|
expires_at DATETIME(6) NOT NULL COMMENT '会话过期时间,统一 UTC',
|
||||||
|
last_seen_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最近一次使用时间,统一 UTC',
|
||||||
|
revoked_at DATETIME(6) NULL COMMENT '撤销时间;为空表示未撤销',
|
||||||
|
user_agent VARCHAR(512) NOT NULL DEFAULT '' COMMENT '登录时的客户端摘要',
|
||||||
|
remote_addr VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录时的来源地址',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_console_session_token (token_digest),
|
||||||
|
KEY ix_console_session_user (user_id, revoked_at, expires_at),
|
||||||
|
KEY ix_console_session_expiry (expires_at, revoked_at),
|
||||||
|
CONSTRAINT ck_console_sessions_0 CHECK (expires_at > created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='控制台 HttpOnly 登录会话,只保存令牌摘要';
|
||||||
|
|||||||
217
internal/storage/mysql/console_auth.go
Normal file
217
internal/storage/mysql/console_auth.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ConsoleSessionLifetime 是控制台登录会话的最长空闲前有效期。
|
||||||
|
ConsoleSessionLifetime = 12 * time.Hour
|
||||||
|
// 登录不是业务热路径,使用高于 bcrypt 默认值的成本换取更高的离线破解成本。
|
||||||
|
consolePasswordCost = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConsoleAuthStore 管理控制台的人类账号和 HttpOnly 会话。
|
||||||
|
// 账号绑定一个已有的 Orbit 业务凭据,登录后沿用该凭据的权限范围;
|
||||||
|
// 因此控制台登录不会改变 SDK 令牌,也不会绕过原有的 API 授权。
|
||||||
|
type ConsoleAuthStore struct{ db *sql.DB }
|
||||||
|
|
||||||
|
// NewConsoleAuthStore 创建控制台认证存储。
|
||||||
|
func NewConsoleAuthStore(db *sql.DB) *ConsoleAuthStore { return &ConsoleAuthStore{db: db} }
|
||||||
|
|
||||||
|
var _ consoleauth.Store = (*ConsoleAuthStore)(nil)
|
||||||
|
|
||||||
|
// ConsoleUser 和 ConsoleLogin 由 HTTP/数据库共享,避免认证实现与路由互相依赖。
|
||||||
|
type ConsoleUser = consoleauth.User
|
||||||
|
type ConsoleLogin = consoleauth.LoginResult
|
||||||
|
|
||||||
|
// ConsoleUserInput 描述创建控制台账号所需的管理输入。
|
||||||
|
// NamespaceID 和 CredentialID 必须指向同一命名空间下已存在的业务凭据。
|
||||||
|
type ConsoleUserInput struct {
|
||||||
|
NamespaceID core.ID
|
||||||
|
CredentialID core.ID
|
||||||
|
Username string
|
||||||
|
DisplayName string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateConsoleUser 创建一个控制台账号,只保存 bcrypt 哈希,不保存密码原文。
|
||||||
|
func (s *ConsoleAuthStore) CreateConsoleUser(ctx context.Context, in ConsoleUserInput) (ConsoleUser, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return ConsoleUser{}, errors.New("console auth database unavailable")
|
||||||
|
}
|
||||||
|
if err := validateConsoleUserInput(in); err != nil {
|
||||||
|
return ConsoleUser{}, err
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), consolePasswordCost)
|
||||||
|
if err != nil {
|
||||||
|
return ConsoleUser{}, err
|
||||||
|
}
|
||||||
|
id, err := core.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return ConsoleUser{}, err
|
||||||
|
}
|
||||||
|
operationID, err := core.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return ConsoleUser{}, err
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(in.DisplayName)
|
||||||
|
if name == "" {
|
||||||
|
name = in.Username
|
||||||
|
}
|
||||||
|
if err := CheckSchema(ctx, s.db); err != nil {
|
||||||
|
return ConsoleUser{}, err
|
||||||
|
}
|
||||||
|
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
result, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_users
|
||||||
|
(id,namespace_id,credential_id,username,display_name,password_hash)
|
||||||
|
SELECT ?,?,?,?,?,? FROM orbit_credentials c
|
||||||
|
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
||||||
|
JOIN orbit_applications a ON a.id=n.application_id
|
||||||
|
WHERE c.namespace_id=? AND c.id=? AND c.revoked_at IS NULL
|
||||||
|
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
|
||||||
|
AND n.enabled=TRUE AND a.enabled=TRUE`,
|
||||||
|
id, in.NamespaceID, in.CredentialID, in.Username, name, string(hash), in.NamespaceID, in.CredentialID)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||||
|
return fault.Conflict
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err != nil {
|
||||||
|
return err
|
||||||
|
} else if affected != 1 {
|
||||||
|
return fault.NotFound
|
||||||
|
}
|
||||||
|
actor, err := databaseActor(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return appendAuditActor(ctx, tx, in.NamespaceID, actor, operationID, "console_user.create", id, "create console user", nil, map[string]any{
|
||||||
|
"username": in.Username, "display_name": name, "credential_id": in.CredentialID,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ConsoleUser{}, err
|
||||||
|
}
|
||||||
|
return ConsoleUser{ID: string(id), Username: in.Username, DisplayName: name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 校验账号密码并建立一个独立会话。失败时统一返回未认证,避免泄露账号存在性。
|
||||||
|
func (s *ConsoleAuthStore) Login(ctx context.Context, username, password, userAgent, remoteAddr string) (ConsoleLogin, error) {
|
||||||
|
if s == nil || s.db == nil || username == "" || password == "" || len(password) > 1024 {
|
||||||
|
return ConsoleLogin{}, fault.Unauthorized
|
||||||
|
}
|
||||||
|
var user ConsoleUser
|
||||||
|
var userID core.ID
|
||||||
|
var namespace, credential core.ID
|
||||||
|
var hash string
|
||||||
|
var enabled bool
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.display_name,u.password_hash,u.enabled,u.namespace_id,u.credential_id
|
||||||
|
FROM orbit_console_users u
|
||||||
|
JOIN orbit_credentials c ON c.namespace_id=u.namespace_id AND c.id=u.credential_id
|
||||||
|
JOIN orbit_namespaces n ON n.id=u.namespace_id
|
||||||
|
JOIN orbit_applications a ON a.id=n.application_id
|
||||||
|
WHERE u.username=? AND c.revoked_at IS NULL
|
||||||
|
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
|
||||||
|
AND n.enabled=TRUE AND a.enabled=TRUE`, username).
|
||||||
|
Scan(&userID, &user.Username, &user.DisplayName, &hash, &enabled, &namespace, &credential)
|
||||||
|
if err != nil || !enabled || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||||
|
return ConsoleLogin{}, fault.Unauthorized
|
||||||
|
}
|
||||||
|
user.ID = string(userID)
|
||||||
|
token, secretDigest, err := auth.Issue(namespace, credential)
|
||||||
|
if err != nil {
|
||||||
|
return ConsoleLogin{}, err
|
||||||
|
}
|
||||||
|
var now time.Time
|
||||||
|
if err := s.db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now); err != nil {
|
||||||
|
return ConsoleLogin{}, err
|
||||||
|
}
|
||||||
|
now = now.UTC()
|
||||||
|
expires := now.Add(ConsoleSessionLifetime)
|
||||||
|
sessionID, err := core.NewID()
|
||||||
|
if err != nil {
|
||||||
|
return ConsoleLogin{}, err
|
||||||
|
}
|
||||||
|
if len(userAgent) > 512 {
|
||||||
|
userAgent = userAgent[:512]
|
||||||
|
}
|
||||||
|
if len(remoteAddr) > 64 {
|
||||||
|
remoteAddr = remoteAddr[:64]
|
||||||
|
}
|
||||||
|
_, err = s.db.ExecContext(ctx, `INSERT INTO orbit_console_sessions
|
||||||
|
(id,user_id,namespace_id,credential_id,token_digest,expires_at,last_seen_at,user_agent,remote_addr)
|
||||||
|
VALUES (?,?,?,?,?,?,UTC_TIMESTAMP(6),?,?)`,
|
||||||
|
sessionID, userID, namespace, credential, secretDigest, expires, userAgent, remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return ConsoleLogin{}, err
|
||||||
|
}
|
||||||
|
return ConsoleLogin{User: user, Token: token, ExpiresAt: expires}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSession 检查会话是否仍然有效,并更新最近使用时间。
|
||||||
|
// API 授权还会再次读取该会话对应凭据的权限,不信任浏览器提交的身份字段。
|
||||||
|
func (s *ConsoleAuthStore) ValidateSession(ctx context.Context, token auth.Token) error {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return fault.Unauthorized
|
||||||
|
}
|
||||||
|
var one int
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT 1
|
||||||
|
FROM orbit_console_sessions s
|
||||||
|
JOIN orbit_console_users u ON u.id=s.user_id
|
||||||
|
JOIN orbit_credentials c ON c.namespace_id=s.namespace_id AND c.id=s.credential_id
|
||||||
|
JOIN orbit_namespaces n ON n.id=s.namespace_id
|
||||||
|
JOIN orbit_applications a ON a.id=n.application_id
|
||||||
|
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=?
|
||||||
|
AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6)
|
||||||
|
AND u.enabled=TRUE AND c.revoked_at IS NULL
|
||||||
|
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
|
||||||
|
AND n.enabled=TRUE AND a.enabled=TRUE`, token.NamespaceID, token.CredentialID, token.Digest).Scan(&one)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fault.Unauthorized
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.ExecContext(ctx, `UPDATE orbit_console_sessions SET last_seen_at=UTC_TIMESTAMP(6)
|
||||||
|
WHERE namespace_id=? AND credential_id=? AND token_digest=? AND revoked_at IS NULL`, token.NamespaceID, token.CredentialID, token.Digest)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeSession 使当前浏览器会话立即失效;找不到会话时也按成功处理,便于幂等退出。
|
||||||
|
func (s *ConsoleAuthStore) RevokeSession(ctx context.Context, token auth.Token) error {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE orbit_console_sessions SET revoked_at=UTC_TIMESTAMP(6)
|
||||||
|
WHERE namespace_id=? AND credential_id=? AND token_digest=? AND revoked_at IS NULL`, token.NamespaceID, token.CredentialID, token.Digest)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateConsoleUserInput(in ConsoleUserInput) error {
|
||||||
|
if in.NamespaceID.Validate() != nil || in.CredentialID.Validate() != nil {
|
||||||
|
return fault.Invalid("namespace and credential are required")
|
||||||
|
}
|
||||||
|
if in.Username == "" || len(in.Username) > 128 || !utf8.ValidString(in.Username) || strings.TrimSpace(in.Username) != in.Username {
|
||||||
|
return fault.Invalid("username must be 1-128 valid UTF-8 characters")
|
||||||
|
}
|
||||||
|
if in.DisplayName != "" && (len(in.DisplayName) > 128 || !utf8.ValidString(in.DisplayName)) {
|
||||||
|
return fault.Invalid("display name must be at most 128 UTF-8 bytes")
|
||||||
|
}
|
||||||
|
if len(in.Password) < 12 || len(in.Password) > 1024 {
|
||||||
|
return fault.Invalid("password must be 12-1024 bytes")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ var migrationFiles embed.FS
|
|||||||
//go:embed baseline.sql
|
//go:embed baseline.sql
|
||||||
var freshInstallBaseline string
|
var freshInstallBaseline string
|
||||||
|
|
||||||
const freshInstallBaselineVersion uint64 = 28
|
const freshInstallBaselineVersion uint64 = 29
|
||||||
|
|
||||||
type Migration struct {
|
type Migration struct {
|
||||||
Version uint64
|
Version uint64
|
||||||
|
|||||||
36
internal/storage/mysql/migrations/000029_console_auth.up.sql
Normal file
36
internal/storage/mysql/migrations/000029_console_auth.up.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- Orbit 控制台账号与会话;账号只用于人登录,业务 SDK 仍使用命名空间凭据。
|
||||||
|
CREATE TABLE orbit_console_users (
|
||||||
|
id BINARY(16) NOT NULL COMMENT '控制台账号标识,全局唯一',
|
||||||
|
namespace_id BINARY(16) NOT NULL COMMENT '登录后可访问的命名空间',
|
||||||
|
credential_id BINARY(16) NOT NULL COMMENT '登录后代表该账号调用 API 的业务凭据',
|
||||||
|
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始',
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC',
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后更新时间,统一 UTC',
|
||||||
|
username VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '登录名,大小写敏感',
|
||||||
|
display_name VARCHAR(128) NOT NULL COMMENT '展示名称',
|
||||||
|
password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'bcrypt 密码哈希,不保存明文',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否允许登录',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_console_username (username),
|
||||||
|
KEY ix_console_user_principal (namespace_id, credential_id),
|
||||||
|
CONSTRAINT ck_console_users_0 CHECK (revision > 0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='控制台人类账号,不替代业务 SDK 凭据';
|
||||||
|
|
||||||
|
CREATE TABLE orbit_console_sessions (
|
||||||
|
id BINARY(16) NOT NULL COMMENT '会话记录标识',
|
||||||
|
user_id BINARY(16) NOT NULL COMMENT '控制台账号标识',
|
||||||
|
namespace_id BINARY(16) NOT NULL COMMENT '会话对应命名空间',
|
||||||
|
credential_id BINARY(16) NOT NULL COMMENT '会话对应业务凭据',
|
||||||
|
token_digest BINARY(32) NOT NULL COMMENT '会话令牌摘要,不保存令牌原文',
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC',
|
||||||
|
expires_at DATETIME(6) NOT NULL COMMENT '会话过期时间,统一 UTC',
|
||||||
|
last_seen_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最近一次使用时间,统一 UTC',
|
||||||
|
revoked_at DATETIME(6) NULL COMMENT '撤销时间;为空表示未撤销',
|
||||||
|
user_agent VARCHAR(512) NOT NULL DEFAULT '' COMMENT '登录时的客户端摘要',
|
||||||
|
remote_addr VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录时的来源地址',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_console_session_token (token_digest),
|
||||||
|
KEY ix_console_session_user (user_id, revoked_at, expires_at),
|
||||||
|
KEY ix_console_session_expiry (expires_at, revoked_at),
|
||||||
|
CONSTRAINT ck_console_sessions_0 CHECK (expires_at > created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='控制台 HttpOnly 登录会话,只保存令牌摘要';
|
||||||
@@ -58,7 +58,27 @@ func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permi
|
|||||||
return auth.Permissions{}, err
|
return auth.Permissions{}, err
|
||||||
}
|
}
|
||||||
if subtle.ConstantTimeCompare(digest[:], token.Digest[:]) != 1 {
|
if subtle.ConstantTimeCompare(digest[:], token.Digest[:]) != 1 {
|
||||||
return auth.Permissions{}, fault.Unauthorized
|
// 控制台会话使用独立的短期令牌摘要,不轮换也不覆盖业务 SDK
|
||||||
|
// 凭据。只有经过会话表、账号、命名空间和应用启停状态联合校验,
|
||||||
|
// 才允许沿用该业务凭据的权限快照。
|
||||||
|
var sessionPermissions []byte
|
||||||
|
sessionErr := tx.QueryRowContext(ctx, `SELECT c.permissions
|
||||||
|
FROM orbit_console_sessions s
|
||||||
|
JOIN orbit_console_users u ON u.id=s.user_id AND u.enabled=TRUE
|
||||||
|
JOIN orbit_credentials c ON c.namespace_id=s.namespace_id AND c.id=s.credential_id
|
||||||
|
JOIN orbit_namespaces n ON n.id=s.namespace_id AND n.enabled=TRUE
|
||||||
|
JOIN orbit_applications a ON a.id=n.application_id AND a.enabled=TRUE
|
||||||
|
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=?
|
||||||
|
AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6)
|
||||||
|
AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
|
||||||
|
FOR SHARE`, token.NamespaceID, token.CredentialID, token.Digest).Scan(&sessionPermissions)
|
||||||
|
if errors.Is(sessionErr, sql.ErrNoRows) {
|
||||||
|
return auth.Permissions{}, fault.Unauthorized
|
||||||
|
}
|
||||||
|
if sessionErr != nil {
|
||||||
|
return auth.Permissions{}, sessionErr
|
||||||
|
}
|
||||||
|
raw = sessionPermissions
|
||||||
}
|
}
|
||||||
var p auth.Permissions
|
var p auth.Permissions
|
||||||
if json.Unmarshal(raw, &p) != nil || p.Validate() != nil {
|
if json.Unmarshal(raw, &p) != nil || p.Validate() != nil {
|
||||||
|
|||||||
@@ -1,25 +1,39 @@
|
|||||||
package httpserver
|
package httpserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const consoleSessionCookie = "orbit_console_session"
|
||||||
|
|
||||||
// consoleAssets 将控制台静态资源编译进 Orbit,避免运行容器依赖 Node.js、外部 CDN
|
// consoleAssets 将控制台静态资源编译进 Orbit,避免运行容器依赖 Node.js、外部 CDN
|
||||||
// 或额外的文件挂载。控制台只调用版本化 HTTP API,不读取数据库。
|
// 或额外的文件挂载。业务页面需要登录;登录页和静态资源保持公开。
|
||||||
//
|
//
|
||||||
//go:embed console
|
//go:embed console
|
||||||
var consoleAssets embed.FS
|
var consoleAssets embed.FS
|
||||||
|
|
||||||
// RegisterConsole 注册本地及生产可复用的 Orbit 控制台入口。
|
// RegisterConsole 注册本地及生产可复用的 Orbit 控制台入口。
|
||||||
// /console 不改变业务 API 的认证和路由;静态页面自身不包含凭据。
|
// 页面本身不直接读取数据库,登录后的 API 请求仍由已有服务层做权限校验。
|
||||||
func RegisterConsole(server *http.Server) {
|
func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
|
||||||
if server == nil {
|
if server == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var consoleAuth consoleauth.Store
|
||||||
|
if len(authStores) > 0 {
|
||||||
|
consoleAuth = authStores[0]
|
||||||
|
}
|
||||||
mux, ok := server.Handler.(*http.ServeMux)
|
mux, ok := server.Handler.(*http.ServeMux)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -32,19 +46,147 @@ func RegisterConsole(server *http.Server) {
|
|||||||
mux.HandleFunc("GET /console", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /console", func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, "/console/", http.StatusPermanentRedirect)
|
http.Redirect(w, r, "/console/", http.StatusPermanentRedirect)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("GET /console/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serveConsoleAsset(w, r, assets, "login.html")
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /console/auth/session", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if consoleAuth == nil {
|
||||||
|
writeConsoleJSON(w, http.StatusOK, map[string]any{"authenticated": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, ok := consoleCookieToken(r)
|
||||||
|
if !ok || consoleAuth.ValidateSession(r.Context(), token) != nil {
|
||||||
|
writeConsoleJSON(w, http.StatusOK, map[string]any{"authenticated": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeConsoleJSON(w, http.StatusOK, map[string]any{"authenticated": true})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /console/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if consoleAuth == nil {
|
||||||
|
writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "console authentication is not configured"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := decodeBody(r, &in); err != nil {
|
||||||
|
writeConsoleJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "INVALID_ARGUMENT", "message": "invalid login request"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
login, err := consoleAuth.Login(ctx, in.Username, in.Password, r.UserAgent(), remoteAddress(r))
|
||||||
|
if err != nil {
|
||||||
|
// 账号不存在、密码错误、账号停用和绑定凭据失效统一返回,避免账号枚举。
|
||||||
|
writeConsoleJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{"code": "UNAUTHENTICATED", "message": "用户名或密码错误"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: consoleSessionCookie, Value: login.Token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Expires: login.ExpiresAt, MaxAge: int(time.Until(login.ExpiresAt).Seconds())})
|
||||||
|
writeConsoleJSON(w, http.StatusOK, map[string]any{"user": login.User, "expires_at": login.ExpiresAt})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /console/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if consoleAuth != nil {
|
||||||
|
if token, ok := consoleCookieToken(r); ok {
|
||||||
|
_ = consoleAuth.RevokeSession(r.Context(), token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: consoleSessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)})
|
||||||
|
writeConsoleJSON(w, http.StatusOK, map[string]any{"logged_out": true})
|
||||||
|
})
|
||||||
mux.HandleFunc("GET /console/config", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /console/config", func(w http.ResponseWriter, r *http.Request) {
|
||||||
// 队列 ID 只是控制台的默认范围,不是凭据。Token 仍由每个授权 API
|
if consoleAuth != nil {
|
||||||
// 自己校验;这里不返回任何秘密,也不访问数据库。
|
if token, ok := consoleCookieToken(r); !ok || consoleAuth.ValidateSession(r.Context(), token) != nil {
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
consoleLoginRedirect(w, r)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
}
|
||||||
_ = json.NewEncoder(w).Encode(struct {
|
}
|
||||||
DefaultQueueID string `json:"default_queue_id"`
|
// 队列 ID 只是已登录控制台的默认范围,不是凭据,也不返回秘密。
|
||||||
}{DefaultQueueID: os.Getenv("ORBIT_CONSOLE_QUEUE_ID")})
|
writeConsoleJSON(w, http.StatusOK, map[string]string{"default_queue_id": consoleQueueID()})
|
||||||
})
|
})
|
||||||
mux.Handle("GET /console/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
mux.Handle("GET /console/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/console/" && consoleAuth != nil {
|
||||||
|
if token, ok := consoleCookieToken(r); !ok || consoleAuth.ValidateSession(r.Context(), token) != nil {
|
||||||
|
consoleLoginRedirect(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
files.ServeHTTP(w, r)
|
files.ServeHTTP(w, r)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConsoleSessionMiddleware 将 HttpOnly 会话转换为内部 Bearer 请求头。
|
||||||
|
// 浏览器脚本永远看不到业务令牌;现有所有 v1 handler 仍走同一个鉴权入口。
|
||||||
|
func ConsoleSessionMiddleware(next http.Handler, consoleAuth consoleauth.Store) http.Handler {
|
||||||
|
if next == nil || consoleAuth == nil {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if len(r.Header.Values("Authorization")) == 0 {
|
||||||
|
if token, ok := consoleCookieToken(r); ok && consoleAuth.ValidateSession(r.Context(), token) == nil {
|
||||||
|
clone := r.Clone(r.Context())
|
||||||
|
clone.Header = r.Header.Clone()
|
||||||
|
if cookie, err := r.Cookie(consoleSessionCookie); err == nil {
|
||||||
|
clone.Header.Set("Authorization", "Bearer "+cookie.Value)
|
||||||
|
}
|
||||||
|
r = clone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func consoleCookieToken(r *http.Request) (auth.Token, bool) {
|
||||||
|
cookie, err := r.Cookie(consoleSessionCookie)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return auth.Token{}, false
|
||||||
|
}
|
||||||
|
token, err := auth.Parse(cookie.Value)
|
||||||
|
return token, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func consoleLoginRedirect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
returnTo := r.URL.Path
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
returnTo += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
if returnTo == "/console/config" || !strings.HasPrefix(returnTo, "/console") || strings.HasPrefix(returnTo, "//") {
|
||||||
|
returnTo = "/console/"
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/console/login?return="+url.QueryEscape(returnTo), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveConsoleAsset(w http.ResponseWriter, r *http.Request, assets fs.FS, name string) {
|
||||||
|
file, err := fs.ReadFile(assets, name)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConsoleJSON(w http.ResponseWriter, status int, value any) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteAddress(r *http.Request) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
value := r.RemoteAddr
|
||||||
|
if host, _, err := net.SplitHostPort(value); err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func consoleQueueID() string { return strings.TrimSpace(os.Getenv("ORBIT_CONSOLE_QUEUE_ID")) }
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const state = { token: '', queue: '', base: location.origin, consoleConfig: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
|
const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
|
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
|
||||||
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅' };
|
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅' };
|
||||||
const sessionQueue = sessionStorage.getItem('orbit.console.queue') || '';
|
|
||||||
$('api-url').value = location.origin;
|
|
||||||
$('queue-id').value = sessionQueue;
|
|
||||||
|
|
||||||
function showToast(message) {
|
function showToast(message) {
|
||||||
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
||||||
@@ -32,42 +29,49 @@
|
|||||||
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
|
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
function redirectToLogin() {
|
||||||
|
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||||
|
location.replace(`/console/login?return=${encodeURIComponent(returnTo.startsWith('/console') ? returnTo : '/console/')}`);
|
||||||
|
}
|
||||||
|
async function ensureSession() {
|
||||||
|
const response = await fetch('/console/auth/session', { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' });
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!body.authenticated) { redirectToLogin(); return false; }
|
||||||
|
state.authenticated = true; return true;
|
||||||
|
}
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const headers = { Accept: 'application/json', ...(options.headers || {}) };
|
const headers = { Accept: 'application/json', ...(options.headers || {}) };
|
||||||
if (state.token) headers.Authorization = `Bearer ${state.token}`;
|
const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||||
const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers });
|
|
||||||
const raw = await response.text(); let body = {};
|
const raw = await response.text(); let body = {};
|
||||||
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
|
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
|
||||||
|
if (response.status === 401) { state.authenticated = false; redirectToLogin(); throw new Error('登录已过期,请重新登录'); }
|
||||||
if (!response.ok) throw new Error(body?.error?.message || body?.message || `HTTP ${response.status}`);
|
if (!response.ok) throw new Error(body?.error?.message || body?.message || `HTTP ${response.status}`);
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
async function loadConsoleConfig() {
|
async function loadConsoleConfig() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/console/config', { headers: { Accept: 'application/json' }, cache: 'no-store' });
|
const response = await fetch('/console/config', { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' });
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
state.consoleConfig = await response.json();
|
state.consoleConfig = await response.json();
|
||||||
const defaultQueue = String(state.consoleConfig?.default_queue_id || '').trim();
|
const defaultQueue = String(state.consoleConfig?.default_queue_id || '').trim();
|
||||||
if (!state.queue && defaultQueue) {
|
if (!state.queue && defaultQueue) {
|
||||||
state.queue = defaultQueue;
|
state.queue = defaultQueue;
|
||||||
$('queue-id').value = defaultQueue;
|
|
||||||
sessionStorage.setItem('orbit.console.queue', defaultQueue);
|
|
||||||
text('queue-hint', '已使用服务端默认队列配置;也可选择凭据 JSON 覆盖。');
|
|
||||||
renderConnectionSummary();
|
renderConnectionSummary();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 连接表单仍可使用手工 API 地址和凭据,不因可选配置失败而阻断页面。
|
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function renderConnectionSummary() {
|
function renderConnectionSummary() {
|
||||||
text('summary-api', state.base || location.origin);
|
text('summary-api', state.base || location.origin);
|
||||||
text('summary-api-hint', state.connected ? '接口响应正常,按 10 秒周期刷新' : 'API 地址自动跟随当前页面');
|
text('summary-api-hint', state.connected ? '接口响应正常,按 10 秒周期刷新' : 'API 地址自动跟随当前页面');
|
||||||
text('summary-queue', state.queue ? shortID(state.queue) : '未配置');
|
text('summary-queue', state.queue ? shortID(state.queue) : '未配置');
|
||||||
text('summary-credential', state.token ? '已载入' : '未载入');
|
text('summary-credential', state.authenticated ? '账号会话' : '未登录');
|
||||||
text('summary-note', state.token && state.queue ? '已具备读取业务数据的连接条件。' : '服务状态会自动检查;需要业务数据时再加载凭据。');
|
text('summary-note', state.authenticated ? '当前页面已通过账号会话访问服务,数据按账号权限显示。' : '登录后加载业务数据。');
|
||||||
}
|
}
|
||||||
function setConnection(connected, detail = '') {
|
function setConnection(connected, detail = '') {
|
||||||
state.connected = connected;
|
state.connected = connected;
|
||||||
const authenticated = connected && Boolean(state.token && state.queue);
|
const authenticated = connected && state.authenticated;
|
||||||
$('connection-state').textContent = authenticated ? '已连接' : (connected ? '服务在线' : '未连接');
|
$('connection-state').textContent = authenticated ? '已连接' : (connected ? '服务在线' : '未连接');
|
||||||
$('connection-state').classList.toggle('ready', connected);
|
$('connection-state').classList.toggle('ready', connected);
|
||||||
$('side-status').textContent = authenticated ? '环境已连接' : (connected ? '服务在线' : '等待连接');
|
$('side-status').textContent = authenticated ? '环境已连接' : (connected ? '服务在线' : '等待连接');
|
||||||
@@ -172,8 +176,6 @@
|
|||||||
return `console-${prefix}-${id}`.slice(0, 128);
|
return `console-${prefix}-${id}`.slice(0, 128);
|
||||||
}
|
}
|
||||||
function terminalState(value) { return ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(value); }
|
function terminalState(value) { return ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(value); }
|
||||||
function openConnectionSettings() { $('connection-settings-backdrop').hidden = false; document.body.classList.add('drawer-open'); $('api-url').focus(); }
|
|
||||||
function closeConnectionSettings() { $('connection-settings-backdrop').hidden = true; document.body.classList.remove('drawer-open'); }
|
|
||||||
function showTaskDrawer() { $('task-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
function showTaskDrawer() { $('task-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); }
|
||||||
function closeTaskDrawer() { $('task-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeActionForm(); }
|
function closeTaskDrawer() { $('task-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeActionForm(); }
|
||||||
function renderDetailList(id, items, emptyText, renderItem) {
|
function renderDetailList(id, items, emptyText, renderItem) {
|
||||||
@@ -204,7 +206,7 @@
|
|||||||
}
|
}
|
||||||
async function openTaskDetail(id) {
|
async function openTaskDetail(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (!state.token) { showToast('请先连接具备 tasks:read 权限的凭据'); return; }
|
if (!state.authenticated) { redirectToLogin(); return; }
|
||||||
showTaskDrawer(); text('detail-title', '正在读取任务…'); text('detail-id', shortID(id)); text('detail-state', '读取中'); $('detail-actions').hidden = true;
|
showTaskDrawer(); text('detail-title', '正在读取任务…'); text('detail-id', shortID(id)); text('detail-state', '读取中'); $('detail-actions').hidden = true;
|
||||||
$('detail-attempts').innerHTML = '<div class="detail-loading">正在读取尝试历史…</div>'; $('detail-operations').innerHTML = '<div class="detail-loading">正在读取操作审计…</div>';
|
$('detail-attempts').innerHTML = '<div class="detail-loading">正在读取尝试历史…</div>'; $('detail-operations').innerHTML = '<div class="detail-loading">正在读取操作审计…</div>';
|
||||||
const encoded = encodeURIComponent(id); const [taskResult, attemptsResult, operationsResult] = await Promise.allSettled([request(`/v1/tasks/${encoded}`), request(`/v1/tasks/${encoded}/attempts?limit=20`), request(`/v1/tasks/${encoded}/operations?limit=20`)]);
|
const encoded = encodeURIComponent(id); const [taskResult, attemptsResult, operationsResult] = await Promise.allSettled([request(`/v1/tasks/${encoded}`), request(`/v1/tasks/${encoded}/attempts?limit=20`), request(`/v1/tasks/${encoded}/operations?limit=20`)]);
|
||||||
@@ -253,7 +255,7 @@
|
|||||||
}
|
}
|
||||||
async function openScheduleDetail(id) {
|
async function openScheduleDetail(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (!state.token) { showToast('请先连接具备 schedules:read 权限的凭据'); return; }
|
if (!state.authenticated) { redirectToLogin(); return; }
|
||||||
showScheduleDrawer(); text('schedule-detail-title', '正在读取计划…'); text('schedule-detail-id', shortID(id)); $('schedule-actions').hidden = true; $('schedule-occurrences').innerHTML = '<div class="detail-loading">正在读取触发记录…</div>';
|
showScheduleDrawer(); text('schedule-detail-title', '正在读取计划…'); text('schedule-detail-id', shortID(id)); $('schedule-actions').hidden = true; $('schedule-occurrences').innerHTML = '<div class="detail-loading">正在读取触发记录…</div>';
|
||||||
const encoded = encodeURIComponent(id); const [scheduleResult, occurrencesResult] = await Promise.allSettled([request(`/v1/schedules/${encoded}`), request(`/v1/schedules/${encoded}/occurrences?limit=20`)]);
|
const encoded = encodeURIComponent(id); const [scheduleResult, occurrencesResult] = await Promise.allSettled([request(`/v1/schedules/${encoded}`), request(`/v1/schedules/${encoded}/occurrences?limit=20`)]);
|
||||||
if (scheduleResult.status !== 'fulfilled') { text('schedule-detail-title', '计划读取失败'); text('schedule-action-note', scheduleResult.reason?.message || '无法读取计划详情'); return; }
|
if (scheduleResult.status !== 'fulfilled') { text('schedule-detail-title', '计划读取失败'); text('schedule-action-note', scheduleResult.reason?.message || '无法读取计划详情'); return; }
|
||||||
@@ -393,7 +395,7 @@
|
|||||||
}
|
}
|
||||||
async function openWorkflowDetail(id) {
|
async function openWorkflowDetail(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (!state.token) { showToast('请先连接具备 workflows:read 权限的凭据'); return; }
|
if (!state.authenticated) { redirectToLogin(); return; }
|
||||||
showWorkflowDrawer(); closeWorkflowAction(); closeWorkflowRecovery(); $('workflow-actions').hidden = true; $('workflow-recovery-block').hidden = true; text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', shortID(id)); text('workflow-detail-state', '读取中'); $('workflow-steps').innerHTML = '<div class="detail-loading">正在读取步骤…</div>'; $('workflow-events').innerHTML = '<div class="detail-loading">正在读取事件…</div>';
|
showWorkflowDrawer(); closeWorkflowAction(); closeWorkflowRecovery(); $('workflow-actions').hidden = true; $('workflow-recovery-block').hidden = true; text('workflow-detail-title', '正在读取流程…'); text('workflow-detail-id', shortID(id)); text('workflow-detail-state', '读取中'); $('workflow-steps').innerHTML = '<div class="detail-loading">正在读取步骤…</div>'; $('workflow-events').innerHTML = '<div class="detail-loading">正在读取事件…</div>';
|
||||||
const encoded = encodeURIComponent(id); const [workflowResult, stepsResult, eventsResult] = await Promise.allSettled([request(`/v1/workflows/${encoded}`), request(`/v1/workflows/${encoded}/steps`), request(`/v1/workflows/${encoded}/events?limit=20`)]);
|
const encoded = encodeURIComponent(id); const [workflowResult, stepsResult, eventsResult] = await Promise.allSettled([request(`/v1/workflows/${encoded}`), request(`/v1/workflows/${encoded}/steps`), request(`/v1/workflows/${encoded}/events?limit=20`)]);
|
||||||
if (workflowResult.status !== 'fulfilled') { text('workflow-detail-title', '流程读取失败'); text('workflow-detail-note', workflowResult.reason?.message || '无法读取流程详情'); renderWorkflowSteps({}, true); renderWorkflowEvents({}, true); return; }
|
if (workflowResult.status !== 'fulfilled') { text('workflow-detail-title', '流程读取失败'); text('workflow-detail-note', workflowResult.reason?.message || '无法读取流程详情'); renderWorkflowSteps({}, true); renderWorkflowEvents({}, true); return; }
|
||||||
@@ -443,7 +445,7 @@
|
|||||||
}
|
}
|
||||||
async function openSubscriptionDetail(id) {
|
async function openSubscriptionDetail(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (!state.token) { showToast('请先连接具备 events:read 权限的凭据'); return; }
|
if (!state.authenticated) { redirectToLogin(); return; }
|
||||||
showSubscriptionDrawer(); closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', shortID(id)); $('subscription-deliveries').innerHTML = '<div class="detail-loading">正在读取交付历史…</div>';
|
showSubscriptionDrawer(); closeSubscriptionAction(); closeSubscriptionRetry(); $('subscription-actions').hidden = true; text('subscription-detail-title', '正在读取订阅…'); text('subscription-detail-id', shortID(id)); $('subscription-deliveries').innerHTML = '<div class="detail-loading">正在读取交付历史…</div>';
|
||||||
const encoded = encodeURIComponent(id); const [subscriptionResult, deliveriesResult] = await Promise.allSettled([request(`/v1/subscriptions/${encoded}`), request(`/v1/subscriptions/${encoded}/deliveries?limit=20`)]);
|
const encoded = encodeURIComponent(id); const [subscriptionResult, deliveriesResult] = await Promise.allSettled([request(`/v1/subscriptions/${encoded}`), request(`/v1/subscriptions/${encoded}/deliveries?limit=20`)]);
|
||||||
if (subscriptionResult.status !== 'fulfilled') { text('subscription-detail-title', '订阅读取失败'); text('subscription-detail-note', subscriptionResult.reason?.message || '无法读取订阅详情'); renderSubscriptionDeliveries({}, true); return; }
|
if (subscriptionResult.status !== 'fulfilled') { text('subscription-detail-title', '订阅读取失败'); text('subscription-detail-note', subscriptionResult.reason?.message || '无法读取订阅详情'); renderSubscriptionDeliveries({}, true); return; }
|
||||||
@@ -453,7 +455,7 @@
|
|||||||
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
|
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
|
||||||
try {
|
try {
|
||||||
await loadHealth();
|
await loadHealth();
|
||||||
if (!state.token || !state.queue) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
|
if (!state.authenticated || !state.queue) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
|
||||||
const tasks = await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`); const taskItems = extractItems(tasks); renderTasks(taskItems);
|
const tasks = await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`); const taskItems = extractItems(tasks); renderTasks(taskItems);
|
||||||
const optional = await Promise.allSettled([
|
const optional = await Promise.allSettled([
|
||||||
request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
|
request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
|
||||||
@@ -470,23 +472,14 @@
|
|||||||
} catch (error) { setConnection(false); showToast(`连接失败:${error.message}`); text('last-updated', '刷新失败'); }
|
} catch (error) { setConnection(false); showToast(`连接失败:${error.message}`); text('last-updated', '刷新失败'); }
|
||||||
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
|
finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
|
||||||
}
|
}
|
||||||
function connect() {
|
async function logout() { await fetch('/console/auth/logout', { method: 'POST', credentials: 'same-origin' }); redirectToLogin(); }
|
||||||
state.base = $('api-url').value.trim().replace(/\/$/, '') || location.origin; state.token = $('token').value.trim(); state.queue = $('queue-id').value.trim() || String(state.consoleConfig?.default_queue_id || '').trim();
|
$('logout-hero').addEventListener('click', logout); $('logout-button').addEventListener('click', logout); $('logout-inline').addEventListener('click', logout); $('refresh-button').addEventListener('click', refresh);
|
||||||
$('queue-id').value = state.queue;
|
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
|
||||||
sessionStorage.setItem('orbit.console.queue', state.queue);
|
|
||||||
if (!state.token) showToast('服务状态可以直接查看;选择凭据 JSON 后可读取业务数据');
|
|
||||||
else if (!state.queue) showToast('队列会从凭据 JSON 自动读取,也可配置 ORBIT_CONSOLE_QUEUE_ID');
|
|
||||||
closeConnectionSettings();
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
$('connect-button').addEventListener('click', connect); $('connect-hero').addEventListener('click', openConnectionSettings); $('connection-settings-button').addEventListener('click', openConnectionSettings); $('connection-settings-inline').addEventListener('click', openConnectionSettings); $('close-connection-settings').addEventListener('click', closeConnectionSettings); $('connection-settings-backdrop').addEventListener('click', event => { if (event.target.id === 'connection-settings-backdrop') closeConnectionSettings(); }); $('refresh-button').addEventListener('click', refresh);
|
|
||||||
$('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('connection-settings-backdrop').hidden) closeConnectionSettings(); else if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
|
|
||||||
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);
|
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);
|
||||||
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('workflow-recovery-form').hidden) closeWorkflowRecovery(); });
|
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('workflow-recovery-form').hidden) closeWorkflowRecovery(); });
|
||||||
$('reveal-token').addEventListener('click', () => { const field = $('token'); field.type = field.type === 'password' ? 'text' : 'password'; $('reveal-token').textContent = field.type === 'password' ? '显示' : '隐藏'; });
|
|
||||||
$('credential-file').addEventListener('change', event => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const credential = JSON.parse(reader.result); const queue = String(credential.queue_id || state.consoleConfig?.default_queue_id || '').trim(); $('token').value = credential.token || ''; $('queue-id').value = queue; state.queue = queue; state.token = String(credential.token || '').trim(); sessionStorage.setItem('orbit.console.queue', queue); text('queue-hint', queue ? '队列已从凭据 JSON 自动读取;无需手工填写。' : '凭据未附带队列,请配置 ORBIT_CONSOLE_QUEUE_ID。'); renderConnectionSummary(); showToast(queue ? '凭据已读取,队列已自动填充' : '凭据已读取,但缺少队列配置'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); });
|
|
||||||
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
|
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
|
||||||
window.addEventListener('hashchange', () => setView(currentView(), false));
|
window.addEventListener('hashchange', () => setView(currentView(), false));
|
||||||
setView(currentView(), false);
|
setView(currentView(), false);
|
||||||
loadConsoleConfig().finally(() => refresh()); state.timer = setInterval(refresh, 10000);
|
ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(() => refresh()); }).catch(() => redirectToLogin());
|
||||||
|
state.timer = setInterval(() => { if (state.authenticated) refresh(); }, 10000);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<span class="live-chip"><i></i><span id="live-label">本地环境</span></span>
|
<span class="live-chip"><i></i><span id="live-label">本地环境</span></span>
|
||||||
<span class="last-updated" id="last-updated">尚未刷新</span>
|
<span class="last-updated" id="last-updated">尚未刷新</span>
|
||||||
<button class="icon-button" id="connection-settings-button" title="连接设置" aria-label="连接设置">⚙</button>
|
<button class="icon-button" id="logout-button" title="退出登录" aria-label="退出登录">↪</button>
|
||||||
<button class="icon-button" id="refresh-button" title="刷新数据" aria-label="刷新数据">↻</button>
|
<button class="icon-button" id="refresh-button" title="刷新数据" aria-label="刷新数据">↻</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<p>任务、定时、流程和交付状态,在一块屏幕上保持有序运转。</p>
|
<p>任务、定时、流程和交付状态,在一块屏幕上保持有序运转。</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<button class="primary-button" data-view="tasks">查看任务 <span>↗</span></button>
|
<button class="primary-button" data-view="tasks">查看任务 <span>↗</span></button>
|
||||||
<button class="quiet-button" id="connect-hero">连接设置</button>
|
<button class="quiet-button" id="logout-hero">退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-visual" aria-hidden="true">
|
<div class="hero-visual" aria-hidden="true">
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
|
|
||||||
<section class="metric-grid" aria-label="运行概览">
|
<section class="metric-grid" aria-label="运行概览">
|
||||||
<article class="metric-card metric-highlight"><div class="metric-top"><span>服务状态</span><span class="metric-icon green">✦</span></div><strong id="metric-health">—</strong><small id="metric-health-sub">等待就绪检查</small><div class="sparkline green-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
<article class="metric-card metric-highlight"><div class="metric-top"><span>服务状态</span><span class="metric-icon green">✦</span></div><strong id="metric-health">—</strong><small id="metric-health-sub">等待就绪检查</small><div class="sparkline green-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
||||||
<article class="metric-card"><div class="metric-top"><span>任务总览</span><span class="metric-icon purple">◇</span></div><strong id="metric-tasks">—</strong><small id="metric-tasks-sub">连接凭据后加载</small><div class="mini-bar"><i id="task-bar-succeeded"></i><i id="task-bar-running"></i><i id="task-bar-waiting"></i><i id="task-bar-other"></i></div></article>
|
<article class="metric-card"><div class="metric-top"><span>任务总览</span><span class="metric-icon purple">◇</span></div><strong id="metric-tasks">—</strong><small id="metric-tasks-sub">当前会话加载</small><div class="mini-bar"><i id="task-bar-succeeded"></i><i id="task-bar-running"></i><i id="task-bar-waiting"></i><i id="task-bar-other"></i></div></article>
|
||||||
<article class="metric-card"><div class="metric-top"><span>流程实例</span><span class="metric-icon blue">⌘</span></div><strong id="metric-workflows">—</strong><small id="metric-workflows-sub">等待工作流权限</small><div class="sparkline blue-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
<article class="metric-card"><div class="metric-top"><span>流程实例</span><span class="metric-icon blue">⌘</span></div><strong id="metric-workflows">—</strong><small id="metric-workflows-sub">等待工作流权限</small><div class="sparkline blue-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
||||||
<article class="metric-card"><div class="metric-top"><span>结果订阅</span><span class="metric-icon orange">⌁</span></div><strong id="metric-subscriptions">—</strong><small id="metric-subscriptions-sub">等待事件权限</small><div class="subscription-meter"><span id="subscription-meter-fill"></span></div></article>
|
<article class="metric-card"><div class="metric-top"><span>结果订阅</span><span class="metric-icon orange">⌁</span></div><strong id="metric-subscriptions">—</strong><small id="metric-subscriptions-sub">等待事件权限</small><div class="subscription-meter"><span id="subscription-meter-fill"></span></div></article>
|
||||||
</section>
|
</section>
|
||||||
@@ -93,11 +93,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="panel connection-summary-panel">
|
<article class="panel connection-summary-panel">
|
||||||
<div class="panel-heading"><div><span class="panel-kicker">CONTROL PLANE</span><h2>连接状态</h2></div><span class="connection-state" id="connection-state">未连接</span></div>
|
<div class="panel-heading"><div><span class="panel-kicker">CONTROL PLANE</span><h2>登录状态</h2></div><span class="connection-state" id="connection-state">未连接</span></div>
|
||||||
<div class="connection-summary-copy"><span class="connection-summary-icon">↗</span><div><b id="summary-api">当前页面</b><small id="summary-api-hint">API 地址自动跟随当前页面</small></div></div>
|
<div class="connection-summary-copy"><span class="connection-summary-icon">↗</span><div><b id="summary-api">当前页面</b><small id="summary-api-hint">API 地址自动跟随当前页面</small></div></div>
|
||||||
<div class="connection-summary-grid"><div><span>队列范围</span><b id="summary-queue">自动配置</b></div><div><span>凭据状态</span><b id="summary-credential">未载入</b></div></div>
|
<div class="connection-summary-grid"><div><span>队列范围</span><b id="summary-queue">自动配置</b></div><div><span>登录会话</span><b id="summary-credential">未登录</b></div></div>
|
||||||
<p class="connection-summary-note" id="summary-note">服务状态会自动检查;需要业务数据时再加载凭据。</p>
|
<p class="connection-summary-note" id="summary-note">服务状态会自动检查;需要业务数据时再加载凭据。</p>
|
||||||
<button class="quiet-button" id="connection-settings-inline" type="button">管理连接设置 <span>→</span></button>
|
<button class="quiet-button" id="logout-inline" type="button">退出当前账号 <span>→</span></button>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
<section class="content-grid single-column">
|
<section class="content-grid single-column">
|
||||||
<article class="panel wide-panel" id="tasks">
|
<article class="panel wide-panel" id="tasks">
|
||||||
<div class="panel-heading"><div><span class="panel-kicker">PULSE / TASKS</span><h2>任务执行流</h2></div><div class="panel-tools"><span class="table-count" id="task-count">0 项</span><button class="text-button" data-view="overview">返回总览 <span>←</span></button></div></div>
|
<div class="panel-heading"><div><span class="panel-kicker">PULSE / TASKS</span><h2>任务执行流</h2></div><div class="panel-tools"><span class="table-count" id="task-count">0 项</span><button class="text-button" data-view="overview">返回总览 <span>←</span></button></div></div>
|
||||||
<div class="table-wrap"><table><thead><tr><th>任务</th><th>处理器</th><th>状态</th><th>修订</th><th>创建时间</th></tr></thead><tbody id="task-table"><tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>还没有任务数据</b><small>连接一个具备 tasks:read 权限的凭据后自动加载</small></td></tr></tbody></table></div>
|
<div class="table-wrap"><table><thead><tr><th>任务</th><th>处理器</th><th>状态</th><th>修订</th><th>创建时间</th></tr></thead><tbody id="task-table"><tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>还没有任务数据</b><small>登录后按账号权限自动加载</small></td></tr></tbody></table></div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,20 +132,6 @@
|
|||||||
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-backdrop" id="connection-settings-backdrop" hidden>
|
|
||||||
<aside class="task-drawer connection-drawer" role="dialog" aria-modal="true" aria-labelledby="connection-settings-title">
|
|
||||||
<div class="drawer-header">
|
|
||||||
<div><span class="panel-kicker">CONTROL PLANE / SETTINGS</span><h2 id="connection-settings-title">连接设置</h2><small class="drawer-id">凭据只在当前浏览器会话中使用</small></div>
|
|
||||||
<button class="icon-button" id="close-connection-settings" type="button" aria-label="关闭连接设置">×</button>
|
|
||||||
</div>
|
|
||||||
<p class="drawer-note connection-settings-intro">API 地址默认使用当前页面;队列优先读取服务端配置或凭据 JSON。这里不会读取数据库,也不会上传凭据。</p>
|
|
||||||
<label class="field-label" for="api-url">API 地址 <small>自动使用当前页面</small></label><div class="input-wrap"><span class="input-prefix">↗</span><input id="api-url" type="url" value="" placeholder="http://127.0.0.1:8080"></div>
|
|
||||||
<label class="field-label" for="queue-id">队列范围 <small>自动读取</small></label><div class="input-wrap"><span class="input-prefix">#</span><input id="queue-id" type="text" autocomplete="off" placeholder="凭据 JSON 或服务端配置会自动填充"></div><div class="field-hint" id="queue-hint">选择凭据 JSON 后自动填充;也可由服务端配置默认队列。</div>
|
|
||||||
<label class="field-label" for="token">Bearer Token</label><div class="input-wrap"><span class="input-prefix">●</span><input id="token" type="password" placeholder="粘贴开发凭据 token"><button class="reveal-button" id="reveal-token" type="button">显示</button></div>
|
|
||||||
<div class="connect-row"><label class="file-button"><input id="credential-file" type="file" accept="application/json">选择凭据 JSON</label><button class="primary-button compact" id="connect-button" type="button">连接 <span>→</span></button></div>
|
|
||||||
<div class="security-note"><span>▣</span><span>当前页面只调用公开 API 和授权接口,不读取数据库。</span></div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
<div class="drawer-backdrop" id="task-drawer-backdrop" hidden>
|
<div class="drawer-backdrop" id="task-drawer-backdrop" hidden>
|
||||||
<aside class="task-drawer" role="dialog" aria-modal="true" aria-labelledby="detail-title">
|
<aside class="task-drawer" role="dialog" aria-modal="true" aria-labelledby="detail-title">
|
||||||
<div class="drawer-header">
|
<div class="drawer-header">
|
||||||
|
|||||||
37
internal/transport/httpserver/console/login.html
Normal file
37
internal/transport/httpserver/console/login.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>登录 Orbit · 星轨控制台</title>
|
||||||
|
<link rel="stylesheet" href="/console/styles.css">
|
||||||
|
<style>
|
||||||
|
html,body{min-height:100%;margin:0}body{display:grid;place-items:center;background:#06101e;color:#dceafb;font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.ambient{position:fixed;pointer-events:none}.login-shell{position:relative;width:min(430px,calc(100% - 40px));padding:38px;border:1px solid rgba(156,183,218,.15);border-radius:24px;background:linear-gradient(145deg,rgba(18,37,62,.95),rgba(7,19,34,.97));box-shadow:0 26px 80px rgba(0,0,0,.35)}.login-brand{display:flex;align-items:center;gap:13px;margin-bottom:34px}.login-brand-mark{display:grid;grid-template-columns:repeat(3,5px);align-items:end;gap:3px;width:35px;height:35px;padding:9px;border:1px solid rgba(98,230,212,.5);border-radius:13px;background:rgba(98,230,212,.08)}.login-brand-mark i{display:block;border-radius:4px;background:#62e6d4}.login-brand-mark i:nth-child(1){height:11px}.login-brand-mark i:nth-child(2){height:20px}.login-brand-mark i:nth-child(3){height:15px}.login-brand strong{display:block;font-size:20px;letter-spacing:.02em}.login-brand small{display:block;margin-top:3px;color:#7189a3;font-size:10px}.login-kicker{color:#62e6d4;font-size:10px;letter-spacing:.22em;font-weight:700}.login-title{margin:9px 0 9px;font-size:29px;letter-spacing:-.04em}.login-copy{margin:0 0 28px;color:#8095ad;font-size:12px;line-height:1.7}.login-form{display:grid;gap:15px}.login-field span{display:block;margin-bottom:8px;color:#91a6bc;font-size:11px}.login-field input{box-sizing:border-box;width:100%;padding:13px 14px;border:1px solid rgba(156,183,218,.18);border-radius:11px;outline:0;background:rgba(3,12,24,.58);color:#e5f2ff;font:inherit;font-size:13px}.login-field input:focus{border-color:rgba(98,230,212,.72);box-shadow:0 0 0 3px rgba(98,230,212,.09)}.login-submit{margin-top:6px;padding:13px 16px;border:0;border-radius:11px;background:#62e6d4;color:#05262a;font:700 13px inherit;cursor:pointer;box-shadow:0 12px 25px rgba(98,230,212,.16)}.login-submit:hover{background:#8ff2e6}.login-submit:disabled{opacity:.62;cursor:wait}.login-error{min-height:16px;margin:1px 0 0;color:#ff9baa;font-size:11px}.login-foot{margin:26px 0 0;color:#5e7895;font-size:10px;line-height:1.6}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient ambient-one"></div><div class="ambient ambient-two"></div>
|
||||||
|
<main class="login-shell">
|
||||||
|
<div class="login-brand"><div class="login-brand-mark" aria-hidden="true"><i></i><i></i><i></i></div><div><strong>Orbit</strong><small>星轨控制台</small></div></div>
|
||||||
|
<div class="login-kicker">CONTROL PLANE</div>
|
||||||
|
<h1 class="login-title">登录控制台</h1>
|
||||||
|
<p class="login-copy">使用你的控制台账号进入任务、定时、流程和结果交付页面。</p>
|
||||||
|
<form class="login-form" id="login-form" autocomplete="on">
|
||||||
|
<label class="login-field"><span>用户名</span><input id="username" name="username" type="text" autocomplete="username" maxlength="128" required autofocus></label>
|
||||||
|
<label class="login-field"><span>密码</span><input id="password" name="password" type="password" autocomplete="current-password" maxlength="1024" required></label>
|
||||||
|
<p class="login-error" id="login-error" role="alert"></p>
|
||||||
|
<button class="login-submit" id="login-submit" type="submit">登录 Orbit <span>→</span></button>
|
||||||
|
</form>
|
||||||
|
<p class="login-foot">会话由服务端保存和校验,浏览器不会读取业务凭据。</p>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const form = document.getElementById('login-form'); const username = document.getElementById('username'); const password = document.getElementById('password'); const submit = document.getElementById('login-submit'); const error = document.getElementById('login-error');
|
||||||
|
const params = new URLSearchParams(location.search); let returnTo = params.get('return') || '/console/'; if (!returnTo.startsWith('/console') || returnTo.startsWith('//') || returnTo.startsWith('/console/login')) returnTo = '/console/';
|
||||||
|
fetch('/console/auth/session', { headers: { Accept: 'application/json' }, cache: 'no-store' }).then(response => response.json()).then(body => { if (body.authenticated) location.replace(returnTo); }).catch(() => {});
|
||||||
|
form.addEventListener('submit', async event => { event.preventDefault(); error.textContent = ''; submit.disabled = true; submit.textContent = '正在登录…'; try { const response = await fetch('/console/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ username: username.value.trim(), password: password.value }) }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body?.error?.message || '登录失败,请检查用户名和密码'); location.replace(returnTo); } catch (loginError) { error.textContent = loginError.message || '登录失败,请稍后重试'; password.select(); } finally { submit.disabled = false; submit.textContent = '登录 Orbit →'; } });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
84
internal/transport/httpserver/console_auth_test.go
Normal file
84
internal/transport/httpserver/console_auth_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeConsoleAuth struct {
|
||||||
|
valid bool
|
||||||
|
login consoleauth.LoginResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeConsoleAuth) Login(context.Context, string, string, string, string) (consoleauth.LoginResult, error) {
|
||||||
|
return f.login, nil
|
||||||
|
}
|
||||||
|
func (f *fakeConsoleAuth) ValidateSession(context.Context, auth.Token) error {
|
||||||
|
if !f.valid {
|
||||||
|
return context.Canceled
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeConsoleAuth) RevokeSession(context.Context, auth.Token) error { return nil }
|
||||||
|
|
||||||
|
func TestConsoleBusinessPageRedirectsToLogin(t *testing.T) {
|
||||||
|
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/console/", nil)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
server.Handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusFound || res.Header().Get("Location") != "/console/login?return=%2Fconsole%2F" {
|
||||||
|
t.Fatalf("redirect = %d %q", res.Code, res.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleSessionMiddlewareCopiesValidatedCookieToBearer(t *testing.T) {
|
||||||
|
ns, _ := core.NewID()
|
||||||
|
cred, _ := core.NewID()
|
||||||
|
raw, _, err := auth.Issue(ns, cred)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store := &fakeConsoleAuth{valid: true}
|
||||||
|
handler := ConsoleSessionMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer "+raw {
|
||||||
|
t.Fatalf("authorization = %q", got)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}), store)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v1/tasks", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d", res.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleLoginSetsRootScopedHttpOnlyCookie(t *testing.T) {
|
||||||
|
ns, _ := core.NewID()
|
||||||
|
cred, _ := core.NewID()
|
||||||
|
raw, _, err := auth.Issue(ns, cred)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{login: consoleauth.LoginResult{Token: raw, ExpiresAt: time.Now().Add(time.Hour)}})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/console/auth/login", strings.NewReader(`{"username":"operator","password":"password-password"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
server.Handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", res.Code)
|
||||||
|
}
|
||||||
|
cookie := res.Result().Cookies()[0]
|
||||||
|
if cookie.Path != "/" || !cookie.HttpOnly {
|
||||||
|
t.Fatalf("cookie path/httpOnly = %q/%v", cookie.Path, cookie.HttpOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/buildinfo"
|
"git.yuntuo.hk/k2safe/orbit/internal/buildinfo"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||||
"git.yuntuo.hk/k2safe/orbit/internal/observability"
|
"git.yuntuo.hk/k2safe/orbit/internal/observability"
|
||||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
)
|
)
|
||||||
@@ -48,6 +49,16 @@ func (r *Readiness) BeginDrain() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(addr string, ready Checker, tasks ...*service.Service) *http.Server {
|
func New(addr string, ready Checker, tasks ...*service.Service) *http.Server {
|
||||||
|
return newServer(addr, ready, nil, tasks...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithConsoleAuth 创建带数据库控制台登录会话的 HTTP 服务。
|
||||||
|
// 保留 New 供只测试 API 的调用方使用;生产入口应传入 ConsoleAuthStore。
|
||||||
|
func NewWithConsoleAuth(addr string, ready Checker, consoleAuth consoleauth.Store, tasks ...*service.Service) *http.Server {
|
||||||
|
return newServer(addr, ready, consoleAuth, tasks...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServer(addr string, ready Checker, consoleAuth consoleauth.Store, tasks ...*service.Service) *http.Server {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
write := func(w http.ResponseWriter, status int, body map[string]string) {
|
write := func(w http.ResponseWriter, status int, body map[string]string) {
|
||||||
// 探针响应不能被代理或浏览器缓存,否则下线节点可能继续被路由流量。
|
// 探针响应不能被代理或浏览器缓存,否则下线节点可能继续被路由流量。
|
||||||
@@ -83,10 +94,19 @@ func New(addr string, ready Checker, tasks ...*service.Service) *http.Server {
|
|||||||
registerHistory(mux, tasks[0])
|
registerHistory(mux, tasks[0])
|
||||||
}
|
}
|
||||||
server := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
|
server := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
|
||||||
RegisterConsole(server)
|
RegisterConsole(server, consoleAuth)
|
||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WrapConsoleAuth 必须在所有 Register* 路由完成后调用,因为路由注册器需要
|
||||||
|
// 直接访问底层 ServeMux;包装完成后,浏览器会话才会转换为内部 Bearer 请求。
|
||||||
|
func WrapConsoleAuth(server *http.Server, consoleAuth consoleauth.Store) {
|
||||||
|
if server == nil || consoleAuth == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server.Handler = ConsoleSessionMiddleware(server.Handler, consoleAuth)
|
||||||
|
}
|
||||||
|
|
||||||
// WrapMetrics must run after all Register* functions because those functions
|
// WrapMetrics must run after all Register* functions because those functions
|
||||||
// intentionally register routes on the underlying ServeMux.
|
// intentionally register routes on the underlying ServeMux.
|
||||||
func WrapMetrics(server *http.Server, metrics *observability.Registry) {
|
func WrapMetrics(server *http.Server, metrics *observability.Registry) {
|
||||||
|
|||||||
Reference in New Issue
Block a user