auth: switch user login to session token and decouple tenant access
This commit is contained in:
220
ARCH_V1_USER_NETWORK.md
Normal file
220
ARCH_V1_USER_NETWORK.md
Normal 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 C(IPAM + 拓扑闭环)
|
||||
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 回退
|
||||
@@ -91,80 +91,62 @@ func main() {
|
||||
srv := server.New(cfg)
|
||||
srv.StartCleanup()
|
||||
|
||||
// Admin-only Middleware
|
||||
// Admin-only Middleware (master token only)
|
||||
adminMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/auth/login" {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
valid := authHeader == fmt.Sprintf("Bearer %d", cfg.Token)
|
||||
if !valid {
|
||||
ac, ok := srv.ResolveAccess(r, cfg.Token)
|
||||
if !ok || ac.Kind != "master" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant or Admin Middleware
|
||||
// Tenant or Admin Middleware (session/apikey/master)
|
||||
tenantMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/auth/login" {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == fmt.Sprintf("Bearer %d", cfg.Token) {
|
||||
next(w, r)
|
||||
ac, ok := srv.ResolveAccess(r, cfg.Token)
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
|
||||
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.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
|
||||
return
|
||||
}
|
||||
if u.Role == "operator" {
|
||||
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" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
if ac.Kind == "session" && ac.Role == "operator" {
|
||||
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" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
|
||||
return
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
getTenantID := func(r *http.Request) int64 {
|
||||
tok := server.BearerToken(r)
|
||||
if tok == "" {
|
||||
return 0
|
||||
}
|
||||
if ac, ok := srv.ResolveTenantAccessToken(tok); ok && ac.Kind != "master" {
|
||||
return ac.TenantID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ws", srv.HandleWS)
|
||||
|
||||
@@ -201,7 +183,7 @@ func main() {
|
||||
_ = json.Unmarshal(body, &reqTok)
|
||||
_ = json.Unmarshal(body, &reqUser)
|
||||
|
||||
// --- user login ---
|
||||
// --- user login (session token) ---
|
||||
if reqUser.TenantID > 0 && reqUser.Username != "" && reqUser.Password != "" {
|
||||
if srv.Store() == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -216,23 +198,24 @@ func main() {
|
||||
fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`)
|
||||
return
|
||||
}
|
||||
// issue API key for this tenant and return subnet
|
||||
key, err := srv.Store().CreateAPIKey(reqUser.TenantID, "all", 0)
|
||||
sessionToken, exp, err := srv.Store().CreateSessionToken(u.ID, reqUser.TenantID, u.Role, 24*time.Hour)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, `{"error":1,"message":"create token failed"}`)
|
||||
fmt.Fprintf(w, `{"error":1,"message":"create session failed"}`)
|
||||
return
|
||||
}
|
||||
ten, _ := srv.Store().GetTenantByID(reqUser.TenantID)
|
||||
resp := struct {
|
||||
Error int `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Token string `json:"token"`
|
||||
Role string `json:"role"`
|
||||
Status int `json:"status"`
|
||||
Subnet string `json:"subnet"`
|
||||
}{0, "ok", key, u.Role, u.Status, ""}
|
||||
Error int `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Token string `json:"token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Role string `json:"role"`
|
||||
Status int `json:"status"`
|
||||
Subnet string `json:"subnet"`
|
||||
}{0, "ok", sessionToken, "session", exp, u.Role, u.Status, ""}
|
||||
if ten != nil {
|
||||
resp.Subnet = ten.Subnet
|
||||
}
|
||||
@@ -289,13 +272,8 @@ func main() {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// tenant filter by API key
|
||||
tenantID := int64(0)
|
||||
if srv.Store() != nil {
|
||||
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
|
||||
tenantID = ten.ID
|
||||
}
|
||||
}
|
||||
// tenant filter by session/apikey
|
||||
tenantID := getTenantID(r)
|
||||
if tenantID > 0 {
|
||||
nodes := srv.GetOnlineNodesByTenant(tenantID)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": nodes})
|
||||
@@ -311,13 +289,8 @@ func main() {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// tenant filter by API key
|
||||
tenantID := int64(0)
|
||||
if srv.Store() != nil {
|
||||
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
|
||||
tenantID = ten.ID
|
||||
}
|
||||
}
|
||||
// tenant filter by session/apikey
|
||||
tenantID := getTenantID(r)
|
||||
if tenantID > 0 {
|
||||
_ = json.NewEncoder(w).Encode(srv.GetSDWANTenant(tenantID))
|
||||
return
|
||||
@@ -335,13 +308,8 @@ func main() {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// tenant filter by API key
|
||||
tenantID := int64(0)
|
||||
if srv.Store() != nil {
|
||||
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
|
||||
tenantID = ten.ID
|
||||
}
|
||||
}
|
||||
// tenant filter by session/apikey
|
||||
tenantID := getTenantID(r)
|
||||
if tenantID > 0 {
|
||||
if err := srv.SetSDWANTenant(tenantID, req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -378,13 +346,8 @@ func main() {
|
||||
http.Error(w, "node not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// tenant filter by API key
|
||||
tenantID := int64(0)
|
||||
if srv.Store() != nil {
|
||||
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
|
||||
tenantID = ten.ID
|
||||
}
|
||||
}
|
||||
// tenant filter by session/apikey
|
||||
tenantID := getTenantID(r)
|
||||
if tenantID > 0 && node.TenantID != tenantID {
|
||||
http.Error(w, "node not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -413,13 +376,8 @@ func main() {
|
||||
http.Error(w, "node not found or offline", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// tenant filter by API key
|
||||
tenantID := int64(0)
|
||||
if srv.Store() != nil {
|
||||
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
|
||||
tenantID = ten.ID
|
||||
}
|
||||
}
|
||||
// tenant filter by session/apikey
|
||||
tenantID := getTenantID(r)
|
||||
if tenantID > 0 && node.TenantID != tenantID {
|
||||
http.Error(w, "node not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -451,13 +409,8 @@ func main() {
|
||||
http.Error(w, "source node offline", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// tenant filter by API key
|
||||
tenantID := int64(0)
|
||||
if srv.Store() != nil {
|
||||
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
|
||||
tenantID = ten.ID
|
||||
}
|
||||
}
|
||||
// tenant filter by session/apikey
|
||||
tenantID := getTenantID(r)
|
||||
if tenantID > 0 && fromNode.TenantID != tenantID {
|
||||
http.Error(w, "node not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -529,10 +482,10 @@ func main() {
|
||||
|
||||
// Enable TCP keepalive at server level
|
||||
httpSrv := &http.Server{
|
||||
Handler: mux,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
if err := httpSrv.Serve(ln); err != http.ErrServerClosed {
|
||||
|
||||
54
internal/server/authz.go
Normal file
54
internal/server/authz.go
Normal 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
|
||||
}
|
||||
@@ -34,12 +34,47 @@ func writeJSON(w http.ResponseWriter, status int, body string) {
|
||||
}
|
||||
|
||||
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 {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
|
||||
return
|
||||
}
|
||||
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 == "" {
|
||||
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
|
||||
@@ -49,23 +84,39 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
|
||||
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 {
|
||||
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create tenant failed"}`)
|
||||
return
|
||||
}
|
||||
resp := struct {
|
||||
Error int `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Tenant int64 `json:"tenant_id"`
|
||||
Subnet string `json:"subnet"`
|
||||
}{0, "ok", ten.ID, ten.Subnet}
|
||||
Error int `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Tenant int64 `json:"tenant_id"`
|
||||
Subnet string `json:"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)
|
||||
writeJSON(w, http.StatusOK, string(b))
|
||||
}
|
||||
|
||||
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"}`)
|
||||
return
|
||||
}
|
||||
@@ -87,6 +138,37 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
|
||||
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 {
|
||||
Scope string `json:"scope"`
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
|
||||
return
|
||||
}
|
||||
// tenant auth by API key
|
||||
// tenant auth by session/apikey
|
||||
if s.store == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
|
||||
return
|
||||
}
|
||||
tok := BearerToken(r)
|
||||
ten, err := s.store.VerifyAPIKey(tok)
|
||||
if err != nil || ten == nil {
|
||||
ac, ok := s.ResolveTenantAccessToken(tok)
|
||||
if !ok || ac.TenantID <= 0 {
|
||||
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`)
|
||||
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)
|
||||
if err != nil {
|
||||
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"}`)
|
||||
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 {
|
||||
Code string `json:"code"`
|
||||
NodeName string `json:"node"`
|
||||
@@ -171,12 +291,13 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
resp := struct {
|
||||
Error int `json:"error"`
|
||||
Message string `json:"message"`
|
||||
NodeID int64 `json:"node_id"`
|
||||
Secret string `json:"node_secret"`
|
||||
Tenant int64 `json:"tenant_id"`
|
||||
}{0, "ok", cred.NodeID, cred.Secret, cred.TenantID}
|
||||
Error int `json:"error"`
|
||||
Message string `json:"message"`
|
||||
NodeID int64 `json:"node_id"`
|
||||
Secret string `json:"node_secret"`
|
||||
Tenant int64 `json:"tenant_id"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}{0, "ok", cred.NodeID, cred.Secret, cred.TenantID, cred.CreatedAt}
|
||||
b, _ := json.Marshal(resp)
|
||||
writeJSON(w, http.StatusOK, string(b))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
@@ -18,27 +19,43 @@ type Store struct {
|
||||
}
|
||||
|
||||
type Tenant struct {
|
||||
ID int64
|
||||
Name string
|
||||
Status int
|
||||
Subnet string
|
||||
ID int64
|
||||
Name string
|
||||
Status int
|
||||
Subnet string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
TenantID int64
|
||||
Role string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Status int
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID int64
|
||||
TenantID int64
|
||||
Hash string
|
||||
Scope string
|
||||
Expires *time.Time
|
||||
Status int
|
||||
ID int64
|
||||
TenantID int64
|
||||
Hash string
|
||||
Scope string
|
||||
ExpiresAt *int64
|
||||
Status int
|
||||
CreatedAt int64
|
||||
Plain string
|
||||
}
|
||||
|
||||
type NodeCredential struct {
|
||||
NodeID int64
|
||||
NodeName string
|
||||
Secret string
|
||||
VirtualIP string
|
||||
TenantID int64
|
||||
NodeID int64
|
||||
NodeName string
|
||||
Secret string
|
||||
VirtualIP string
|
||||
TenantID int64
|
||||
Status int
|
||||
CreatedAt int64
|
||||
LastSeen *int64
|
||||
}
|
||||
|
||||
type EnrollToken struct {
|
||||
@@ -50,6 +67,18 @@ type EnrollToken struct {
|
||||
MaxAttempt int
|
||||
Attempts 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) {
|
||||
@@ -111,6 +140,7 @@ func (s *Store) migrate() error {
|
||||
virtual_ip TEXT,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
last_seen INTEGER,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS enroll_tokens (
|
||||
@@ -154,6 +184,18 @@ func (s *Store) migrate() error {
|
||||
tenant_id INTEGER,
|
||||
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 {
|
||||
if _, err := s.DB.Exec(stmt); err != nil {
|
||||
@@ -215,25 +257,45 @@ func (s *Store) CreateTenant(name string) (*Tenant, error) {
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
_, _ = 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) {
|
||||
secret := randToken()
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
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) {
|
||||
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
|
||||
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
|
||||
@@ -241,9 +303,18 @@ func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) {
|
||||
|
||||
func (s *Store) GetTenantByToken(token uint64) (*Tenant, error) {
|
||||
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
|
||||
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 &t, nil
|
||||
@@ -274,10 +345,10 @@ func (s *Store) CreateEnrollToken(tenantID int64, ttl time.Duration, maxAttempt
|
||||
func (s *Store) ConsumeEnrollToken(code string) (*EnrollToken, error) {
|
||||
h := hashTokenString(code)
|
||||
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 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
b := make([]byte, 8)
|
||||
for i := uint(0); i < 8; i++ {
|
||||
@@ -320,14 +482,149 @@ func hashTokenString(token string) string {
|
||||
|
||||
func (s *Store) VerifyAPIKey(token string) (*Tenant, error) {
|
||||
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
|
||||
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) 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 {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
|
||||
@@ -287,7 +287,8 @@ createApp({
|
||||
const keyForm = ref({ scope:'all', ttl:0 });
|
||||
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 filteredNodes = computed(() => {
|
||||
const k = (nodeKeyword.value || '').trim().toLowerCase();
|
||||
@@ -340,6 +341,7 @@ createApp({
|
||||
localStorage.setItem('t', d.token || '');
|
||||
role.value = d.role || '';
|
||||
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('账号已停用');
|
||||
loggedIn.value = true;
|
||||
await refreshAll();
|
||||
@@ -356,6 +358,7 @@ createApp({
|
||||
localStorage.removeItem('master_t');
|
||||
loggedIn.value = false;
|
||||
role.value = '';
|
||||
tokenType.value = '';
|
||||
stopTimer();
|
||||
};
|
||||
|
||||
@@ -537,7 +540,7 @@ createApp({
|
||||
});
|
||||
|
||||
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,
|
||||
health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm,
|
||||
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,
|
||||
|
||||
Reference in New Issue
Block a user