diff --git a/cmd/orbit-admin/main.go b/cmd/orbit-admin/main.go index f3c6bd5..fe36fc4 100644 --- a/cmd/orbit-admin/main.go +++ b/cmd/orbit-admin/main.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "time" "git.yuntuo.hk/k2safe/orbit/internal/buildinfo" @@ -26,13 +27,15 @@ func main() { } func run() error { 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] { case "release": return runRelease(os.Args[2:]) case "bootstrap": return runBootstrap(os.Args[2:]) + case "console-user": + return runConsoleUser(os.Args[2:]) case "verify": return runVerify(os.Args[2:]) case "observe": @@ -42,7 +45,7 @@ func run() error { case "credential": 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、凭据或业务数据。 @@ -324,3 +327,78 @@ func runBootstrap(args []string) error { fmt.Println("Bootstrap committed. Credentials saved in the requested private file; valid for 30 days.") 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 +} diff --git a/cmd/orbit/main.go b/cmd/orbit/main.go index 533ddb5..08aabe1 100644 --- a/cmd/orbit/main.go +++ b/cmd/orbit/main.go @@ -84,7 +84,8 @@ func run() error { txStats := mysql.ReadTransactionStats() 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))) node, err := core.NewID() if err != nil { @@ -120,6 +121,7 @@ func run() error { httpserver.RegisterSchedules(server, schedule.New(schedules)) 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.WrapConsoleAuth(server, consoleAuth) httpserver.WrapMetrics(server, metrics) workflows, err := mysql.NewWorkflowRuntime(db, node) if err != nil { diff --git a/docs/console.md b/docs/console.md index 9951319..3ea4262 100644 --- a/docs/console.md +++ b/docs/console.md @@ -9,8 +9,8 @@ http://127.0.0.1:8080/console/ ## 当前能力 - 读取 `/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 负责最终门控。 - 在具备 `events:manage` 权限时,订阅详情可以启停订阅,并对 `EXPIRED` 交付追加次数预算后重试;原交付身份、已用次数和事件不会被页面重置。 - 左侧五个模块是独立的控制台视图,使用 `/console/#/overview`、`#/tasks`、`#/schedules`、`#/workflows` 和 `#/subscriptions` 定位;切换视图不会把所有模块堆在一张长页面上。 -- 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。 +- 任务状态按 10 秒自动刷新;页面只调用服务端授权接口,不把账号密码或业务凭据上传到独立服务,也不读取数据库。 - 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。 ## 本地打开 -启动 Orbit 后直接访问 `/console/`。当前 Docker 开发环境可用: +启动 Orbit 后访问 `/console/`。未登录时,任何控制台业务页面都会跳转到 `/console/login`;登录成功后进入控制台,登录会话过期或服务端返回 401 时也会重新跳转。当前 Docker 开发环境入口: ```text 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 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,不自动猜测结果、不替操作者补齐证据,也不把冲突重试成“最新版本”。 diff --git a/go.mod b/go.mod index d172120..1ec6f43 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,10 @@ require ( github.com/go-sql-driver/mysql v1.10.1 github.com/robfig/cron/v3 v3.0.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + golang.org/x/crypto v0.48.0 ) require ( filippo.io/edwards25519 v1.2.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index dec3d63..cf31c6b 100644 --- a/go.sum +++ b/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/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= 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/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +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= diff --git a/internal/consoleauth/auth.go b/internal/consoleauth/auth.go new file mode 100644 index 0000000..c9562da --- /dev/null +++ b/internal/consoleauth/auth.go @@ -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 +} diff --git a/internal/storage/mysql/baseline.sql b/internal/storage/mysql/baseline.sql index 917cefd..893dbc2 100644 --- a/internal/storage/mysql/baseline.sql +++ b/internal/storage/mysql/baseline.sql @@ -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_2` CHECK ((`kind` in (_utf8mb4'SIGNAL',_utf8mb4'TIMER',_utf8mb4'POLL'))) ) 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 登录会话,只保存令牌摘要'; diff --git a/internal/storage/mysql/console_auth.go b/internal/storage/mysql/console_auth.go new file mode 100644 index 0000000..b444181 --- /dev/null +++ b/internal/storage/mysql/console_auth.go @@ -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 +} diff --git a/internal/storage/mysql/migrate.go b/internal/storage/mysql/migrate.go index 103865d..d815674 100644 --- a/internal/storage/mysql/migrate.go +++ b/internal/storage/mysql/migrate.go @@ -26,7 +26,7 @@ var migrationFiles embed.FS //go:embed baseline.sql var freshInstallBaseline string -const freshInstallBaselineVersion uint64 = 28 +const freshInstallBaselineVersion uint64 = 29 type Migration struct { Version uint64 diff --git a/internal/storage/mysql/migrations/000029_console_auth.up.sql b/internal/storage/mysql/migrations/000029_console_auth.up.sql new file mode 100644 index 0000000..727d873 --- /dev/null +++ b/internal/storage/mysql/migrations/000029_console_auth.up.sql @@ -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 登录会话,只保存令牌摘要'; diff --git a/internal/storage/mysql/task_store.go b/internal/storage/mysql/task_store.go index ebb26db..aad4d1d 100644 --- a/internal/storage/mysql/task_store.go +++ b/internal/storage/mysql/task_store.go @@ -58,7 +58,27 @@ func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permi return auth.Permissions{}, err } 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 if json.Unmarshal(raw, &p) != nil || p.Validate() != nil { diff --git a/internal/transport/httpserver/console.go b/internal/transport/httpserver/console.go index ceae11d..bed010a 100644 --- a/internal/transport/httpserver/console.go +++ b/internal/transport/httpserver/console.go @@ -1,25 +1,39 @@ package httpserver import ( + "context" "embed" "encoding/json" "io/fs" + "net" "net/http" + "net/url" "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 -// 或额外的文件挂载。控制台只调用版本化 HTTP API,不读取数据库。 +// 或额外的文件挂载。业务页面需要登录;登录页和静态资源保持公开。 // //go:embed console var consoleAssets embed.FS // RegisterConsole 注册本地及生产可复用的 Orbit 控制台入口。 -// /console 不改变业务 API 的认证和路由;静态页面自身不包含凭据。 -func RegisterConsole(server *http.Server) { +// 页面本身不直接读取数据库,登录后的 API 请求仍由已有服务层做权限校验。 +func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) { if server == nil { return } + var consoleAuth consoleauth.Store + if len(authStores) > 0 { + consoleAuth = authStores[0] + } mux, ok := server.Handler.(*http.ServeMux) if !ok { return @@ -32,19 +46,147 @@ func RegisterConsole(server *http.Server) { mux.HandleFunc("GET /console", func(w http.ResponseWriter, r *http.Request) { 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) { - // 队列 ID 只是控制台的默认范围,不是凭据。Token 仍由每个授权 API - // 自己校验;这里不返回任何秘密,也不访问数据库。 - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("Content-Type", "application/json") - w.Header().Set("X-Content-Type-Options", "nosniff") - _ = json.NewEncoder(w).Encode(struct { - DefaultQueueID string `json:"default_queue_id"` - }{DefaultQueueID: os.Getenv("ORBIT_CONSOLE_QUEUE_ID")}) + if consoleAuth != nil { + if token, ok := consoleCookieToken(r); !ok || consoleAuth.ValidateSession(r.Context(), token) != nil { + consoleLoginRedirect(w, r) + return + } + } + // 队列 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) { + 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("X-Content-Type-Options", "nosniff") 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")) } diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js index 5f8d7b3..31249ac 100644 --- a/internal/transport/httpserver/console/app.js +++ b/internal/transport/httpserver/console/app.js @@ -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 text = (id, value) => { const el = $(id); if (el) el.textContent = value; }; 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) { const toast = $('toast'); toast.textContent = message; toast.classList.add('show'); @@ -32,42 +29,49 @@ if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`; 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 = {}) { 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 }); + const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers, credentials: 'same-origin' }); const raw = await response.text(); let body = {}; 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}`); return body; } async function loadConsoleConfig() { 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; state.consoleConfig = await response.json(); const defaultQueue = String(state.consoleConfig?.default_queue_id || '').trim(); if (!state.queue && defaultQueue) { state.queue = defaultQueue; - $('queue-id').value = defaultQueue; - sessionStorage.setItem('orbit.console.queue', defaultQueue); - text('queue-hint', '已使用服务端默认队列配置;也可选择凭据 JSON 覆盖。'); renderConnectionSummary(); } } catch { - // 连接表单仍可使用手工 API 地址和凭据,不因可选配置失败而阻断页面。 + // 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。 } } function renderConnectionSummary() { text('summary-api', state.base || location.origin); text('summary-api-hint', state.connected ? '接口响应正常,按 10 秒周期刷新' : 'API 地址自动跟随当前页面'); text('summary-queue', state.queue ? shortID(state.queue) : '未配置'); - text('summary-credential', state.token ? '已载入' : '未载入'); - text('summary-note', state.token && state.queue ? '已具备读取业务数据的连接条件。' : '服务状态会自动检查;需要业务数据时再加载凭据。'); + text('summary-credential', state.authenticated ? '账号会话' : '未登录'); + text('summary-note', state.authenticated ? '当前页面已通过账号会话访问服务,数据按账号权限显示。' : '登录后加载业务数据。'); } function setConnection(connected, detail = '') { state.connected = connected; - const authenticated = connected && Boolean(state.token && state.queue); + const authenticated = connected && state.authenticated; $('connection-state').textContent = authenticated ? '已连接' : (connected ? '服务在线' : '未连接'); $('connection-state').classList.toggle('ready', connected); $('side-status').textContent = authenticated ? '环境已连接' : (connected ? '服务在线' : '等待连接'); @@ -172,8 +176,6 @@ return `console-${prefix}-${id}`.slice(0, 128); } 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 closeTaskDrawer() { $('task-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeActionForm(); } function renderDetailList(id, items, emptyText, renderItem) { @@ -204,7 +206,7 @@ } async function openTaskDetail(id) { 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; $('detail-attempts').innerHTML = '
任务、定时、流程和交付状态,在一块屏幕上保持有序运转。
服务状态会自动检查;需要业务数据时再加载凭据。
- +| 任务 | 处理器 | 状态 | 修订 | 创建时间 |
|---|---|---|---|---|
| 还没有任务数据连接一个具备 tasks:read 权限的凭据后自动加载 | ||||
| 任务 | 处理器 | 状态 | 修订 | 创建时间 |
|---|---|---|---|---|
| 还没有任务数据登录后按账号权限自动加载 | ||||