Files
Agent-System-Backend/cmd/agent-system/main.go
T
weichen 9a545b1e69 Add admin-issued accounts with forced first-login password change
Why: the user ruled out self-registration (2026-09-30): accounts are
created only by admins who set an initial password; first login (and
first login after any admin reset) must force a password change.

Evidence:
- migration v10 auth_accounts (users password/must_change/is_admin +
  sessions table; upgrade test walks v1<->v10 both directions).
- argon2id hash/verify with PHC encoding; store tests cover hash
  roundtrip, duplicate-owner conflict, must-change lifecycle, session
  keep/except semantics.
- e2e TestAdminAccountFlow: static login page reachable without auth,
  wrong password 401, login sets HttpOnly cookie + mustChangePassword,
  admin creates user (409 on duplicate), non-admin create 403, forced
  change clears flag and old password dies, admin reset revokes sessions
  and re-arms forced change, API-key channel untouched, 5-fail backoff
  429, logout kills session. Full go test ./... green.

Impact: middleware now accepts Bearer key OR session cookie and exempts
static + /api/auth/login (also fixes the latent case where the login
page itself could 401). Embedded no-auth mode unchanged.

Refs: proposal 2026-09-30-ui-redesign-and-auth.md section 5 (admin-issued
variant, user decision 2026-09-30)
2026-09-30 11:15:22 +00:00

211 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// agent-system:单二进制服务(HTTP/SSE + 内嵌前端 + 双档存储)。
// 部署约束(设计 4.1 原则 8):非容器化;拷贝即运行。
// 嵌入式档默认(SQLite + 本地对象目录,零外部服务);--dsn 切换 PostgreSQL 档
// (多用户/多实例,认证默认开启,apikey 子命令管理密钥)。
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"agent-system-backend/internal/app"
"agent-system-backend/internal/config"
)
var version = "0.1.0-dev"
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "version", "--version":
fmt.Println("agent-system", version)
return
case "serve":
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
case "apikey":
os.Exit(runAPIKey(os.Args[2:]))
case "config":
os.Exit(runConfig(os.Args[2:]))
case "user":
os.Exit(runUser(os.Args[2:]))
}
}
fs := flag.NewFlagSet("serve", flag.ExitOnError)
cfgFile := fs.String("config", "", "显式配置文件(JSON,键见 config explain;无自动发现)")
addr := fs.String("addr", "127.0.0.1:8787", "监听地址")
data := fs.String("data", "./data", "数据目录(SQLite 与工件对象目录)")
pluginsDir := fs.String("plugins", "./plugins", "插件目录(声明资产热加载)")
mock := fs.Bool("mock", false, "演示模式(等同 runtime.mock=true,不调用模型)")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite 档)")
artifacts := fs.String("artifacts", "", "工件对象目录(默认 <data>/artifacts;PG 档多实例时指向共享卷)")
auth := fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off")
fs.Parse(os.Args[1:])
cliSet := map[string]bool{}
fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true })
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth,
}, cliSet)
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
}
if err := app.Run(app.Options{
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
}); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
}
}
// runConfig:config explain——只读解析诊断(不建库、不写业务目录;DSN 脱敏)。
func runConfig(args []string) int {
if len(args) == 0 || args[0] != "explain" {
fmt.Fprintln(os.Stderr, "用法:agent-system config explain [--config f.json] [--addr …](其余 serve 同名 flag 用于演示覆盖)")
return 2
}
fs := flag.NewFlagSet("config explain", flag.ExitOnError)
cfgFile := fs.String("config", "", "显式配置文件(JSON)")
addr := fs.String("addr", "127.0.0.1:8787", "")
data := fs.String("data", "./data", "")
pluginsDir := fs.String("plugins", "./plugins", "")
mock := fs.Bool("mock", false, "")
dsn := fs.String("dsn", "", "")
artifacts := fs.String("artifacts", "", "")
auth := fs.String("auth", "auto", "")
fs.Parse(args[1:])
cliSet := map[string]bool{}
fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true })
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth,
}, cliSet)
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
return 1
}
resolved.Explain(os.Stdout, *cfgFile)
return 0
}
// runAPIKey:密钥管理子命令(create/list/revoke)。
func runAPIKey(args []string) int {
// 子命令在前、flag 在后:先剥出子命令再解析(Go flag 遇位置参数即停)。
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "用法:agent-system apikey <create|list|revoke> [--owner x --name y]")
return 2
}
sub := args[0]
fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError)
data := fs.String("data", "./data", "数据目录")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
owner := fs.String("owner", "", "租户名(owner)")
name := fs.String("name", "default", "密钥名称")
fs.Parse(args[1:])
if *owner == "" {
fmt.Fprintln(os.Stderr, "apikey: --owner 必填")
return 2
}
switch sub {
case "create":
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey create:", err)
return 1
}
// 明文仅此一次输出;库中只存 SHA-256 摘要。
fmt.Println(key)
return 0
case "list":
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey list:", err)
return 1
}
out, _ := json.MarshalIndent(keys, "", " ")
fmt.Println(string(out))
return 0
case "revoke":
ok, err := app.RevokeAPIKey(*dsn, *data, *owner, *name)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey revoke:", err)
return 1
}
if !ok {
fmt.Fprintf(os.Stderr, "apikey revoke: 未找到 owner=%s name=%s 的有效密钥\n", *owner, *name)
return 1
}
fmt.Println("revoked")
return 0
default:
fmt.Fprintln(os.Stderr, "apikey: 未知子命令", sub)
return 2
}
}
// runUser:用户账号管理子命令(管理员发放制,R2)——create / reset-password / list。
// 密码未显式给定时生成随机值,明文仅此一次输出;创建/重置后首次登录强制改密。
func runUser(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|reset-password|list> [--owner x --name y --admin --password p]")
return 2
}
sub := args[0]
fs := flag.NewFlagSet("user "+sub, flag.ExitOnError)
data := fs.String("data", "./data", "数据目录")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
owner := fs.String("owner", "", "用户名(owner)")
name := fs.String("name", "", "显示名")
password := fs.String("password", "", "密码(空 = 随机生成并打印一次)")
admin := fs.Bool("admin", false, "管理员(可管理用户与密钥)")
fs.Parse(args[1:])
switch sub {
case "create":
if *owner == "" {
fmt.Fprintln(os.Stderr, "user create: --owner 必填")
return 2
}
pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, *admin)
if err != nil {
fmt.Fprintln(os.Stderr, "user create:", err)
return 1
}
fmt.Println(pw)
if *password == "" {
fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。")
}
return 0
case "reset-password":
if *owner == "" {
fmt.Fprintln(os.Stderr, "user reset-password: --owner 必填")
return 2
}
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password)
if err != nil {
fmt.Fprintln(os.Stderr, "user reset-password:", err)
return 1
}
fmt.Println(pw)
if *password == "" {
fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。")
}
return 0
case "list":
users, err := app.ListUserAccounts(*dsn, *data)
if err != nil {
fmt.Fprintln(os.Stderr, "user list:", err)
return 1
}
out, _ := json.MarshalIndent(users, "", " ")
fmt.Println(string(out))
return 0
default:
fmt.Fprintln(os.Stderr, "user: 未知子命令", sub)
return 2
}
}