auth: switch user login to session token and decouple tenant access

This commit is contained in:
2026-03-03 19:45:09 +08:00
parent 67bc6ecae6
commit 3b555df56c
6 changed files with 795 additions and 147 deletions

220
ARCH_V1_USER_NETWORK.md Normal file
View File

@@ -0,0 +1,220 @@
# INP2P 最终方案 v1用户网络模型
> 状态:已冻结(按 2026-03-03 讨论结论)
> 目标:稳定优先,先完成认证/网络模型收敛,再做增量体验优化。
---
## 0. 冻结决策(已确认)
1. 一账号=一网络1:1
2. Hub 离线自动回 Mesh
3. 改 IP 后:目标节点重连 + 同网广播更新
4. API Key保留后台能力前台不突出
5. 暂不上 Refresh Token
6. Node 唯一标识采用 UUID稳定优先
---
## 1. 核心对象模型
### 1.1 Account账号
- 控制台登录主体(人)
- 字段:
- `account_id` (int64 PK)
- `username` (unique)
- `password_hash`
- `status` (1/0)
- `created_at`
### 1.2 Network用户网络
- 每个账号唯一绑定一个网络
- 字段:
- `network_id` (int64 PK)
- `account_id` (unique FK -> accounts)
- `cidr` (e.g. `10.0.1.0/24`)
- `mode` (`mesh`|`hub`)
- `hub_node_id` (nullable, FK -> nodes.node_id)
- `fallback_to_mesh` (bool, default true)
- `status` (1/0)
- `created_at`, `updated_at`
### 1.3 Node设备
- 设备唯一标识不变;展示名可变
- 字段:
- `node_id` (UUID, PK) ✅ 后端唯一键
- `network_id` (FK)
- `hostname` (设备上报)
- `alias` (用户自定义昵称,可空)
- `virtual_ip` (network cidr 内)
- `node_secret_hash`
- `relay_enabled` (bool)
- `online` (bool)
- `last_seen`
- `created_at`, `updated_at`
### 1.4 凭证对象
- `session_tokens`(控制台会话,短期)
- `enroll_tokens`(一次性/短期入网码)
- `api_keys`(后台自动化,非控制台主要路径)
---
## 2. 凭证与认证边界(彻底拆分)
### 2.1 会话 token控制台
- 登录成功后返回 `session_token`
- 内含:`account_id`, `network_id`, `role=owner`, `exp`
- 用于所有控制台 API
### 2.2 enroll token设备入网
- 控制台生成,一次性,短时有效(默认 10 分钟)
- 设备携带 `hostname` + enroll token 兑换 `node_id + node_secret + virtual_ip`
### 2.3 node_secret设备长期
- 设备 WS 登录凭证
- 仅设备连接信令使用,不用于控制台
### 2.4 API Key后台能力
- 用于自动化脚本/平台对接
- 不作为控制台默认登录路径
- 前台不突出,仅高级设置页可见
---
## 3. 网络与 IPAM 规则
### 3.1 子网分配
- 池:`10.0.1.0/24` ~ `10.0.254.0/24`
- 保留:`10.0.0.0/24``10.0.255.0/24`
- 账号注册时分配第一个可用网段
### 3.2 地址分配
- 每网络内自动分配:从 `.2` 开始
- 保留 `.0/.255/.1`
- 禁止重复占用
### 3.3 手动改 IP
- 必须在网络 CIDR 内
- 不可与现有节点冲突
- 成功后触发:
1) 目标节点收到 `ip_changed` 推送并重连
2) 服务端广播 `peer_ip_changed` 给同网在线节点
---
## 4. 拓扑模式状态机
### 4.1 Mesh默认
- 同网节点按策略尝试直连UDP/TCP
- 失败后按中继策略兜底
### 4.2 Hub
- 条件:`hub_node_id` 必填,且必须“在线 + 同网络 + relay_enabled=true”
- 数据面按 hub 转发
### 4.3 Hub 离线回退
- 监测阈值15~30 秒无心跳
- 自动回 `mesh`(记录审计日志)
- Hub 恢复后暂不自动切回v1 简化)
---
## 5. API 草图v1
## 5.1 认证
- `POST /api/v1/auth/register`
- req: `{username,password}`
- rsp: `{account_id, network:{cidr,mode}}`
- `POST /api/v1/auth/login`
- req: `{username,password}`
- rsp: `{session_token, expires_in, network}`
- `POST /api/v1/auth/logout`
## 5.2 网络配置
- `GET /api/v1/network`
- `POST /api/v1/network/mode`
- req: `{mode:"mesh"|"hub", hub_node_id?}`
- `POST /api/v1/network/hub`
- req: `{hub_node_id}`(含在线校验)
## 5.3 节点管理
- `GET /api/v1/nodes`
- `POST /api/v1/nodes/{node_id}/alias`
- req: `{alias}`
- `POST /api/v1/nodes/{node_id}/ip`
- req: `{virtual_ip}`
- `POST /api/v1/nodes/{node_id}/kick`
## 5.4 入网与设备凭证
- `POST /api/v1/enroll/create`
- `POST /api/v1/enroll/revoke/{id}`
- `POST /api/v1/enroll/consume`
- req: `{code, hostname}`
- rsp: `{node_id, node_secret, virtual_ip, network_cidr}`
## 5.5 自动化(低显著)
- `GET/POST /api/v1/settings/api-keys`
---
## 6. 前端展示规范
- 节点主显示名:`alias || hostname`
- 次级显示:`node_id`(短)
- 明确区分:
- "控制台会话"session
- "设备凭证"node_secret
- "自动化凭证"api key
- API Key 页面放到“高级设置”折叠区,不作为主流程入口
---
## 7. 迁移计划(分阶段)
### Phase A认证收敛必做
1. `auth/login` 改为返回 session token不再返回 API key
2. 中间件基于 session 解出 `account_id/network_id`
3. 控制台 API 全量改用 session 鉴权
### Phase B节点模型升级
1. 引入 `node_id(UUID)`
2. 增加 `alias` 字段
3. 节点唯一性改以 `node_id` 为准(非 hostname
### Phase CIPAM + 拓扑闭环)
1. 改 IP 重连与广播机制
2. Hub 在线校验 + 自动回 mesh
3. 观测与审计日志完善
---
## 8. 验收标准v1
1. 用户登录后拿到 session token不是 API key
2. 同一网络外的数据不可见
3. 改 IP 后目标节点重连,其他节点收到变更通知
4. hub 下线后自动回 mesh
5. node_id 在重命名 hostname/alias 后仍不变
6. API Key 不影响控制台主流程
---
## 9. 风险与规避
- 风险:旧 token 体系与新 session 并存期可能混淆
- 规避:版本开关 + 明确响应字段(`token_type=session`
- 风险:节点重连窗口导致短时抖动
- 规避:变更前提示 + 逐节点串行生效
- 风险hub 恢复/掉线频繁导致模式抖动
- 规避:加入最小驻留时间(如 60s
---
## 10. 下一步实施顺序(立即执行)
1. 后端:新增 session token 生成/校验(无 refresh
2. 后端:中间件切换到 session 识别 network
3. 前端:登录流程改读 `session_token`
4. 后端:保留 API key 能力但移出主登录流程
5. 回归联调登录、节点、sdwan、connect、enroll、hub 回退

View File

@@ -91,60 +91,39 @@ func main() {
srv := server.New(cfg) srv := server.New(cfg)
srv.StartCleanup() srv.StartCleanup()
// Admin-only Middleware // Admin-only Middleware (master token only)
adminMiddleware := func(next http.HandlerFunc) http.HandlerFunc { adminMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/auth/login" { if r.URL.Path == "/api/v1/auth/login" {
next(w, r) next(w, r)
return return
} }
authHeader := r.Header.Get("Authorization") ac, ok := srv.ResolveAccess(r, cfg.Token)
valid := authHeader == fmt.Sprintf("Bearer %d", cfg.Token) if !ok || ac.Kind != "master" {
if !valid {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`) fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return return
} }
// RBAC: admin only
if srv.Store() != nil {
if u, err := srv.Store().GetUserByToken(server.BearerToken(r)); err == nil && u != nil {
if u.Status != 1 || u.Role != "admin" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
return
}
}
}
next(w, r) next(w, r)
} }
} }
// Tenant or Admin Middleware // Tenant or Admin Middleware (session/apikey/master)
tenantMiddleware := func(next http.HandlerFunc) http.HandlerFunc { tenantMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/auth/login" { if r.URL.Path == "/api/v1/auth/login" {
next(w, r) next(w, r)
return return
} }
authHeader := r.Header.Get("Authorization") ac, ok := srv.ResolveAccess(r, cfg.Token)
if authHeader == fmt.Sprintf("Bearer %d", cfg.Token) { if !ok {
next(w, r)
return
}
// check API key + RBAC
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
// role check if user exists
if u, err := srv.Store().GetUserByToken(server.BearerToken(r)); err == nil && u != nil {
if u.Status != 1 {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`) fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return return
} }
if u.Role == "operator" { if ac.Kind == "session" && ac.Role == "operator" {
path := r.URL.Path path := r.URL.Path
if path != "/api/v1/nodes" && path != "/api/v1/sdwans" && path != "/api/v1/sdwan/edit" && path != "/api/v1/connect" && path != "/api/v1/nodes/apps" && path != "/api/v1/nodes/kick" && path != "/api/v1/stats" && path != "/api/v1/health" { if path != "/api/v1/nodes" && path != "/api/v1/sdwans" && path != "/api/v1/sdwan/edit" && path != "/api/v1/connect" && path != "/api/v1/nodes/apps" && path != "/api/v1/nodes/kick" && path != "/api/v1/stats" && path != "/api/v1/health" {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -153,16 +132,19 @@ func main() {
return return
} }
} }
}
next(w, r) next(w, r)
return
} }
} }
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized) getTenantID := func(r *http.Request) int64 {
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`) tok := server.BearerToken(r)
return if tok == "" {
return 0
} }
if ac, ok := srv.ResolveTenantAccessToken(tok); ok && ac.Kind != "master" {
return ac.TenantID
}
return 0
} }
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -201,7 +183,7 @@ func main() {
_ = json.Unmarshal(body, &reqTok) _ = json.Unmarshal(body, &reqTok)
_ = json.Unmarshal(body, &reqUser) _ = json.Unmarshal(body, &reqUser)
// --- user login --- // --- user login (session token) ---
if reqUser.TenantID > 0 && reqUser.Username != "" && reqUser.Password != "" { if reqUser.TenantID > 0 && reqUser.Username != "" && reqUser.Password != "" {
if srv.Store() == nil { if srv.Store() == nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -216,12 +198,11 @@ func main() {
fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`) fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`)
return return
} }
// issue API key for this tenant and return subnet sessionToken, exp, err := srv.Store().CreateSessionToken(u.ID, reqUser.TenantID, u.Role, 24*time.Hour)
key, err := srv.Store().CreateAPIKey(reqUser.TenantID, "all", 0)
if err != nil { if err != nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":1,"message":"create token failed"}`) fmt.Fprintf(w, `{"error":1,"message":"create session failed"}`)
return return
} }
ten, _ := srv.Store().GetTenantByID(reqUser.TenantID) ten, _ := srv.Store().GetTenantByID(reqUser.TenantID)
@@ -229,10 +210,12 @@ func main() {
Error int `json:"error"` Error int `json:"error"`
Message string `json:"message"` Message string `json:"message"`
Token string `json:"token"` Token string `json:"token"`
TokenType string `json:"token_type"`
ExpiresAt int64 `json:"expires_at"`
Role string `json:"role"` Role string `json:"role"`
Status int `json:"status"` Status int `json:"status"`
Subnet string `json:"subnet"` Subnet string `json:"subnet"`
}{0, "ok", key, u.Role, u.Status, ""} }{0, "ok", sessionToken, "session", exp, u.Role, u.Status, ""}
if ten != nil { if ten != nil {
resp.Subnet = ten.Subnet resp.Subnet = ten.Subnet
} }
@@ -289,13 +272,8 @@ func main() {
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
// tenant filter by API key // tenant filter by session/apikey
tenantID := int64(0) tenantID := getTenantID(r)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
if tenantID > 0 { if tenantID > 0 {
nodes := srv.GetOnlineNodesByTenant(tenantID) nodes := srv.GetOnlineNodesByTenant(tenantID)
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": nodes}) _ = json.NewEncoder(w).Encode(map[string]any{"nodes": nodes})
@@ -311,13 +289,8 @@ func main() {
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
// tenant filter by API key // tenant filter by session/apikey
tenantID := int64(0) tenantID := getTenantID(r)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
if tenantID > 0 { if tenantID > 0 {
_ = json.NewEncoder(w).Encode(srv.GetSDWANTenant(tenantID)) _ = json.NewEncoder(w).Encode(srv.GetSDWANTenant(tenantID))
return return
@@ -335,13 +308,8 @@ func main() {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
// tenant filter by API key // tenant filter by session/apikey
tenantID := int64(0) tenantID := getTenantID(r)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
if tenantID > 0 { if tenantID > 0 {
if err := srv.SetSDWANTenant(tenantID, req); err != nil { if err := srv.SetSDWANTenant(tenantID, req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -378,13 +346,8 @@ func main() {
http.Error(w, "node not found", http.StatusNotFound) http.Error(w, "node not found", http.StatusNotFound)
return return
} }
// tenant filter by API key // tenant filter by session/apikey
tenantID := int64(0) tenantID := getTenantID(r)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
if tenantID > 0 && node.TenantID != tenantID { if tenantID > 0 && node.TenantID != tenantID {
http.Error(w, "node not found", http.StatusNotFound) http.Error(w, "node not found", http.StatusNotFound)
return return
@@ -413,13 +376,8 @@ func main() {
http.Error(w, "node not found or offline", http.StatusNotFound) http.Error(w, "node not found or offline", http.StatusNotFound)
return return
} }
// tenant filter by API key // tenant filter by session/apikey
tenantID := int64(0) tenantID := getTenantID(r)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
if tenantID > 0 && node.TenantID != tenantID { if tenantID > 0 && node.TenantID != tenantID {
http.Error(w, "node not found", http.StatusNotFound) http.Error(w, "node not found", http.StatusNotFound)
return return
@@ -451,13 +409,8 @@ func main() {
http.Error(w, "source node offline", http.StatusNotFound) http.Error(w, "source node offline", http.StatusNotFound)
return return
} }
// tenant filter by API key // tenant filter by session/apikey
tenantID := int64(0) tenantID := getTenantID(r)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
if tenantID > 0 && fromNode.TenantID != tenantID { if tenantID > 0 && fromNode.TenantID != tenantID {
http.Error(w, "node not found", http.StatusNotFound) http.Error(w, "node not found", http.StatusNotFound)
return return

54
internal/server/authz.go Normal file
View File

@@ -0,0 +1,54 @@
package server
import (
"net/http"
"strconv"
)
type AccessContext struct {
Kind string
TenantID int64
UserID int64
Role string
Token string
}
func (s *Server) ResolveAccess(r *http.Request, masterToken uint64) (*AccessContext, bool) {
tok := BearerToken(r)
if tok == "" {
return nil, false
}
if tok == strconv.FormatUint(masterToken, 10) {
return &AccessContext{Kind: "master", Role: "admin", Token: tok}, true
}
return s.ResolveTenantAccessToken(tok)
}
func (s *Server) ResolveTenantAccessToken(tok string) (*AccessContext, bool) {
if tok == "" || s.store == nil {
return nil, false
}
if ss, err := s.store.VerifySessionToken(tok); err == nil && ss != nil {
return &AccessContext{
Kind: "session",
TenantID: ss.TenantID,
UserID: ss.UserID,
Role: ss.Role,
Token: tok,
}, true
}
if ten, err := s.store.VerifyAPIKey(tok); err == nil && ten != nil {
return &AccessContext{
Kind: "apikey",
TenantID: ten.ID,
Role: "apikey",
Token: tok,
}, true
}
return nil, false
}

View File

@@ -34,12 +34,47 @@ func writeJSON(w http.ResponseWriter, status int, body string) {
} }
func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request) { func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
tenants, err := s.store.ListTenants()
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list tenants failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Tenants []store.Tenant `json:"tenants"`
}{0, "ok", tenants}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
return
}
// update tenant status via /api/v1/admin/tenants/{id}?status=0|1
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/admin/tenants/") {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) >= 4 {
var id int64
_, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &id)
st := r.URL.Query().Get("status")
if id > 0 && st != "" {
status := 0
if st == "1" {
status = 1
}
_ = s.store.UpdateTenantStatus(id, status)
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
}
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`) writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return return
} }
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
AdminPassword string `json:"admin_password"`
OperatorPassword string `json:"operator_password"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`) writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
@@ -49,7 +84,15 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`) writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return return
} }
ten, err := s.store.CreateTenant(req.Name) var ten *store.Tenant
var admin *store.User
var op *store.User
var err error
if req.AdminPassword != "" && req.OperatorPassword != "" {
ten, admin, op, err = s.store.CreateTenantWithUsers(req.Name, req.AdminPassword, req.OperatorPassword)
} else {
ten, err = s.store.CreateTenant(req.Name)
}
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create tenant failed"}`) writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create tenant failed"}`)
return return
@@ -59,13 +102,21 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request)
Message string `json:"message"` Message string `json:"message"`
Tenant int64 `json:"tenant_id"` Tenant int64 `json:"tenant_id"`
Subnet string `json:"subnet"` Subnet string `json:"subnet"`
}{0, "ok", ten.ID, ten.Subnet} AdminUser string `json:"admin_user"`
OperatorUser string `json:"operator_user"`
}{0, "ok", ten.ID, ten.Subnet, "", ""}
if admin != nil {
resp.AdminUser = admin.Email
}
if op != nil {
resp.OperatorUser = op.Email
}
b, _ := json.Marshal(resp) b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b)) writeJSON(w, http.StatusOK, string(b))
} }
func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) { func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost && r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`) writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return return
} }
@@ -87,6 +138,37 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`) writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return return
} }
if r.Method == http.MethodGet {
keys, err := s.store.ListAPIKeys(tenantID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list keys failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Keys []store.APIKey `json:"keys"`
}{0, "ok", keys}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
return
}
// update key status via /api/v1/admin/tenants/{id}/keys/{keyId}?status=0|1
if strings.Contains(r.URL.Path, "/keys/") {
parts2 := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
var keyID int64
_, _ = fmt.Sscanf(parts2[len(parts2)-1], "%d", &keyID)
st := r.URL.Query().Get("status")
if keyID > 0 && st != "" {
status := 0
if st == "1" {
status = 1
}
_ = s.store.UpdateAPIKeyStatus(keyID, status)
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
}
var req struct { var req struct {
Scope string `json:"scope"` Scope string `json:"scope"`
TTL int64 `json:"ttl"` // seconds TTL int64 `json:"ttl"` // seconds
@@ -112,21 +194,41 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request)
} }
func (s *Server) HandleTenantEnroll(w http.ResponseWriter, r *http.Request) { func (s *Server) HandleTenantEnroll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost && r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`) writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return return
} }
// tenant auth by API key // tenant auth by session/apikey
if s.store == nil { if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`) writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return return
} }
tok := BearerToken(r) tok := BearerToken(r)
ten, err := s.store.VerifyAPIKey(tok) ac, ok := s.ResolveTenantAccessToken(tok)
if err != nil || ten == nil { if !ok || ac.TenantID <= 0 {
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`) writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`)
return return
} }
ten, err := s.store.GetTenantByID(ac.TenantID)
if err != nil || ten == nil || ten.Status != 1 {
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`)
return
}
if r.Method == http.MethodGet {
tokens, err := s.store.ListEnrollTokens(ten.ID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list enroll failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Enrolls []store.EnrollToken `json:"enrolls"`
}{0, "ok", tokens}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
return
}
code, err := s.store.CreateEnrollToken(ten.ID, 10*time.Minute, 5) code, err := s.store.CreateEnrollToken(ten.ID, 10*time.Minute, 5)
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create enroll failed"}`) writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create enroll failed"}`)
@@ -147,6 +249,24 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`) writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return return
} }
// revoke support: /api/v1/enroll/consume/{id}?status=0
if strings.Contains(r.URL.Path, "/enroll/consume/") {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) >= 4 {
var id int64
_, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &id)
st := r.URL.Query().Get("status")
if id > 0 && st != "" {
status := 0
if st == "1" {
status = 1
}
_ = s.store.UpdateEnrollStatus(id, status)
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
}
}
var req struct { var req struct {
Code string `json:"code"` Code string `json:"code"`
NodeName string `json:"node"` NodeName string `json:"node"`
@@ -176,7 +296,8 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
NodeID int64 `json:"node_id"` NodeID int64 `json:"node_id"`
Secret string `json:"node_secret"` Secret string `json:"node_secret"`
Tenant int64 `json:"tenant_id"` Tenant int64 `json:"tenant_id"`
}{0, "ok", cred.NodeID, cred.Secret, cred.TenantID} CreatedAt int64 `json:"created_at"`
}{0, "ok", cred.NodeID, cred.Secret, cred.TenantID, cred.CreatedAt}
b, _ := json.Marshal(resp) b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b)) writeJSON(w, http.StatusOK, string(b))
} }

