Merge pull request 'Add one-time login keys, restricted sessions, and OpenExisting' (#171) from feature/svc-deploy-u4-login-key into main
ci / vet-and-test (push) Failing after 43s
ci / vet-and-test (push) Failing after 43s
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/171
This commit was merged in pull request #171.
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/app"
|
||||
"agent-system-backend/internal/config"
|
||||
@@ -222,11 +223,11 @@ func runAPIKey(args []string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// runUser:用户账号管理子命令(管理员发放制,R2)——create / reset-password / list。
|
||||
// 密码未显式给定时生成随机值,明文仅此一次输出;创建/重置后首次登录强制改密。
|
||||
// runUser:create / reset-password / list / issue-login-key / revoke-login-key / bootstrap-admin。
|
||||
// 默认 create 无初始密码并签发激活 Key;--generate-password 兼容旧随机密码路径。
|
||||
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]")
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|reset-password|list|issue-login-key|revoke-login-key|bootstrap-admin> [flags]")
|
||||
return 2
|
||||
}
|
||||
sub := args[0]
|
||||
@@ -235,8 +236,15 @@ func runUser(args []string) int {
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
|
||||
owner := fs.String("owner", "", "用户名(owner)")
|
||||
name := fs.String("name", "", "显示名")
|
||||
password := fs.String("password", "", "密码(空 = 随机生成并打印一次)")
|
||||
admin := fs.Bool("admin", false, "管理员(可管理用户与密钥)")
|
||||
password := fs.String("password", "", "密码(create 空=激活 Key;加 --generate-password 则随机)")
|
||||
admin := fs.Bool("admin", false, "管理员")
|
||||
role := fs.String("role", "user", "角色 user|admin")
|
||||
openExisting := fs.Bool("open-existing", false, "只打开既有存储")
|
||||
generatePassword := fs.Bool("generate-password", false, "create 时生成随机初始密码")
|
||||
purpose := fs.String("purpose", "recover", "issue-login-key:activate|recover")
|
||||
ttlMin := fs.Int("ttl-minutes", 30, "登录 Key 有效分钟(5–120)")
|
||||
revokeAll := fs.Bool("revoke-all-credentials", false, "签发时撤销全部长期 API Key 与会话")
|
||||
keyID := fs.String("id", "", "revoke-login-key 凭据编号")
|
||||
fs.Parse(args[1:])
|
||||
switch sub {
|
||||
case "create":
|
||||
@@ -244,13 +252,24 @@ func runUser(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, "user create: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, *admin)
|
||||
isAdmin := *admin
|
||||
switch *role {
|
||||
case "user":
|
||||
case "admin":
|
||||
isAdmin = true
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role)
|
||||
return 2
|
||||
}
|
||||
pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user create:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(pw)
|
||||
if *password == "" {
|
||||
if *password == "" && !*generatePassword {
|
||||
fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。")
|
||||
} else if *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。")
|
||||
}
|
||||
return 0
|
||||
@@ -259,7 +278,7 @@ func runUser(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password)
|
||||
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password, *openExisting)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password:", err)
|
||||
return 1
|
||||
@@ -270,7 +289,7 @@ func runUser(args []string) int {
|
||||
}
|
||||
return 0
|
||||
case "list":
|
||||
users, err := app.ListUserAccounts(*dsn, *data)
|
||||
users, err := app.ListUserAccounts(*dsn, *data, *openExisting)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user list:", err)
|
||||
return 1
|
||||
@@ -278,6 +297,47 @@ func runUser(args []string) int {
|
||||
out, _ := json.MarshalIndent(users, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return 0
|
||||
case "issue-login-key":
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user issue-login-key: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
if *purpose != store.LoginKeyPurposeActivate && *purpose != store.LoginKeyPurposeRecover {
|
||||
fmt.Fprintf(os.Stderr, "user issue-login-key: 无效 purpose %q\n", *purpose)
|
||||
return 2
|
||||
}
|
||||
plain, err := app.IssueUserLoginKey(*dsn, *data, *owner, *purpose, *openExisting, *revokeAll, time.Duration(*ttlMin)*time.Minute)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user issue-login-key:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(plain)
|
||||
fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。")
|
||||
return 0
|
||||
case "revoke-login-key":
|
||||
if *keyID == "" {
|
||||
fmt.Fprintln(os.Stderr, "user revoke-login-key: --id 必填")
|
||||
return 2
|
||||
}
|
||||
if err := app.RevokeUserLoginKey(*dsn, *data, *keyID, *openExisting); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user revoke-login-key:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("revoked")
|
||||
return 0
|
||||
case "bootstrap-admin":
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
plain, err := app.BootstrapAdmin(*dsn, *data, *owner, *openExisting)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(plain)
|
||||
fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。")
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "user: 未知子命令", sub)
|
||||
return 2
|
||||
|
||||
+96
-21
@@ -704,25 +704,87 @@ func Run(opt Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUserAccount CLI 建号(管理员发放制):未给密码则生成随机 12 位并返回明文(仅此一次输出)。
|
||||
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin bool) (string, error) {
|
||||
if password == "" {
|
||||
password = server.RandomPassword()
|
||||
}
|
||||
hash, err := store.HashPassword(password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := withStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
return st.CreateAccount(owner, name, hash, isAdmin)
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
// CreateUserAccount CLI 建号。
|
||||
// password 空且 generatePassword=false:无初始密码,同事务签发 activate Key,返回明文 Key。
|
||||
// generatePassword=true 或显式 password:兼容旧路径(随机/给定密码 + must_change)。
|
||||
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) {
|
||||
var out string
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if password != "" || generatePassword {
|
||||
pw := password
|
||||
if pw == "" {
|
||||
pw = server.RandomPassword()
|
||||
}
|
||||
hash, err := store.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.CreateAccount(owner, name, hash, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
out = pw
|
||||
return nil
|
||||
}
|
||||
if err := st.CreateAccount(owner, name, "", isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli", 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = plain
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// IssueUserLoginKey CLI 签发激活/恢复 Key。
|
||||
func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) {
|
||||
var plain string
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if revokeAllCreds {
|
||||
if err := st.RevokeAllAPIKeys(owner); err != nil {
|
||||
return err
|
||||
}
|
||||
st.DeleteUserSessions(owner, "")
|
||||
}
|
||||
p, _, err := st.IssueLoginKey(owner, purpose, "cli", ttl)
|
||||
plain = p
|
||||
return err
|
||||
})
|
||||
return plain, err
|
||||
}
|
||||
|
||||
// RevokeUserLoginKey CLI 撤销未使用登录 Key。
|
||||
func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error {
|
||||
return withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
return st.RevokeLoginKey(id)
|
||||
})
|
||||
}
|
||||
|
||||
// BootstrapAdmin 仅当库无任何用户时创建首位管理员并签发 activate Key。
|
||||
func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) {
|
||||
var plain string
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
users, err := st.ListUsersAdmin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) > 0 {
|
||||
return fmt.Errorf("bootstrap-admin: users already exist (%d); refuse takeover", len(users))
|
||||
}
|
||||
if err := st.CreateAccount(owner, owner, "", true); err != nil {
|
||||
return err
|
||||
}
|
||||
p, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli-bootstrap", 0)
|
||||
plain = p
|
||||
return err
|
||||
})
|
||||
return plain, err
|
||||
}
|
||||
|
||||
// ResetUserPassword CLI 重置密码:must_change=1(首登强制改密),吊销全部会话。
|
||||
func ResetUserPassword(storageDSN, dataDir, owner, password string) (string, error) {
|
||||
func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) {
|
||||
if password == "" {
|
||||
password = server.RandomPassword()
|
||||
}
|
||||
@@ -730,7 +792,7 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string) (string, err
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := withStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if err := st.SetUserPassword(owner, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -743,9 +805,9 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string) (string, err
|
||||
}
|
||||
|
||||
// ListUserAccounts CLI 用户列表。
|
||||
func ListUserAccounts(storageDSN, dataDir string) ([]store.AdminUser, error) {
|
||||
func ListUserAccounts(storageDSN, dataDir string, openExisting bool) ([]store.AdminUser, error) {
|
||||
var out []store.AdminUser
|
||||
err := withStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
var e error
|
||||
out, e = st.ListUsersAdmin()
|
||||
return e
|
||||
@@ -754,12 +816,25 @@ func ListUserAccounts(storageDSN, dataDir string) ([]store.AdminUser, error) {
|
||||
}
|
||||
|
||||
func withStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
|
||||
return withStoreMode(storageDSN, dataDir, false, fn)
|
||||
}
|
||||
|
||||
func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
if openExisting {
|
||||
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
}
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
dbPath := filepath.Join(dataDir, "agent-system.db")
|
||||
if openExisting {
|
||||
st, err = store.OpenExistingSQLite(dbPath)
|
||||
} else {
|
||||
st, err = store.OpenSQLite(dbPath)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -32,6 +32,12 @@ func NewLocal(dir string) (*Local, error) {
|
||||
return &Local{dir: dir}, nil
|
||||
}
|
||||
|
||||
// OpenLocalExisting 绑定既有目录,不 MkdirAll。目录缺失时仍返回可用句柄
|
||||
// (账户运维只碰 DB;Put 时若目录不存在会失败)。
|
||||
func OpenLocalExisting(dir string) (*Local, error) {
|
||||
return &Local{dir: dir}, nil
|
||||
}
|
||||
|
||||
// validID 只接受纯文件名形态的对象 id:非空、不含路径分隔符、非 . / ..。
|
||||
// Put 的 id 可能是上层透传的用户输入(如工件路径参数),先在此挡住路径穿越。
|
||||
func validID(id string) bool {
|
||||
|
||||
@@ -70,10 +70,41 @@ func setSessionCookie(w http.ResponseWriter, token string, maxAge int) {
|
||||
func authStateJSON(st store.UserAuthState) map[string]any {
|
||||
return map[string]any{
|
||||
"owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin,
|
||||
"mustChangePassword": st.MustChangePassword, "auth": true,
|
||||
"mustChangePassword": st.MustChangePassword, "passwordSet": st.HasPassword,
|
||||
"auth": true,
|
||||
}
|
||||
}
|
||||
|
||||
// redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。
|
||||
func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Auth == nil || !s.Auth.Enabled {
|
||||
http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
token, owner, err := s.st.RedeemLoginKey(strings.TrimSpace(req.Key))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
http.Error(w, "login key already used", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid or expired login key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, token, int(10*60))
|
||||
st, _ := s.st.GetUserAuthState(owner)
|
||||
out := authStateJSON(st)
|
||||
out["sessionScope"] = store.SessionScopeSetPasswordOnly
|
||||
out["reloginRequired"] = false
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// login:用户名+密码 → 会话。失败统一 401(不区分账号是否存在);
|
||||
// 连续失败触发进程内退避(429)。
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -123,15 +154,16 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// changePassword:会话登录者改密。校验当前密码 → 新哈希落库 → 清 must_change →
|
||||
// 吊销其余会话(保留当前)。重置后的首登改密走同一路径。
|
||||
// changePassword:会话登录者改密。
|
||||
// - scope=set_password_only 或 must_change/未设密:不要求 current;完成后销毁全部会话,要求重新密码登录。
|
||||
// - 普通主动改密:要求 current;保留当前会话、吊销其余。
|
||||
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
tok := sessionToken(r)
|
||||
if tok == "" {
|
||||
http.Error(w, "session login required (API keys do not own passwords)", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
owner, ok := s.st.SessionOwner(tok)
|
||||
owner, scope, ok := s.st.SessionInfo(tok)
|
||||
if !ok {
|
||||
http.Error(w, "session expired", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -144,24 +176,39 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := s.st.VerifyLogin(owner, req.Current); !ok {
|
||||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := validPassword(req.Next); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
st0, _ := s.st.GetUserAuthState(owner)
|
||||
forceSet := scope == store.SessionScopeSetPasswordOnly || st0.MustChangePassword || !st0.HasPassword
|
||||
if !forceSet {
|
||||
if _, ok := s.st.VerifyLogin(owner, req.Current); !ok {
|
||||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
hash, err := store.HashPassword(req.Next)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if forceSet {
|
||||
if err := s.st.CompleteSetPassword(owner, hash); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, "", -1)
|
||||
st, _ := s.st.GetUserAuthState(owner)
|
||||
out := authStateJSON(st)
|
||||
out["reloginRequired"] = true
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
if err := s.st.SetUserPassword(owner, hash); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
// SetUserPassword 置了 must_change=1(重置语义);本人主动改密后即清除。
|
||||
if err := s.st.ClearMustChangePassword(owner); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
|
||||
+82
-18
@@ -14,13 +14,16 @@ type ownerCtxKey struct{}
|
||||
// authViaCtxKey 携带凭证通道(AuthViaKey / AuthViaSession;未启用认证与豁免路径为空)。
|
||||
type authViaCtxKey struct{}
|
||||
|
||||
// sessionScopeCtxKey 携带会话 scope(full / set_password_only)。
|
||||
type sessionScopeCtxKey struct{}
|
||||
|
||||
// 凭证通道值:/api/me 的 authVia 字段;前端据此区分「改密门」只对会话登录者放行。
|
||||
const (
|
||||
AuthViaKey = "key"
|
||||
AuthViaSession = "session"
|
||||
)
|
||||
|
||||
// Authenticator 是 API key 认证(阶段 1:user 即租户)。
|
||||
// Authenticator 是 API key 认证(阶段 1:owner 即租户)。
|
||||
// Enabled=false(嵌入式档默认,回环本地单用户)时全部请求回落默认租户。
|
||||
// SSE 的 EventSource 无法带 Authorization 头,允许 ?key= 查询参数兜底。
|
||||
type Authenticator struct {
|
||||
@@ -41,41 +44,94 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r.WithContext(withOwner(r, store.OwnerDefault)))
|
||||
return
|
||||
}
|
||||
// 豁免:静态前端(登录页须可加载)与登录入口本身。
|
||||
p := r.URL.Path
|
||||
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" {
|
||||
// 豁免:静态前端、登录、一次性 Key 兑换(body 专用,不走 Bearer)。
|
||||
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// 凭证二选一:Authorization: Bearer <api key>(程序化;?key= 兼容 SSE 老客户端)
|
||||
// 或会话 cookie(浏览器登录;2026-09-30 R2 管理员发放制)。
|
||||
// 失效 key 不一票否决:浏览器 localStorage 可能残留已吊销 key(前端对所有请求
|
||||
// 附加 Bearer 头),会话 cookie 仍有效时回落会话;程序化客户端不带 cookie,
|
||||
// 行为不变(两者都无效才 401)。
|
||||
key := bearerKey(r)
|
||||
if key == "" {
|
||||
key = r.URL.Query().Get("key")
|
||||
}
|
||||
// 一次性登录 Key 不得经 Bearer / ?key= 使用。
|
||||
if strings.HasPrefix(key, "alk_") {
|
||||
http.Error(w, "unauthorized: one-time login keys must use /api/auth/login-key/redeem", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var owner string
|
||||
var via string
|
||||
var scope string
|
||||
ok := false
|
||||
if key != "" {
|
||||
if owner, ok := a.st.AuthOwner(key); ok {
|
||||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaKey)))
|
||||
if o, keyOK := a.st.AuthOwner(key); keyOK {
|
||||
owner, via, scope, ok = o, AuthViaKey, store.SessionScopeFull, true
|
||||
} else if o, sc, sessOK := cookieSession(r, a.st); sessOK {
|
||||
owner, via, scope, ok = o, AuthViaSession, sc, true
|
||||
} else {
|
||||
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if owner, ok := cookieOwner(r, a.st); ok {
|
||||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
|
||||
return
|
||||
}
|
||||
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
|
||||
} else if o, sc, sessOK := cookieSession(r, a.st); sessOK {
|
||||
owner, via, scope, ok = o, AuthViaSession, sc, true
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if owner, ok := cookieOwner(r, a.st); ok {
|
||||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
|
||||
ctx := withAuthVia(withOwner(r, owner), via)
|
||||
ctx = context.WithValue(ctx, sessionScopeCtxKey{}, scope)
|
||||
if err := a.enforcePasswordGate(w, r, owner, via, scope, p); err != nil {
|
||||
return
|
||||
}
|
||||
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Authenticator) enforcePasswordGate(w http.ResponseWriter, r *http.Request, owner, via, scope, path string) error {
|
||||
allow := path == "/api/me" || path == "/api/auth/password" || path == "/api/auth/logout" ||
|
||||
path == "/api/health"
|
||||
if scope == store.SessionScopeSetPasswordOnly {
|
||||
if !allow {
|
||||
http.Error(w, "forbidden: set-password session only", http.StatusForbidden)
|
||||
return errGate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
st, err := a.st.GetUserAuthState(owner)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// 长期 API Key:仅当 must_change(已有密码待改)时拒绝业务,防止绕过改密门;
|
||||
// 无密码的纯集成账户仍可走 Key(与历史 apikey 语义兼容)。
|
||||
if via == AuthViaKey {
|
||||
if st.MustChangePassword && !allow {
|
||||
http.Error(w, "forbidden: password change required", http.StatusForbidden)
|
||||
return errGate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 会话:待改密或尚未设密 → 只允许身份/设密/退出。
|
||||
if (st.MustChangePassword || !st.HasPassword) && !allow {
|
||||
http.Error(w, "forbidden: password setup required", http.StatusForbidden)
|
||||
return errGate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errGate = errorsGate{}
|
||||
|
||||
type errorsGate struct{}
|
||||
|
||||
func (errorsGate) Error() string { return "gate" }
|
||||
|
||||
func cookieSession(r *http.Request, st *store.Store) (owner, scope string, ok bool) {
|
||||
if tok := sessionToken(r); tok != "" {
|
||||
return st.SessionInfo(tok)
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// cookieOwner 会话 cookie 有效时返回其 owner。
|
||||
func cookieOwner(r *http.Request, st *store.Store) (string, bool) {
|
||||
if tok := sessionToken(r); tok != "" {
|
||||
@@ -108,6 +164,14 @@ func AuthVia(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// SessionScope 返回当前会话 scope(非会话为空)。
|
||||
func SessionScope(r *http.Request) string {
|
||||
if v, ok := r.Context().Value(sessionScopeCtxKey{}).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func bearerKey(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
|
||||
@@ -321,10 +321,29 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
return rec
|
||||
}
|
||||
|
||||
// 会话可访问 API;/api/me 携带身份与管理员位。
|
||||
// /api/me 允许 must_change;业务 API 拒绝(U4 设密门)。
|
||||
if rec := session("GET", "/api/me", ""); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"isAdmin":true`) {
|
||||
t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("must_change admin must be gated before create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 首登改密:不要求 current;成功后销毁会话,须重新密码登录。
|
||||
if rec := session("POST", "/api/auth/password", `{"next":"admin-final-9"}`); rec.Code != http.StatusOK ||
|
||||
!strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
|
||||
t.Fatalf("admin force set-password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := session("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("admin session must die after set-password: %d", rec.Code)
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("admin relogin after set-password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
tok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
|
||||
|
||||
// 管理员建号(普通用户,初始密码)。
|
||||
if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusOK {
|
||||
@@ -335,7 +354,7 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
t.Fatalf("duplicate owner must 409: %d", rec.Code)
|
||||
}
|
||||
|
||||
// alice 首登:mustChangePassword=true;改密后清除。
|
||||
// alice 首登:mustChangePassword=true;业务 API 被设密门拒绝。
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
@@ -358,16 +377,19 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("alice first login must force change: %s", rec.Body.String())
|
||||
}
|
||||
// alice 非管理员:建号被拒 403。
|
||||
if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin create must 403: %d", rec.Code)
|
||||
t.Fatalf("must_change non-admin create must 403: %d", rec.Code)
|
||||
}
|
||||
// alice 改密(错误当前密码 → 401;成功 → must_change 清除)。
|
||||
if rec := alice("POST", "/api/auth/password", `{"current":"bad","next":"alice-final-9"}`); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong current password: %d", rec.Code)
|
||||
// must_change 改密忽略 current;短密码拒绝;成功后 reloginRequired。
|
||||
if rec := alice("POST", "/api/auth/password", `{"next":"short"}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("short password must 400: %d", rec.Code)
|
||||
}
|
||||
if rec := alice("POST", "/api/auth/password", `{"current":"alice-init-01","next":"alice-final-9"}`); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("alice change password: %d %s", rec.Code, rec.Body.String())
|
||||
if rec := alice("POST", "/api/auth/password", `{"current":"ignored","next":"alice-final-9"}`); rec.Code != http.StatusOK ||
|
||||
!strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
|
||||
t.Fatalf("alice force set-password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("alice session must die after set-password: %d", rec.Code)
|
||||
}
|
||||
// 旧密码不能再登录;新密码可以且无需改密。
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`))
|
||||
@@ -377,6 +399,18 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old password must fail after change: %d", rec.Code)
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("alice relogin: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
aliceTok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
|
||||
// 已设密普通用户:建号仍 403(非管理员)。
|
||||
if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin create must 403: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 管理员重置 alice 密码 → 其旧会话吊销 + 首登再改密。
|
||||
if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusOK {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestRedeemLoginKeyRestrictedSession(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
if err := st.CreateAccount("carol", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey("carol", store.LoginKeyPurposeActivate, "test", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"key": plain})
|
||||
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cookie := rec.Result().Cookies()
|
||||
var sess string
|
||||
for _, c := range cookie {
|
||||
if c.Name == SessionCookie {
|
||||
sess = c.Value
|
||||
}
|
||||
}
|
||||
if sess == "" {
|
||||
t.Fatal("missing session cookie")
|
||||
}
|
||||
// 业务拒绝
|
||||
req = httptest.NewRequest("GET", "/api/projects", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("projects want 403, got %d", rec.Code)
|
||||
}
|
||||
// me 允许
|
||||
req = httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("me: %d", rec.Code)
|
||||
}
|
||||
// 设密后要求重新登录
|
||||
pwBody, _ := json.Marshal(map[string]string{"next": "carol-final-99"})
|
||||
req = httptest.NewRequest("POST", "/api/auth/password", bytes.NewReader(pwBody))
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(`"reloginRequired":true`)) {
|
||||
t.Fatalf("set password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,7 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
mux.HandleFunc("POST /api/auth/login", s.login)
|
||||
mux.HandleFunc("POST /api/auth/login-key/redeem", s.redeemLoginKey)
|
||||
mux.HandleFunc("POST /api/auth/logout", s.logout)
|
||||
mux.HandleFunc("POST /api/auth/password", s.changePassword)
|
||||
mux.HandleFunc("GET /api/admin/users", s.listAdminUsers)
|
||||
@@ -634,9 +635,13 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
name = u.Name
|
||||
}
|
||||
out := map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled, "authVia": AuthVia(r)}
|
||||
if sc := SessionScope(r); sc != "" {
|
||||
out["sessionScope"] = sc
|
||||
}
|
||||
if st, err := s.st.GetUserAuthState(owner); err == nil {
|
||||
out["isAdmin"] = st.IsAdmin
|
||||
out["mustChangePassword"] = st.MustChangePassword
|
||||
out["passwordSet"] = st.HasPassword
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
+39
-12
@@ -218,7 +218,8 @@ func VerifyPassword(hash, password string) bool {
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
// CreateAccount 管理员建号:owner 唯一;初始密码 must_change=1。
|
||||
// CreateAccount 管理员建号:owner 唯一。
|
||||
// passwordHash 非空时 must_change=1;空哈希表示未设密(走激活 Key),must_change=0。
|
||||
func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error {
|
||||
if _, err := s.GetUser(owner); err == nil {
|
||||
return ErrConflict
|
||||
@@ -226,10 +227,15 @@ func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) er
|
||||
if err := s.EnsureUser(owner, name); err != nil {
|
||||
return err
|
||||
}
|
||||
mustChange := passwordHash != ""
|
||||
setAt := ""
|
||||
if passwordHash != "" {
|
||||
setAt = nowISO()
|
||||
}
|
||||
// 布尔列一律绑 Go bool 参数、不写 1/0 字面量:SQLite 两侧都容忍,
|
||||
// PostgreSQL 对 boolean 列收到 integer 会 42804(2026-10-05 user create 半失败根因)。
|
||||
_, err := s.db.Exec(s.q(`UPDATE users SET name=?, password_hash=?, password_set_at=?, must_change_password=?, is_admin=? WHERE owner=?`),
|
||||
name, passwordHash, nowISO(), true, isAdmin, owner)
|
||||
name, passwordHash, setAt, mustChange, isAdmin, owner)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -294,35 +300,56 @@ func (s *Store) VerifyLogin(owner, password string) (UserAuthState, bool) {
|
||||
|
||||
// ---- 会话 ----
|
||||
|
||||
// CreateSession 建会话:明文 token 仅此一次返回;库存 SHA-256 摘要与到期时间。
|
||||
// CreateSession 建会话:明文 token 仅此一次返回;库存 SHA-256 摘要与到期时间;scope=full。
|
||||
func (s *Store) CreateSession(owner string) (string, error) {
|
||||
return s.CreateSessionScoped(owner, SessionScopeFull, sessionTTL)
|
||||
}
|
||||
|
||||
// CreateSessionScoped 按 scope 与 TTL 建会话。
|
||||
func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (string, error) {
|
||||
if scope == "" {
|
||||
scope = SessionScopeFull
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = sessionTTL
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := "sess_" + hex.EncodeToString(raw)
|
||||
exp := time.Now().Add(sessionTTL)
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at) VALUES(?,?,?,?,?)`),
|
||||
hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO())
|
||||
exp := time.Now().Add(ttl)
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope) VALUES(?,?,?,?,?,?)`),
|
||||
hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope)
|
||||
return token, err
|
||||
}
|
||||
|
||||
// SessionOwner 校验会话:命中且未过期返回 owner(尽力刷新 last_seen)。
|
||||
func (s *Store) SessionOwner(token string) (string, bool) {
|
||||
owner, _, ok := s.SessionInfo(token)
|
||||
return owner, ok
|
||||
}
|
||||
|
||||
// SessionInfo 返回 owner 与 scope。
|
||||
func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) {
|
||||
if token == "" {
|
||||
return "", false
|
||||
return "", "", false
|
||||
}
|
||||
var owner, exp string
|
||||
err := s.db.QueryRow(s.q(`SELECT owner,expires_at FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner, &exp)
|
||||
var exp string
|
||||
err := s.db.QueryRow(s.q(`SELECT owner,expires_at,COALESCE(scope,'full') FROM sessions WHERE token_hash=?`), hashKey(token)).
|
||||
Scan(&owner, &exp, &scope)
|
||||
if err != nil {
|
||||
return "", false
|
||||
return "", "", false
|
||||
}
|
||||
if expT, err := time.Parse(time.RFC3339Nano, exp); err != nil || time.Now().After(expT) {
|
||||
s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=?`), hashKey(token))
|
||||
return "", false
|
||||
return "", "", false
|
||||
}
|
||||
s.db.Exec(s.q(`UPDATE sessions SET last_seen_at=? WHERE token_hash=?`), nowISO(), hashKey(token))
|
||||
return owner, true
|
||||
if scope == "" {
|
||||
scope = SessionScopeFull
|
||||
}
|
||||
return owner, scope, true
|
||||
}
|
||||
|
||||
// DeleteSession 注销当前会话。
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 一次性登录凭据(与长期 api_keys 分离)。明文前缀 alk_;库只存 SHA-256。
|
||||
|
||||
const (
|
||||
LoginKeyPurposeActivate = "activate"
|
||||
LoginKeyPurposeRecover = "recover"
|
||||
|
||||
LoginKeyStatusUnused = "unused"
|
||||
LoginKeyStatusConsumed = "consumed"
|
||||
LoginKeyStatusRevoked = "revoked"
|
||||
LoginKeyStatusExpired = "expired"
|
||||
|
||||
SessionScopeFull = "full"
|
||||
SessionScopeSetPasswordOnly = "set_password_only"
|
||||
|
||||
defaultLoginKeyTTL = 30 * time.Minute
|
||||
minLoginKeyTTL = 5 * time.Minute
|
||||
maxLoginKeyTTL = 120 * time.Minute
|
||||
restrictedSessionTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
// LoginKeyMeta 对外可见元数据(无明文)。
|
||||
type LoginKeyMeta struct {
|
||||
ID string `json:"id"`
|
||||
Owner string `json:"owner"`
|
||||
Purpose string `json:"purpose"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
}
|
||||
|
||||
// IssueLoginKey 签发一次性登录 Key;撤销同 owner+purpose 的 unused 旧 Key。
|
||||
// 返回明文(仅此一次)与元数据。
|
||||
func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duration) (plain string, meta LoginKeyMeta, err error) {
|
||||
if purpose != LoginKeyPurposeActivate && purpose != LoginKeyPurposeRecover {
|
||||
return "", meta, fmt.Errorf("login key: invalid purpose %q", purpose)
|
||||
}
|
||||
if _, err := s.GetUser(owner); err != nil {
|
||||
return "", meta, err
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = defaultLoginKeyTTL
|
||||
}
|
||||
if ttl < minLoginKeyTTL {
|
||||
ttl = minLoginKeyTTL
|
||||
}
|
||||
if ttl > maxLoginKeyTTL {
|
||||
ttl = maxLoginKeyTTL
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", meta, err
|
||||
}
|
||||
idRaw := make([]byte, 8)
|
||||
if _, err := rand.Read(idRaw); err != nil {
|
||||
return "", meta, err
|
||||
}
|
||||
plain = "alk_" + hex.EncodeToString(raw)
|
||||
id := "lk_" + hex.EncodeToString(idRaw)
|
||||
now := time.Now().UTC()
|
||||
exp := now.Add(ttl)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", meta, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`),
|
||||
LoginKeyStatusRevoked, owner, purpose, LoginKeyStatusUnused); err != nil {
|
||||
return "", meta, err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by) VALUES(?,?,?,?,?,?,?,?)`),
|
||||
id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy); err != nil {
|
||||
return "", meta, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", meta, err
|
||||
}
|
||||
meta = LoginKeyMeta{ID: id, Owner: owner, Purpose: purpose, Status: LoginKeyStatusUnused, ExpiresAt: exp, CreatedAt: now, CreatedBy: createdBy}
|
||||
return plain, meta, nil
|
||||
}
|
||||
|
||||
// RevokeLoginKey 按凭据编号撤销未使用 Key。
|
||||
func (s *Store) RevokeLoginKey(id string) error {
|
||||
res, err := s.db.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`),
|
||||
LoginKeyStatusRevoked, id, LoginKeyStatusUnused)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RedeemLoginKey 原子消费:unused+未过期 → consumed,并创建 set_password_only 会话。
|
||||
// 明文不得走 Bearer;调用方专用 HTTP 入口。失败整体回滚。
|
||||
func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err error) {
|
||||
if plain == "" || len(plain) < 5 || plain[:4] != "alk_" {
|
||||
return "", "", ErrNotFound
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var id, o, status, exp string
|
||||
err = tx.QueryRow(s.q(`SELECT id,owner,status,expires_at FROM login_keys WHERE key_hash=?`), hashKey(plain)).
|
||||
Scan(&id, &o, &status, &exp)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
expT, err := time.Parse(time.RFC3339Nano, exp)
|
||||
if err != nil || time.Now().After(expT) {
|
||||
_, _ = tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusExpired, id, LoginKeyStatusUnused)
|
||||
_ = tx.Commit()
|
||||
return "", "", ErrNotFound
|
||||
}
|
||||
if status != LoginKeyStatusUnused {
|
||||
return "", "", ErrConflict
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE login_keys SET status=?, consumed_at=? WHERE id=? AND status=?`),
|
||||
LoginKeyStatusConsumed, nowISO(), id, LoginKeyStatusUnused)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
return "", "", ErrConflict
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
token = "sess_" + hex.EncodeToString(raw)
|
||||
sessExp := time.Now().Add(restrictedSessionTTL)
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope) VALUES(?,?,?,?,?,?)`),
|
||||
hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return token, o, nil
|
||||
}
|
||||
|
||||
// CompleteSetPassword 设密事务:写 hash、清 must_change、作废未用登录 Key、删除全部会话。
|
||||
func (s *Store) CompleteSetPassword(owner, passwordHash string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=? WHERE owner=?`),
|
||||
passwordHash, nowISO(), false, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
|
||||
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CountUnusedLoginKeys 未使用登录 Key 计数(list 展示)。
|
||||
func (s *Store) CountUnusedLoginKeys(owner string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM login_keys WHERE owner=? AND status=?`), owner, LoginKeyStatusUnused).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RevokeAllAPIKeys 失陷恢复:吊销 owner 全部长期 API Key。
|
||||
func (s *Store) RevokeAllAPIKeys(owner string) error {
|
||||
_, err := s.db.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND revoked=0`), owner)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenExistingSQLiteMissing(t *testing.T) {
|
||||
_, err := OpenExistingSQLite(filepath.Join(t.TempDir(), "nope.db"))
|
||||
if err == nil {
|
||||
t.Fatal("expected missing file error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginKeyRedeemOnceAndSetPassword(t *testing.T) {
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, meta, err := st.IssueLoginKey("alice", LoginKeyPurposeActivate, "test", 0)
|
||||
if err != nil || plain == "" || meta.ID == "" {
|
||||
t.Fatalf("issue: %v meta=%+v", err, meta)
|
||||
}
|
||||
tok, owner, err := st.RedeemLoginKey(plain)
|
||||
if err != nil || owner != "alice" || tok == "" {
|
||||
t.Fatalf("redeem: %v owner=%s", err, owner)
|
||||
}
|
||||
if _, _, err := st.RedeemLoginKey(plain); err == nil {
|
||||
t.Fatal("replay should fail")
|
||||
}
|
||||
o, scope, ok := st.SessionInfo(tok)
|
||||
if !ok || o != "alice" || scope != SessionScopeSetPasswordOnly {
|
||||
t.Fatalf("session info %v %q %q", ok, o, scope)
|
||||
}
|
||||
hash, err := HashPassword("new-password-9")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CompleteSetPassword("alice", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, ok := st.SessionInfo(tok); ok {
|
||||
t.Fatal("session should be gone after set password")
|
||||
}
|
||||
stAuth, ok := st.VerifyLogin("alice", "new-password-9")
|
||||
if !ok || stAuth.MustChangePassword || !stAuth.HasPassword {
|
||||
t.Fatalf("login after set: ok=%v %+v", ok, stAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginKeyConcurrentRedeem(t *testing.T) {
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "c.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.CreateAccount("bob", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey("bob", LoginKeyPurposeRecover, "test", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var okCount atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, _, err := st.RedeemLoginKey(plain); err == nil {
|
||||
okCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if n := okCount.Load(); n != 1 {
|
||||
t.Fatalf("expected 1 success, got %d", n)
|
||||
}
|
||||
}
|
||||
@@ -712,6 +712,48 @@ func init() {
|
||||
`ALTER TABLE knowledge_vectors DROP COLUMN content_hash`,
|
||||
`ALTER TABLE knowledge_vectors DROP COLUMN model`,
|
||||
}}},
|
||||
migration{version: 26, name: "login_keys_session_scope",
|
||||
// 一次性登录 Key(激活/恢复)与会话 scope(full | set_password_only)。
|
||||
up: map[string][]string{"sqlite": {
|
||||
`CREATE TABLE IF NOT EXISTS login_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
instance_id TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_login_keys_owner_purpose ON login_keys(owner, purpose, status)`,
|
||||
`ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'`,
|
||||
}, "postgres": {
|
||||
`CREATE TABLE IF NOT EXISTS login_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
instance_id TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_login_keys_owner_purpose ON login_keys(owner, purpose, status)`,
|
||||
`ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'`,
|
||||
}},
|
||||
down: map[string][]string{"sqlite": {
|
||||
`ALTER TABLE sessions DROP COLUMN scope`,
|
||||
`DROP INDEX IF EXISTS idx_login_keys_owner_purpose`,
|
||||
`DROP TABLE IF EXISTS login_keys`,
|
||||
}, "postgres": {
|
||||
`ALTER TABLE sessions DROP COLUMN scope`,
|
||||
`DROP INDEX IF EXISTS idx_login_keys_owner_purpose`,
|
||||
`DROP TABLE IF EXISTS login_keys`,
|
||||
}}},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+38
-7
@@ -87,6 +87,22 @@ func OpenSQLite(path string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return openSQLiteFile(path, true)
|
||||
}
|
||||
|
||||
// OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录。
|
||||
// 仍执行兼容迁移(requireCompatible);用于账户运维,避免恢复操作顺带建库。
|
||||
func OpenExistingSQLite(path string) (*Store, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("open existing: sqlite file missing: %s", path)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return openSQLiteFile(path, false)
|
||||
}
|
||||
|
||||
func openSQLiteFile(path string, createArtifacts bool) (*Store, error) {
|
||||
// _txlock=immediate:写事务在 BEGIN 即取库写锁(而非首个写语句升级),
|
||||
// 跨进程迁移/写路径依赖 busy_timeout 排队而不是中途 SQLITE_BUSY(KTD8)。
|
||||
// busy_timeout 30s:迁移竞争测试在慢速 CI 盘上 4 个迁移者串行全程可超 5s
|
||||
@@ -98,7 +114,7 @@ func OpenSQLite(path string) (*Store, error) {
|
||||
}
|
||||
// SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。
|
||||
db.SetMaxOpenConns(1)
|
||||
return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{})
|
||||
return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts)
|
||||
}
|
||||
|
||||
// OpenPostgres 打开 PostgreSQL 档存储并执行迁移(默认连接预算)。
|
||||
@@ -116,6 +132,15 @@ func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*St
|
||||
// OpenPostgresWithOptions 按 KTD12 连接预算打开 PG 档;QueryTimeoutSec>0 时
|
||||
// 以 statement_timeout 运行参数附着到 DSN(对 URL 与键值两种形式均生效)。
|
||||
func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store) (*Store, error) {
|
||||
return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true)
|
||||
}
|
||||
|
||||
// OpenExistingPostgres 打开既有 PG 库并执行兼容迁移;不创建 artifacts 目录。
|
||||
func OpenExistingPostgres(dsn, artifactsDir string) (*Store, error) {
|
||||
return openPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil, false)
|
||||
}
|
||||
|
||||
func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store, createArtifacts bool) (*Store, error) {
|
||||
pool = pool.effective()
|
||||
if pool.QueryTimeoutSec > 0 {
|
||||
dsn = withPGRuntimeParam(dsn, "statement_timeout", strconv.Itoa(pool.QueryTimeoutSec*1000))
|
||||
@@ -130,7 +155,7 @@ func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs b
|
||||
if pool.ConnMaxLifetimeSec > 0 {
|
||||
db.SetConnMaxLifetime(time.Duration(pool.ConnMaxLifetimeSec) * time.Second)
|
||||
}
|
||||
return finishOpen(db, postgresDialect{}, artifactsDir, blobs, pool)
|
||||
return finishOpen(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts)
|
||||
}
|
||||
|
||||
// withPGRuntimeParam 向 DSN 追加运行时参数(libpq 语义:URL 查询串或键值对)。
|
||||
@@ -156,10 +181,14 @@ func AppendPGRuntimeParam(dsn, key, value string) string {
|
||||
return dsn + sep + kv
|
||||
}
|
||||
|
||||
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions) (*Store, error) {
|
||||
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) {
|
||||
if blobs == nil {
|
||||
var err error
|
||||
blobs, err = blobstore.NewLocal(artifactsDir)
|
||||
if createArtifacts {
|
||||
blobs, err = blobstore.NewLocal(artifactsDir)
|
||||
} else {
|
||||
blobs, err = blobstore.OpenLocalExisting(artifactsDir)
|
||||
}
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
@@ -170,9 +199,11 @@ func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Stor
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
if createArtifacts {
|
||||
if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user