commit bcbf2a8d087cbfd2d9e3788737c8943461781a92 Author: XinJiang1 <1170701029@qq.com> Date: Thu Jan 16 15:24:07 2025 +0800 first commit diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..270f77f --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "log" + "net/http" + + "licensing-cotton/internal/database" + "licensing-cotton/internal/handlers" + "licensing-cotton/internal/security" +) + +func main() { + passphrase := "2718" + + // 1. 初始化数据库 + if err := database.InitDB("mydb.db"); err != nil { + log.Fatalf("数据库初始化失败: %v", err) + } + + // 2. 初始化 ECC 密钥 + if err := security.InitECCKey(); err != nil { + log.Fatalf("生成ECC密钥对失败: %v", err) + } + + // 初始化私钥 + if err := security.InitEd25519Keys(passphrase); err != nil { + log.Fatalf("Failed to initialize keys: %v", err) + } + + // 3. 初始化默认管理员 + if err := handlers.EnsureDefaultAdmin("admin", "admin123"); err != nil { + log.Printf("初始化默认管理员失败: %v", err) + } + + // 4. 注册路由 + mux := handlers.RegisterRoutes() + + // 5. 启动HTTP服务器 + log.Println("服务器启动中,监听 :8080 ...") + if err := http.ListenAndServe("0.0.0.0:8895", mux); err != nil { + log.Fatal("服务器启动失败:", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b34f82a --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module licensing-cotton + +go 1.23 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 + golang.org/x/crypto v0.32.0 + modernc.org/sqlite v1.34.4 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.29.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9783bca --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= +modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..45782de --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,3 @@ +package config + +var AutoRenewAllDevices bool = false diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..981914c --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,67 @@ +package database + +import ( + "database/sql" + "fmt" + "log" + + _ "modernc.org/sqlite" +) + +var DB *sql.DB + +func InitDB(dbFile string) error { + var err error + DB, err = sql.Open("sqlite", dbFile) + if err != nil { + return fmt.Errorf("无法打开数据库: %w", err) + } + + // 初始化表 + if err := createTables(); err != nil { + return err + } + return nil +} + +func createTables() error { + // 创建 users 表 + _, err := DB.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL + )`) + if err != nil { + return err + } + + // 创建 devices 表 + _, err = DB.Exec(` + CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT UNIQUE NOT NULL, + expiration DATETIME NOT NULL + )`) + if err != nil { + return err + } + + // **创建 pending_requests 表** + _, err = DB.Exec(`CREATE TABLE IF NOT EXISTS pending_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + request_time DATETIME NOT NULL, + status TEXT NOT NULL CHECK(status IN ('pending', 'approved', 'rejected')), + approved_by TEXT DEFAULT NULL, + approved_at DATETIME DEFAULT NULL, + expiration DATETIME DEFAULT NULL + );`) + if err != nil { + log.Fatalf("创建 pending_requests 失败: %v", err) + return err + } + + return nil +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..a6ec95e --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,117 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "licensing-cotton/internal/security" + "net/http" + "strings" + + "golang.org/x/crypto/bcrypt" + + "licensing-cotton/internal/database" + "licensing-cotton/internal/models" +) + +func HandleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "只允许POST", http.StatusMethodNotAllowed) + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "请求解析失败", http.StatusBadRequest) + return + } + + // 从数据库查询用户 + var user models.User + err := database.DB.QueryRow(`SELECT id, username, password_hash, role + FROM users WHERE username = ?`, req.Username). + Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Role) + if err == sql.ErrNoRows { + http.Error(w, "用户名或密码错误", http.StatusUnauthorized) + return + } else if err != nil { + http.Error(w, "数据库查询错误", http.StatusInternalServerError) + return + } + + // 验证密码 + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + http.Error(w, "用户名或密码错误", http.StatusUnauthorized) + return + } + + // 生成 session + token, _ := security.GenerateToken(user.Username) // session.go 或本文件里实现 + setSession(token, user.Username) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "登录成功", + "token": token, + "role": user.Role, + }) +} + +// 注册(仅管理员能创建/更新用户) +func HandleRegisterUser(w http.ResponseWriter, r *http.Request) { + if !isAdminRequest(w, r) { + return + } + if r.Method != http.MethodPost { + http.Error(w, "只允许POST", http.StatusMethodNotAllowed) + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` // "admin" or "user" + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "解析请求失败", http.StatusBadRequest) + return + } + + passHash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + + _, err := database.DB.Exec(`INSERT INTO users(username, password_hash, role) VALUES (?,?,?)`, + req.Username, passHash, req.Role) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + http.Error(w, "用户名已存在", http.StatusConflict) + } else { + http.Error(w, "插入数据库失败: "+err.Error(), http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "用户创建成功", + }) +} + +// 初始化默认管理员 +func EnsureDefaultAdmin(username, password string) error { + var count int + err := database.DB.QueryRow(`SELECT COUNT(*) FROM users WHERE username = ?`, username).Scan(&count) + if err != nil { + return err + } + if count == 0 { + passHash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + _, err := database.DB.Exec(`INSERT INTO users(username, password_hash, role) VALUES (?,?,?)`, + username, passHash, "admin") + if err != nil { + return err + } + } + return nil +} diff --git a/internal/handlers/device.go b/internal/handlers/device.go new file mode 100644 index 0000000..a538497 --- /dev/null +++ b/internal/handlers/device.go @@ -0,0 +1,85 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "licensing-cotton/internal/database" + "licensing-cotton/internal/models" +) + +// 创建/更新设备 +func HandleCreateOrUpdateDevice(w http.ResponseWriter, r *http.Request) { + if !isAdminRequest(w, r) { + return + } + + var req struct { + DeviceID string `json:"device_id"` + Expiration string `json:"expiration"` // RFC3339 + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "解析请求失败", http.StatusBadRequest) + return + } + exp, err := time.Parse(time.RFC3339, req.Expiration) + if err != nil { + http.Error(w, "时间格式错误", http.StatusBadRequest) + return + } + + // 先查是否存在 + var id int64 + err = database.DB.QueryRow(`SELECT id FROM devices WHERE device_id = ?`, req.DeviceID). + Scan(&id) + if err != nil { + // 如果设备不存在,插入 + if err.Error() == "sql: no rows in result set" { + _, err = database.DB.Exec(`INSERT INTO devices(device_id, expiration) VALUES (?, ?)`, + req.DeviceID, exp) + if err != nil { + http.Error(w, "创建设备失败", http.StatusInternalServerError) + return + } + fmt.Fprintf(w, "设备 %s 创建成功\n", req.DeviceID) + return + } + http.Error(w, "查询设备失败", http.StatusInternalServerError) + return + } + + // 若已存在,更新 + _, err = database.DB.Exec(`UPDATE devices SET expiration=? WHERE device_id=?`, exp, req.DeviceID) + if err != nil { + http.Error(w, "更新设备失败", http.StatusInternalServerError) + return + } + fmt.Fprintf(w, "设备 %s 已更新到期时间: %v\n", req.DeviceID, exp) +} + +func HandleListDevices(w http.ResponseWriter, r *http.Request) { + if !isAdminRequest(w, r) { + return + } + + rows, err := database.DB.Query(`SELECT id, device_id, expiration FROM devices`) + if err != nil { + http.Error(w, "查询失败", http.StatusInternalServerError) + return + } + defer rows.Close() + + var devices []models.Device + for rows.Next() { + var d models.Device + if err := rows.Scan(&d.ID, &d.DeviceID, &d.Expiration); err != nil { + http.Error(w, "解析记录失败", http.StatusInternalServerError) + return + } + devices = append(devices, d) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(devices) +} diff --git a/internal/handlers/license.go b/internal/handlers/license.go new file mode 100644 index 0000000..887e9a4 --- /dev/null +++ b/internal/handlers/license.go @@ -0,0 +1,158 @@ +package handlers + +import ( + "crypto/ecdsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "math/big" + "net/http" + "time" + + "licensing-cotton/internal/database" + "licensing-cotton/internal/security" +) + +// License 结构 +type License struct { + DeviceID string `json:"device_id"` + Expiration string `json:"expiration"` + SignDate string `json:"sign_date"` + Signature string `json:"signature"` +} + +type LicenseWithoutSign struct { + DeviceID string `json:"device_id"` + Expiration string `json:"expiration"` + SignDate string `json:"sign_date"` +} + +// 签发License (仅管理员) +func HandleSignLicense(w http.ResponseWriter, r *http.Request) { + if !isAdminRequest(w, r) { + return + } + + var req struct { + DeviceID string `json:"device_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "解析请求失败", http.StatusBadRequest) + return + } + + // 查设备 + var expiration time.Time + err := database.DB.QueryRow(`SELECT expiration FROM devices WHERE device_id=?`, req.DeviceID). + Scan(&expiration) + if err != nil { + http.Error(w, "设备不存在", http.StatusNotFound) + return + } + + // 构造 license + lic := License{ + DeviceID: req.DeviceID, + Expiration: expiration.Format(time.RFC3339), + } + // 序列化并哈希 + dataBytes, _ := json.Marshal(lic) + hash := sha256.Sum256(dataBytes) + + // 签名 + priv, _, _ := security.GetKeys() + rSig, sSig, err := ecdsa.Sign(nil, priv, hash[:]) + if err != nil { + http.Error(w, "ECDSA签名失败", http.StatusInternalServerError) + return + } + sigBytes := append(rSig.Bytes(), sSig.Bytes()...) + lic.Signature = base64.StdEncoding.EncodeToString(sigBytes) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(lic) +} + +// 验证License (可供服务端或第三方调用) +func HandleVerifyLicense(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "只允许POST", http.StatusMethodNotAllowed) + return + } + + var lic License + if err := json.NewDecoder(r.Body).Decode(&lic); err != nil { + http.Error(w, "解析License失败", http.StatusBadRequest) + return + } + + sigStr := lic.Signature + lic.Signature = "" + + dataBytes, _ := json.Marshal(lic) + hash := sha256.Sum256(dataBytes) + + sigData, err := base64.StdEncoding.DecodeString(sigStr) + if err != nil { + http.Error(w, "Signature base64解码失败", http.StatusBadRequest) + return + } + + half := len(sigData) / 2 + rSig := new(big.Int).SetBytes(sigData[:half]) + sSig := new(big.Int).SetBytes(sigData[half:]) + + _, pub, _ := security.GetKeys() + if !ecdsa.Verify(pub, hash[:], rSig, sSig) { + http.Error(w, "签名验证失败", http.StatusUnauthorized) + return + } + + // 检查是否过期 + exp, err := time.Parse(time.RFC3339, lic.Expiration) + if err != nil { + http.Error(w, "时间格式错误", http.StatusBadRequest) + return + } + if time.Now().After(exp) { + http.Error(w, "License已过期", http.StatusUnauthorized) + return + } + + // 可进一步检查数据库中的device_id与expiration是否匹配 + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"message": "License有效"}`)) +} + +func SignLicense(deviceID string, expirationTime time.Time) (License, error) { + // 1. 准备待签名数据 + licWithoutSign := LicenseWithoutSign{ + DeviceID: deviceID, + Expiration: expirationTime.Format(time.RFC3339), + SignDate: time.Now().Format(time.RFC3339), + } + + // 2. 序列化为 JSON + licBytes, err := json.Marshal(licWithoutSign) + if err != nil { + return License{}, fmt.Errorf("序列化License失败: %w", err) + } + + // 3. 调用你的 Ed25519Sign 函数签名 + signature, signErr := security.Ed25519Sign(licBytes) // <-- 这里是你已有的签名函数 + if signErr != nil { + return License{}, fmt.Errorf("签名失败: %w", signErr) + } + + // 4. 封装最终 License + lic := License{ + DeviceID: deviceID, + Expiration: licWithoutSign.Expiration, + SignDate: licWithoutSign.SignDate, + Signature: base64.StdEncoding.EncodeToString(signature), + } + log.Println("Signed License for " + deviceID + ":" + expirationTime.Format(time.RFC3339)) + return lic, nil +} diff --git a/internal/handlers/middleware.go b/internal/handlers/middleware.go new file mode 100644 index 0000000..efd3054 --- /dev/null +++ b/internal/handlers/middleware.go @@ -0,0 +1,54 @@ +package handlers + +import ( + "fmt" + "licensing-cotton/internal/security" + "net/http" + "strings" +) + +// 解析 Token 并获取用户角色 +func getUserRoleFromRequest(r *http.Request) (string, bool) { + // 获取 Token (支持 `Authorization` 头) + token := r.Header.Get("Authorization") + if token == "" { + token = r.Header.Get("X-Session-Token") // 兼容旧方式 + } + if token == "" { + token = r.URL.Query().Get("token") // 兼容 URL 方式 + } + if token == "" { + return "", false + } + + // 解析 Bearer Token + if strings.HasPrefix(token, "Bearer ") { + token = strings.TrimPrefix(token, "Bearer ") + } + + // 解析 Token 获取用户名 + username, err := security.ParseToken(token) + if err != nil { + fmt.Println("Token 解析失败:", err) + return "", false + } + + // 查询数据库获取用户角色 + var role string + err = dbQueryRole(username, &role) + if err != nil { + fmt.Println("数据库查询角色失败:", err) + return "", false + } + + return role, true +} + +func isAdminRequest(w http.ResponseWriter, r *http.Request) bool { + role, authed := getUserRoleFromRequest(r) + if !authed || role != "admin" { + http.Error(w, "需要管理员权限", http.StatusForbidden) + return false + } + return true +} diff --git a/internal/handlers/renew.go b/internal/handlers/renew.go new file mode 100644 index 0000000..7908d22 --- /dev/null +++ b/internal/handlers/renew.go @@ -0,0 +1,107 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "fmt" + "licensing-cotton/internal/config" + "net/http" + "time" + + "licensing-cotton/internal/database" + "licensing-cotton/internal/models" +) + +// HandleRenewLicense 让设备只续 1 小时;需满足某些逻辑才能续 +func HandleRenewLicense(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"Only POST allowed","code":405}`, http.StatusMethodNotAllowed) + return + } + + var req struct { + DeviceID string `json:"device_id"` + Expiration string `json:"expiration"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"解析请求失败","code":400}`, http.StatusBadRequest) + return + } + + if config.AutoRenewAllDevices { + // 计算新的到期时间 + newExpiration := time.Now().Add(1 * time.Hour) + + // 更新数据库 expiration + _, errUpdate := database.DB.Exec(`UPDATE devices SET expiration=? WHERE device_id=?`, newExpiration, req.DeviceID) + if errUpdate != nil { + http.Error(w, fmt.Sprintf(`{"error":"更新Expiration失败:%s","code":500}`, errUpdate.Error()), http.StatusInternalServerError) + return + } + + lic, signErr := SignLicense(req.DeviceID, newExpiration) + if signErr != nil { + http.Error(w, fmt.Sprintf(`{"error":"签名失败: %v","code":500}`, signErr.Error()), http.StatusInternalServerError) + return + } + // 返回成功续期的 License + resp := struct { + License License `json:"license"` + Message string `json:"message"` + }{ + License: lic, + Message: "License renewed automatically (AutoRenewAllDevices).", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + + // **查询设备** + var dev models.Device + err := database.DB.QueryRow(`SELECT id, device_id, expiration FROM devices WHERE device_id=?`, req.DeviceID). + Scan(&dev.ID, &dev.DeviceID, &dev.Expiration) + if err == sql.ErrNoRows { + if err := CreateRequest(req.DeviceID); err != nil { + http.Error(w, fmt.Sprintf(`{"error":"续期申请失败: %s","code":409}`, err.Error()), http.StatusConflict) + return + } + http.Error(w, `{"error":"设备不存在,已提交申请,等待管理员审批","code":404}`, http.StatusAccepted) + return + } else if err != nil { + http.Error(w, fmt.Sprintf(`{"error":"数据库查询错误: %s","code":500}`, err.Error()), http.StatusInternalServerError) + return + } + + newExpiration := time.Now().Add(1 * time.Hour) + if dev.Expiration.Before(time.Now()) { + if err := CreateRequest(req.DeviceID); err != nil { + http.Error(w, fmt.Sprintf(`{"error":"续期申请失败: %s","code":409}`, err.Error()), http.StatusConflict) + return + } + http.Error(w, `{"error":"设备过期,已提交续期申请,等待管理员审批","code":404}`, http.StatusAccepted) + return + } + if dev.Expiration.Before(newExpiration) { + // 如果时间不够1小时了,就续期到可以运行的极限 + newExpiration = dev.Expiration + } + // **创建新 License 并签名** + lic, signErr := SignLicense(dev.DeviceID, newExpiration) + if signErr != nil { + http.Error(w, fmt.Sprintf(`{"error":"%v","code":500}`, signErr.Error()), http.StatusInternalServerError) + return + } + + // **返回成功续期的 License** + resp := struct { + License License `json:"license"` + Message string `json:"message"` + }{ + License: lic, + Message: "License renewed successfully.", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} diff --git a/internal/handlers/request.go b/internal/handlers/request.go new file mode 100644 index 0000000..f7ee31f --- /dev/null +++ b/internal/handlers/request.go @@ -0,0 +1,213 @@ +package handlers + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "licensing-cotton/internal/config" + "licensing-cotton/internal/database" + "licensing-cotton/internal/models" + "net/http" + "time" +) + +// CreateRequest 记录新的 renew 申请 (如果设备未在申请列表中) +func CreateRequest(deviceID string) error { + var existingStatus string + + // 查询该设备是否已经有 `pending_requests` 记录 + err := database.DB.QueryRow(`SELECT status FROM pending_requests WHERE device_id = ? ORDER BY request_time DESC LIMIT 1`, deviceID). + Scan(&existingStatus) + + if err == nil { + if existingStatus == "pending" { + // 如果已有 `pending` 申请,则不允许重复提交 + return errors.New("该设备已有未处理的续期申请,等待管理员审批") + } + // 如果 `status` 是 `approved` 或 `rejected`,则允许提交新申请 + } else if err != sql.ErrNoRows { + // 查询时发生错误 + return fmt.Errorf("查询续期申请失败: %w", err) + } + + // 插入新的申请(设为 `pending`) + _, err = database.DB.Exec( + `INSERT INTO pending_requests (device_id, request_time, status) VALUES (?, ?, ?)`, + deviceID, time.Now(), "pending", + ) + if err != nil { + return fmt.Errorf("创建续期申请失败: %w", err) + } + + return nil +} + +func HandleListPendingRequests(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) + return + } + + // 管理员权限检查 + if !isAdminRequest(w, r) { + return + } + + // **查询所有未审批的请求** + rows, err := database.DB.Query(` + SELECT id, device_id, request_time, status, approved_by, approved_at, expiration + FROM pending_requests WHERE status='pending' + `) + if err != nil { + http.Error(w, "查询待审批请求失败: "+err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var pendingRequests []models.PendingRequest + for rows.Next() { + var req models.PendingRequest + var approvedBy sql.NullString + var approvedAt sql.NullTime + var expiration sql.NullTime + + // **扫描所有字段** + if err := rows.Scan(&req.ID, &req.DeviceID, &req.RequestTime, &req.Status, &approvedBy, &approvedAt, &expiration); err != nil { + http.Error(w, "解析查询结果失败: "+err.Error(), http.StatusInternalServerError) + return + } + + // **处理 `NULL` 值** + if approvedBy.Valid { + req.ApprovedBy = &approvedBy.String + } + if approvedAt.Valid { + req.ApprovedAt = &approvedAt.Time + } + if expiration.Valid { + req.Expiration = &expiration.Time + } + + pendingRequests = append(pendingRequests, req) + } + + // **返回 JSON** + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(pendingRequests) +} + +func HandleDeviceRequest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) + return + } + + if !isAdminRequest(w, r) { + return + } + + var req struct { + DeviceID string `json:"device_id"` + Expiration string `json:"expiration"` + ApprovedBy string `json:"approved_by"` + Action string `json:"action"` // "approve" 或 "reject" + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "解析请求失败: "+err.Error(), http.StatusBadRequest) + return + } + + switch req.Action { + case "approve": + handleApprove(w, struct{ DeviceID, Expiration, ApprovedBy, Action string }(req)) + case "reject": + handleReject(w, struct{ DeviceID, Expiration, ApprovedBy, Action string }(req)) + default: + http.Error(w, "无效的操作", http.StatusBadRequest) + } +} + +func handleApprove(w http.ResponseWriter, req struct{ DeviceID, Expiration, ApprovedBy, Action string }) { + expirationTime, err := time.Parse(time.RFC3339, req.Expiration) + if err != nil { + http.Error(w, "无效的 expiration 格式", http.StatusBadRequest) + return + } + + var exists bool + err = database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM devices WHERE device_id = ?)`, req.DeviceID).Scan(&exists) + if err != nil { + http.Error(w, "数据库查询失败: "+err.Error(), http.StatusInternalServerError) + return + } + + if !exists { + _, err = database.DB.Exec(`INSERT INTO devices (device_id, expiration) VALUES (?, ?)`, req.DeviceID, expirationTime) + } else { + _, err = database.DB.Exec(`UPDATE devices SET expiration = ? WHERE device_id = ?`, expirationTime, req.DeviceID) + } + + if err != nil { + http.Error(w, "处理设备记录失败: "+err.Error(), http.StatusInternalServerError) + return + } + + _, err = database.DB.Exec(`UPDATE pending_requests SET status='approved', approved_by=?, approved_at=? WHERE device_id=?`, req.ApprovedBy, time.Now(), req.DeviceID) + if err != nil { + http.Error(w, "更新请求状态失败: "+err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(map[string]string{ + "message": fmt.Sprintf("设备 %s 已审批,授权至 %s", req.DeviceID, req.Expiration), + }) +} + +func handleReject(w http.ResponseWriter, req struct{ DeviceID, Expiration, ApprovedBy, Action string }) { + currentTime := time.Now() + _, err := database.DB.Exec(`UPDATE pending_requests SET status='rejected', approved_by=?, approved_at=? WHERE device_id=?`, req.ApprovedBy, currentTime, req.DeviceID) + if err != nil { + http.Error(w, "拒绝请求失败: "+err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(map[string]string{ + "message": fmt.Sprintf("设备 %s 的请求已被拒绝", req.DeviceID), + }) +} + +func SetAutoRenewAllDevices(w http.ResponseWriter, r *http.Request) { + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body: "+err.Error(), http.StatusInternalServerError) + return + } + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // 重置 r.Body 供后续使用 + + if !isAdminRequest(w, r) { + return + } + + // 解析请求体中的 "enabled" 字段 + var req struct { + Enabled string `json:"enabled"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Failed to read request body: "+err.Error(), http.StatusInternalServerError) + //http.Error(w, `{"error":"解析请求失败","code":400}`, http.StatusBadRequest) + return + } + + // 设置全局变量 + config.AutoRenewAllDevices = req.Enabled == "true" + + // 返回成功响应 + msg := fmt.Sprintf(`{"message":"AutoRenewAllDevices set to %v","code":200}`, req.Enabled) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(msg)) +} diff --git a/internal/handlers/routes.go b/internal/handlers/routes.go new file mode 100644 index 0000000..4714730 --- /dev/null +++ b/internal/handlers/routes.go @@ -0,0 +1,27 @@ +package handlers + +import "net/http" + +func RegisterRoutes() *http.ServeMux { + mux := http.NewServeMux() + + // Auth + mux.HandleFunc("/login", HandleLogin) + mux.HandleFunc("/register", HandleRegisterUser) + + // Device + mux.HandleFunc("/device/manage", HandleCreateOrUpdateDevice) + mux.HandleFunc("/device/list", HandleListDevices) + + // License + mux.HandleFunc("/license/sign", HandleSignLicense) + mux.HandleFunc("/license/verify", HandleVerifyLicense) + + mux.HandleFunc("/license/renew", HandleRenewLicense) + + mux.HandleFunc("/admin/pending_requests", HandleListPendingRequests) + mux.HandleFunc("/admin/handle_request", HandleDeviceRequest) + mux.HandleFunc("/admin/allow_auto_renew", SetAutoRenewAllDevices) + + return mux +} diff --git a/internal/handlers/session.go b/internal/handlers/session.go new file mode 100644 index 0000000..7b33582 --- /dev/null +++ b/internal/handlers/session.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "sync" + "time" + + "licensing-cotton/internal/database" +) + +var ( + sessionMap = make(map[string]string) // token -> username + sessionMutex sync.RWMutex +) + +// 生成一个简单的 session token +func generateSessionToken(username string) string { + data := fmt.Sprintf("%s:%d", username, time.Now().UnixNano()) + sum := sha256.Sum256([]byte(data)) + return base64.URLEncoding.EncodeToString(sum[:]) +} + +func setSession(token, username string) { + sessionMutex.Lock() + defer sessionMutex.Unlock() + sessionMap[token] = username +} + +func getSessionUsername(token string) (string, bool) { + sessionMutex.RLock() + defer sessionMutex.RUnlock() + u, ok := sessionMap[token] + return u, ok +} + +// 也可以把 dbQueryRole 写这里或单独再抽一个地方 +func dbQueryRole(username string, role *string) error { + return database.DB.QueryRow(`SELECT role FROM users WHERE username=?`, username). + Scan(role) +} diff --git a/internal/models/device.go b/internal/models/device.go new file mode 100644 index 0000000..ab30a96 --- /dev/null +++ b/internal/models/device.go @@ -0,0 +1,9 @@ +package models + +import "time" + +type Device struct { + ID int64 `json:"id"` + DeviceID string `json:"device_id"` + Expiration time.Time `json:"expiration"` +} diff --git a/internal/models/pending_request.go b/internal/models/pending_request.go new file mode 100644 index 0000000..c158c72 --- /dev/null +++ b/internal/models/pending_request.go @@ -0,0 +1,14 @@ +package models + +import "time" + +// PendingRequest 记录设备申请信息 +type PendingRequest struct { + ID int64 `json:"id"` // 申请 ID + DeviceID string `json:"device_id"` // 申请的设备唯一 ID (MAC 地址) + RequestTime time.Time `json:"request_time"` // 申请时间 + Status string `json:"status"` // 申请状态 ("pending", "approved", "rejected") + ApprovedBy *string `json:"approved_by,omitempty"` // 审批管理员用户名 (仅限 approved) + ApprovedAt *time.Time `json:"approved_at,omitempty"` // 审批时间 (仅限 approved) + Expiration *time.Time `json:"expiration,omitempty"` // 审批通过后的授权时间 (仅限 approved) +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..108cf77 --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,8 @@ +package models + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Role string `json:"role"` +} diff --git a/internal/security/ecc.go b/internal/security/ecc.go new file mode 100644 index 0000000..4c3aa53 --- /dev/null +++ b/internal/security/ecc.go @@ -0,0 +1,28 @@ +package security + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "errors" +) + +var PrivateKey *ecdsa.PrivateKey +var PublicKey ecdsa.PublicKey + +func InitECCKey() error { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + PrivateKey = key + PublicKey = key.PublicKey + return nil +} + +func GetKeys() (*ecdsa.PrivateKey, *ecdsa.PublicKey, error) { + if PrivateKey == nil { + return nil, nil, errors.New("ECC私钥尚未初始化") + } + return PrivateKey, &PublicKey, nil +} diff --git a/internal/security/ed22519.go b/internal/security/ed22519.go new file mode 100644 index 0000000..233d5b6 --- /dev/null +++ b/internal/security/ed22519.go @@ -0,0 +1,54 @@ +package security + +import ( + "crypto/ed25519" + "errors" + "golang.org/x/crypto/ssh" + "os" + "sync" +) + +var ( + ed25519Priv ed25519.PrivateKey + once sync.Once +) + +// InitEd25519Keys 加载并解密 OpenSSH 格式的 Ed25519 私钥 +func InitEd25519Keys(passphrase string) error { + var err error + once.Do(func() { + // 读取私钥文件 + data, e := os.ReadFile("keys/alaer_machines") + if e != nil { + err = errors.New("failed to load ed25519 private key file: " + e.Error()) + return + } + + // 解析 OpenSSH 私钥格式(可能是加密的) + decryptedKey, e := ssh.ParseRawPrivateKeyWithPassphrase(data, []byte(passphrase)) + if e != nil { + err = errors.New("failed to decrypt private key: " + e.Error()) + return + } + + // 检查解析出的私钥类型 + switch key := decryptedKey.(type) { + case ed25519.PrivateKey: + ed25519Priv = key + case *ed25519.PrivateKey: + ed25519Priv = *key + default: + err = errors.New("parsed key is not an ed25519 private key, check your key format") + return + } + }) + return err +} + +// Ed25519Sign 进行 Ed25519 签名 +func Ed25519Sign(message []byte) ([]byte, error) { + if ed25519Priv == nil { + return nil, errors.New("private key not initialized") + } + return ed25519.Sign(ed25519Priv, message), nil +} diff --git a/internal/security/token.go b/internal/security/token.go new file mode 100644 index 0000000..630859a --- /dev/null +++ b/internal/security/token.go @@ -0,0 +1,44 @@ +package security + +import ( + "errors" + "github.com/golang-jwt/jwt/v5" + "time" +) + +var secretKey = []byte("your-secret-key") // 服务器私钥 + +// 生成 Token +func GenerateToken(username string) (string, error) { + claims := jwt.MapClaims{ + "username": username, + "exp": time.Now().Add(time.Hour * 24).Unix(), // 1 天有效期 + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secretKey) +} + +// 解析 Token +func ParseToken(tokenString string) (string, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return secretKey, nil + }) + + if err != nil { + return "", err + } + + // 解析 Claims + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + username, ok := claims["username"].(string) + if !ok { + return "", errors.New("无效的 Token") + } + return username, nil + } + + return "", errors.New("无效的 Token") +}