View File

@@ -10,6 +10,7 @@ import (
"net" "net"
"time" "time"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -22,6 +23,17 @@ type Tenant struct {
Name string Name string
Status int Status int
Subnet string Subnet string
CreatedAt int64
}
type User struct {
ID int64
TenantID int64
Role string
Email string
PasswordHash string
Status int
CreatedAt int64
} }
type APIKey struct { type APIKey struct {
@@ -29,8 +41,10 @@ type APIKey struct {
TenantID int64 TenantID int64
Hash string Hash string
Scope string Scope string
Expires *time.Time ExpiresAt *int64
Status int Status int
CreatedAt int64
Plain string
} }
type NodeCredential struct { type NodeCredential struct {
@@ -39,6 +53,9 @@ type NodeCredential struct {
Secret string Secret string
VirtualIP string VirtualIP string
TenantID int64 TenantID int64
Status int
CreatedAt int64
LastSeen *int64
} }
type EnrollToken struct { type EnrollToken struct {
@@ -50,6 +67,18 @@ type EnrollToken struct {
MaxAttempt int MaxAttempt int
Attempts int Attempts int
Status int Status int
CreatedAt int64
}
type SessionToken struct {
ID int64
UserID int64
TenantID int64
Role string
TokenHash string
ExpiresAt int64
Status int
CreatedAt int64
} }
func Open(dbPath string) (*Store, error) { func Open(dbPath string) (*Store, error) {
@@ -111,6 +140,7 @@ func (s *Store) migrate() error {
virtual_ip TEXT, virtual_ip TEXT,
status INTEGER NOT NULL DEFAULT 1, status INTEGER NOT NULL DEFAULT 1,
last_seen INTEGER, last_seen INTEGER,
created_at INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(tenant_id) REFERENCES tenants(id) FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`, );`,
`CREATE TABLE IF NOT EXISTS enroll_tokens ( `CREATE TABLE IF NOT EXISTS enroll_tokens (
@@ -154,6 +184,18 @@ func (s *Store) migrate() error {
tenant_id INTEGER, tenant_id INTEGER,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
);`, );`,
`CREATE TABLE IF NOT EXISTS session_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
tenant_id INTEGER NOT NULL,
role TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`,
} }
for _, stmt := range stmts { for _, stmt := range stmts {
if _, err := s.DB.Exec(stmt); err != nil { if _, err := s.DB.Exec(stmt); err != nil {
@@ -215,25 +257,45 @@ func (s *Store) CreateTenant(name string) (*Tenant, error) {
} }
id, _ := res.LastInsertId() id, _ := res.LastInsertId()
_, _ = s.DB.Exec(`UPDATE subnet_pool SET status=1, tenant_id=?, updated_at=? WHERE subnet=?`, id, now, sn) _, _ = s.DB.Exec(`UPDATE subnet_pool SET status=1, tenant_id=?, updated_at=? WHERE subnet=?`, id, now, sn)
return &Tenant{ID: id, Name: name, Status: 1, Subnet: sn}, nil return &Tenant{ID: id, Name: name, Status: 1, Subnet: sn, CreatedAt: now}, nil
}
func (s *Store) CreateTenantWithUsers(name, adminPassword, operatorPassword string) (*Tenant, *User, *User, error) {
if adminPassword == "" || operatorPassword == "" {
return nil, nil, nil, errors.New("password required")
}
ten, err := s.CreateTenant(name)
if err != nil {
return nil, nil, nil, err
}
admin, err := s.CreateUser(ten.ID, "admin", "admin@local", adminPassword, 1)
if err != nil {
return nil, nil, nil, err
}
op, err := s.CreateUser(ten.ID, "operator", "operator@local", operatorPassword, 1)
if err != nil {
return nil, nil, nil, err
}
return ten, admin, op, nil
} }
func (s *Store) CreateNodeCredential(tenantID int64, nodeName string) (*NodeCredential, error) { func (s *Store) CreateNodeCredential(tenantID int64, nodeName string) (*NodeCredential, error) {
secret := randToken() secret := randToken()
h := hashTokenString(secret) h := hashTokenString(secret)
res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_name,node_secret_hash,status) VALUES(?,?,?,1)`, tenantID, nodeName, h) now := time.Now().Unix()
res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_name,node_secret_hash,status,last_seen,created_at) VALUES(?,?,?,1,?,?)`, tenantID, nodeName, h, now, now)
if err != nil { if err != nil {
return nil, err return nil, err
} }
id, _ := res.LastInsertId() id, _ := res.LastInsertId()
return &NodeCredential{NodeID: id, NodeName: nodeName, Secret: secret, TenantID: tenantID}, nil return &NodeCredential{NodeID: id, NodeName: nodeName, Secret: secret, TenantID: tenantID, Status: 1, CreatedAt: now, LastSeen: &now}, nil
} }
func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) { func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) {
h := hashTokenString(secret) h := hashTokenString(secret)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM nodes n JOIN tenants t ON n.tenant_id=t.id WHERE n.node_name=? AND n.node_secret_hash=? AND n.status=1`, nodeName, h) row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM nodes n JOIN tenants t ON n.tenant_id=t.id WHERE n.node_name=? AND n.node_secret_hash=? AND n.status=1 AND t.status=1`, nodeName, h)
var t Tenant var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil { if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
return nil, err return nil, err
} }
return &t, nil return &t, nil
@@ -241,9 +303,18 @@ func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) {
func (s *Store) GetTenantByToken(token uint64) (*Tenant, error) { func (s *Store) GetTenantByToken(token uint64) (*Tenant, error) {
h := hashToken(token) h := hashToken(token)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1`, h) row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1 AND t.status=1`, h)
var t Tenant var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil { if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) GetTenantByID(id int64) (*Tenant, error) {
row := s.DB.QueryRow(`SELECT id,name,status,subnet,created_at FROM tenants WHERE id=?`, id)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
return nil, err return nil, err
} }
return &t, nil return &t, nil
@@ -274,10 +345,10 @@ func (s *Store) CreateEnrollToken(tenantID int64, ttl time.Duration, maxAttempt
func (s *Store) ConsumeEnrollToken(code string) (*EnrollToken, error) { func (s *Store) ConsumeEnrollToken(code string) (*EnrollToken, error) {
h := hashTokenString(code) h := hashTokenString(code)
now := time.Now().Unix() now := time.Now().Unix()
row := s.DB.QueryRow(`SELECT id,tenant_id,expires_at,used_at,max_attempt,attempts,status FROM enroll_tokens WHERE token_hash=?`, h) row := s.DB.QueryRow(`SELECT id,tenant_id,expires_at,used_at,max_attempt,attempts,status,created_at FROM enroll_tokens WHERE token_hash=?`, h)
var et EnrollToken var et EnrollToken
var used sql.NullInt64 var used sql.NullInt64
if err := row.Scan(&et.ID, &et.TenantID, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status); err != nil { if err := row.Scan(&et.ID, &et.TenantID, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status, &et.CreatedAt); err != nil {
return nil, err return nil, err
} }
if used.Valid { if used.Valid {
@@ -306,6 +377,97 @@ func (s *Store) IncEnrollAttempt(code string) {
_, _ = s.DB.Exec(`UPDATE enroll_tokens SET attempts=attempts+1 WHERE token_hash=?`, h) _, _ = s.DB.Exec(`UPDATE enroll_tokens SET attempts=attempts+1 WHERE token_hash=?`, h)
} }
// ListTenants returns all tenants (admin)
func (s *Store) ListTenants() ([]Tenant, error) {
rows, err := s.DB.Query(`SELECT id,name,status,subnet,created_at FROM tenants ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Tenant
for rows.Next() {
var t Tenant
if err := rows.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err == nil {
out = append(out, t)
}
}
return out, nil
}
func (s *Store) UpdateTenantStatus(id int64, status int) error {
_, err := s.DB.Exec(`UPDATE tenants SET status=? WHERE id=?`, status, id)
return err
}
func (s *Store) DeleteTenant(id int64) error {
_, err := s.DB.Exec(`DELETE FROM tenants WHERE id=?`, id)
return err
}
// ListAPIKeys returns api keys of a tenant (admin)
func (s *Store) ListAPIKeys(tenantID int64) ([]APIKey, error) {
rows, err := s.DB.Query(`SELECT id,tenant_id,key_hash,scope,expires_at,status,created_at FROM api_keys WHERE tenant_id=? ORDER BY id DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []APIKey
for rows.Next() {
var k APIKey
var exp sql.NullInt64
if err := rows.Scan(&k.ID, &k.TenantID, &k.Hash, &k.Scope, &exp, &k.Status, &k.CreatedAt); err == nil {
if exp.Valid {
v := exp.Int64
k.ExpiresAt = &v
}
out = append(out, k)
}
}
return out, nil
}
func (s *Store) UpdateAPIKeyStatus(id int64, status int) error {
_, err := s.DB.Exec(`UPDATE api_keys SET status=? WHERE id=?`, status, id)
return err
}
func (s *Store) DeleteAPIKey(id int64) error {
_, err := s.DB.Exec(`DELETE FROM api_keys WHERE id=?`, id)
return err
}
// ListEnrollTokens returns enroll tokens for a tenant (admin)
func (s *Store) ListEnrollTokens(tenantID int64) ([]EnrollToken, error) {
rows, err := s.DB.Query(`SELECT id,tenant_id,token_hash,expires_at,used_at,max_attempt,attempts,status,created_at FROM enroll_tokens WHERE tenant_id=? ORDER BY id DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []EnrollToken
for rows.Next() {
var et EnrollToken
var used sql.NullInt64
if err := rows.Scan(&et.ID, &et.TenantID, &et.Hash, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status, &et.CreatedAt); err == nil {
if used.Valid {
v := used.Int64
et.UsedAt = &v
}
out = append(out, et)
}
}
return out, nil
}
func (s *Store) UpdateEnrollStatus(id int64, status int) error {
_, err := s.DB.Exec(`UPDATE enroll_tokens SET status=? WHERE id=?`, status, id)
return err
}
func (s *Store) DeleteEnrollToken(id int64) error {
_, err := s.DB.Exec(`DELETE FROM enroll_tokens WHERE id=?`, id)
return err
}
func hashToken(token uint64) string { func hashToken(token uint64) string {
b := make([]byte, 8) b := make([]byte, 8)
for i := uint(0); i < 8; i++ { for i := uint(0); i < 8; i++ {
@@ -320,14 +482,149 @@ func hashTokenString(token string) string {
func (s *Store) VerifyAPIKey(token string) (*Tenant, error) { func (s *Store) VerifyAPIKey(token string) (*Tenant, error) {
h := hashTokenString(token) h := hashTokenString(token)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1`, h) row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1 AND t.status=1`, h)
var t Tenant var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil { if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
return nil, err return nil, err
} }
return &t, nil return &t, nil
} }
func (s *Store) GetUserByTenant(tenantID int64) (*User, error) {
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? ORDER BY id LIMIT 1`, tenantID)
var u User
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) GetUserByToken(token string) (*User, error) {
h := hashTokenString(token)
row := s.DB.QueryRow(`SELECT u.id,u.tenant_id,u.role,u.email,u.password_hash,u.status,u.created_at FROM api_keys k JOIN users u ON k.tenant_id=u.tenant_id WHERE k.key_hash=? AND k.status=1`, h)
var u User
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) GetUserByEmail(tenantID int64, email string) (*User, error) {
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? AND email=? ORDER BY id LIMIT 1`, tenantID, email)
var u User
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) CreateUser(tenantID int64, role, email, password string, status int) (*User, error) {
now := time.Now().Unix()
var hash string
if password != "" {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
hash = string(b)
}
res, err := s.DB.Exec(`INSERT INTO users(tenant_id,role,email,password_hash,status,created_at) VALUES(?,?,?,?,?,?)`, tenantID, role, email, hash, status, now)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &User{ID: id, TenantID: tenantID, Role: role, Email: email, PasswordHash: hash, Status: status, CreatedAt: now}, nil
}
func (s *Store) UserEmailExists(tenantID int64, email string) (bool, error) {
row := s.DB.QueryRow(`SELECT COUNT(1) FROM users WHERE tenant_id=? AND email=?`, tenantID, email)
var c int
if err := row.Scan(&c); err != nil {
return false, err
}
return c > 0, nil
}
func (s *Store) VerifyUserPassword(tenantID int64, email, password string) (*User, error) {
u, err := s.GetUserByEmail(tenantID, email)
if err != nil {
// compatibility: allow login by role name (admin/operator)
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? AND role=? ORDER BY id LIMIT 1`, tenantID, email)
var byRole User
if scanErr := row.Scan(&byRole.ID, &byRole.TenantID, &byRole.Role, &byRole.Email, &byRole.PasswordHash, &byRole.Status, &byRole.CreatedAt); scanErr != nil {
return nil, err
}
u = &byRole
}
if u.PasswordHash == "" {
return nil, errors.New("password not set")
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid password")
}
return u, nil
}
func (s *Store) CreateSessionToken(userID, tenantID int64, role string, ttl time.Duration) (string, int64, error) {
tok := randToken()
h := hashTokenString(tok)
now := time.Now().Unix()
exp := time.Now().Add(ttl).Unix()
_, err := s.DB.Exec(`INSERT INTO session_tokens(user_id,tenant_id,role,token_hash,expires_at,status,created_at) VALUES(?,?,?,?,?,1,?)`, userID, tenantID, role, h, exp, now)
if err != nil {
return "", 0, err
}
return tok, exp, nil
}
func (s *Store) VerifySessionToken(token string) (*SessionToken, error) {
h := hashTokenString(token)
now := time.Now().Unix()
row := s.DB.QueryRow(`SELECT id,user_id,tenant_id,role,token_hash,expires_at,status,created_at FROM session_tokens WHERE token_hash=? AND status=1 AND expires_at>?`, h, now)
var st SessionToken
if err := row.Scan(&st.ID, &st.UserID, &st.TenantID, &st.Role, &st.TokenHash, &st.ExpiresAt, &st.Status, &st.CreatedAt); err != nil {
return nil, err
}
return &st, nil
}
func (s *Store) RevokeSessionToken(token string) error {
h := hashTokenString(token)
_, err := s.DB.Exec(`UPDATE session_tokens SET status=0 WHERE token_hash=?`, h)
return err
}
func (s *Store) ListUsers(tenantID int64) ([]User, error) {
rows, err := s.DB.Query(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? ORDER BY id`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []User{}
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
out = append(out, u)
}
return out, nil
}
func (s *Store) UpdateUserStatus(id int64, status int) error {
_, err := s.DB.Exec(`UPDATE users SET status=? WHERE id=?`, status, id)
return err
}
func (s *Store) UpdateUserPassword(id int64, password string) error {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.DB.Exec(`UPDATE users SET password_hash=? WHERE id=?`, string(b), id)
return err
}
func hashTokenBytes(b []byte) string { func hashTokenBytes(b []byte) string {
h := sha256.Sum256(b) h := sha256.Sum256(b)
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])

View File

@@ -287,7 +287,8 @@ createApp({
const keyForm = ref({ scope:'all', ttl:0 }); const keyForm = ref({ scope:'all', ttl:0 });
const userForm = ref({ role:'operator', email:'', password:'' }); const userForm = ref({ role:'operator', email:'', password:'' });
const isAdmin = computed(() => role.value === 'admin' && localStorage.getItem('t') === localStorage.getItem('master_t')); const tokenType = ref('');
const isAdmin = computed(() => role.value === 'admin' && tokenType.value !== 'session');
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll'].includes(t.id))); const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll'].includes(t.id)));
const filteredNodes = computed(() => { const filteredNodes = computed(() => {
const k = (nodeKeyword.value || '').trim().toLowerCase(); const k = (nodeKeyword.value || '').trim().toLowerCase();
@@ -340,6 +341,7 @@ createApp({
localStorage.setItem('t', d.token || ''); localStorage.setItem('t', d.token || '');
role.value = d.role || ''; role.value = d.role || '';
status.value = d.status ?? 1; status.value = d.status ?? 1;
tokenType.value = d.token_type || (localStorage.getItem('t') === localStorage.getItem('master_t') ? 'master' : 'apikey');
if (status.value !== 1) throw new Error('账号已停用'); if (status.value !== 1) throw new Error('账号已停用');
loggedIn.value = true; loggedIn.value = true;
await refreshAll(); await refreshAll();
@@ -356,6 +358,7 @@ createApp({
localStorage.removeItem('master_t'); localStorage.removeItem('master_t');
loggedIn.value = false; loggedIn.value = false;
role.value = ''; role.value = '';
tokenType.value = '';
stopTimer(); stopTimer();
}; };
@@ -537,7 +540,7 @@ createApp({
}); });
return { return {
buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status, buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status, tokenType,
loginTenant, loginUser, loginPass, loginToken, loginErr, refreshSec, loginTenant, loginUser, loginPass, loginToken, loginErr, refreshSec,
health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm, health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm,
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm, tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,