From 0e068c6ade0c69a5cdc7e08705ec49c5c96017a9 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 06:50:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=89=B4=E6=9D=83=E3=80=81API=20=E4=BB=A4=E7=89=8C=E4=B8=8E?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DEVIATIONS.md | 38 +++- internal/admin/admin_test.go | 355 ++++++++++++++++++++++++++++++++ internal/admin/apitokens.go | 39 ++++ internal/admin/audit.go | 12 ++ internal/admin/auth.go | 180 ++++++++++++++++ internal/admin/handler.go | 138 +++++++++++++ internal/admin/login.go | 169 +++++++++++++++ internal/admin/memlock.go | 125 +++++++++++ internal/admin/store.go | 249 ++++++++++++++++++++++ internal/admin/tokens.go | 165 +++++++++++++++ internal/httpx/clientip.go | 91 ++++++++ internal/httpx/clientip_test.go | 34 +++ internal/httpx/json.go | 61 ++++++ 13 files changed, 1655 insertions(+), 1 deletion(-) create mode 100644 internal/admin/admin_test.go create mode 100644 internal/admin/apitokens.go create mode 100644 internal/admin/audit.go create mode 100644 internal/admin/auth.go create mode 100644 internal/admin/handler.go create mode 100644 internal/admin/login.go create mode 100644 internal/admin/memlock.go create mode 100644 internal/admin/store.go create mode 100644 internal/admin/tokens.go create mode 100644 internal/httpx/clientip.go create mode 100644 internal/httpx/clientip_test.go create mode 100644 internal/httpx/json.go diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 337f802..2726b75 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -349,7 +349,43 @@ ## 后台接口 A -暂无。 +### A1 2026-09-30 + +1. **A1 仅交付可挂载 Handler,未改 `cmd/nixmsg`** + - 原条款:管理接口由服务进程提供;TASKS 要求路由可挂载。 + - 实际做法:`admin.New(Deps) *Handler` 实现 `http.Handler`,路径为完整 `/api/admin/...`;总控/后续接线在 mux 上 `Handle("/api/admin/", h)` 即可。本任务按隔离要求不改 `cmd/`、`listener`、`protocol`。 + - 原因:与并行线隔离;A1 验收用 httptest。 + - 备选方案:本分支同时改 `serve.go` 挂载(易与 N/P 冲突)。 + - 影响:合入后需在 `serve`/`wire` 挂载并注入真实 DB/Hash/Locks。 + +2. **P3 未合入时的假哈希与本地锁定/令牌生成** + - 原条款:密码与令牌哈希用 `internal/auth`;锁定与 argon2 池由 P3 实现。 + - 实际做法:依赖 `auth.HashPool` / `auth.APITokens` / `auth.LoginLocks` 接口;测试注入 `auth.StubHashPool`。提供 `admin.MemoryLoginLocks`(5 分钟 10 次锁 5 分钟)与 `admin.RandomAPITokens`(`nxm_`+32 字节 base64url,SHA-256)供本线与测试使用,不实现 argon2。 + - 原因:第 1 波允许用假实现;P3 合入后替换注入即可。 + - 备选方案:阻塞等待 P3。 + - 影响:生产接线应改用 P3 实现;`MemoryLoginLocks`/`RandomAPITokens` 可保留作测试替身。 + +3. **API 令牌 `id` 为字符串** + - 原条款:`docs/api/admin-api.md` 示例 `"id": 1`(数字)。 + - 实际做法:遵循库表 `api_tokens.id TEXT`,响应 `id` 为 16 字节随机十六进制字符串。 + - 原因:不改已发布迁移;与 schema 一致。 + - 备选方案:另加 INTEGER 列或把数字存成文本并在 JSON 里发数字。 + - 影响:W 线类型应按 `string` 解析令牌 id。 + +4. **尚未实现的管理路由(鉴权中间件已生效,业务返回 501)** + - 原条款:DEVELOPMENT 第 8 节完整路由表。 + - 实际做法:已实现 `login`/`logout`/`me`/`password` 与 `/api/admin/tokens` 全套。以下路由经鉴权后返回 `501 not_implemented`(属 A2/A3): + `GET /overview`;`endpoints` 列表/开通/import/batch/详情/改/删/kick/reset-login-password/talk-password/unlock;`registration` GET/PUT;`groups` 全部(含 `GET /groups/{id}`);`messages` 列表与详情;`GET /settings`。 + - 原因:A1 范围仅鉴权、令牌、操作日志。 + - 备选方案:无。 + - 影响:W/集成测试在 A2/A3 前勿依赖这些业务响应。 + +5. **操作日志用 `slog` 结构化字段** + - 原条款:写结构化日志(操作者、动作、对象、结果、来源 IP)。 + - 实际做法:`Logger.Info("admin_audit", "actor", ..., "action", ..., "object", ..., "result", ..., "ip", ...)`;改状态请求写日志;不写密码/令牌/正文。 + - 原因:文档未规定日志后端。 + - 备选方案:独立 audit 表。 + - 影响:日志采集需按 msg=`admin_audit` 过滤。 ## 后台网页 W diff --git a/internal/admin/admin_test.go b/internal/admin/admin_test.go new file mode 100644 index 0000000..3237c0b --- /dev/null +++ b/internal/admin/admin_test.go @@ -0,0 +1,355 @@ +package admin_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + + "git.asio.asia/nixevol/NixMsg/internal/admin" + "git.asio.asia/nixevol/NixMsg/internal/auth" + "git.asio.asia/nixevol/NixMsg/internal/store" +) + +const testPassword = "admin-password-ok" + +type envelope struct { + OK bool `json:"ok"` + Data json.RawMessage `json:"data"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +func setup(t *testing.T) (*admin.Handler, *httptest.Server, *http.Client, auth.HashPool) { + t.Helper() + dir := t.TempDir() + db, err := store.Open(filepath.Join(dir, "data"), "FULL") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + hash := auth.NewStubHashPool() + if seedErr := admin.SeedAdminPassword(context.Background(), db, hash, testPassword); seedErr != nil { + t.Fatal(seedErr) + } + locks := admin.NewMemoryLoginLocks() + h := admin.New(admin.Deps{ + DB: db, + Hash: hash, + Tokens: admin.NewRandomAPITokens(), + Locks: locks, + }) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar} + return h, srv, client, hash +} + +func decodeEnv(t *testing.T, res *http.Response) envelope { + t.Helper() + defer func() { _ = res.Body.Close() }() + var env envelope + if err := json.NewDecoder(res.Body).Decode(&env); err != nil { + t.Fatal(err) + } + return env +} + +func postJSON(t *testing.T, client *http.Client, url, body string, headers map[string]string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + return res +} + +func doReq(t *testing.T, client *http.Client, method, rawURL, body string, headers map[string]string) *http.Response { + t.Helper() + req, err := http.NewRequest(method, rawURL, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range headers { + req.Header.Set(k, v) + } + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + return res +} + +func login(t *testing.T, client *http.Client, base string) { + t.Helper() + res := postJSON(t, client, base+"/api/admin/login", + `{"username":"admin","password":"`+testPassword+`"}`, nil) + env := decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("login: status=%d env=%+v", res.StatusCode, env) + } +} + +func TestLoginMePasswordLogout(t *testing.T) { + _, srv, client, _ := setup(t) + base := srv.URL + + login(t, client, base) + + res := doReq(t, client, http.MethodGet, base+"/api/admin/me", "", nil) + env := decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("me: %d %+v", res.StatusCode, env) + } + var me map[string]any + _ = json.Unmarshal(env.Data, &me) + if me["username"] != "admin" || me["auth"] != "cookie" { + t.Fatalf("me data=%v", me) + } + + res = postJSON(t, client, base+"/api/admin/password", + `{"old_password":"`+testPassword+`","new_password":"new-password-12"}`, + map[string]string{"X-Nixmsg-Request": "1"}) + env = decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("password: %d %+v", res.StatusCode, env) + } + + res = postJSON(t, client, base+"/api/admin/logout", `{}`, + map[string]string{"X-Nixmsg-Request": "1"}) + env = decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("logout: %d %+v", res.StatusCode, env) + } + + res = doReq(t, client, http.MethodGet, base+"/api/admin/me", "", nil) + env = decodeEnv(t, res) + if res.StatusCode != 401 { + t.Fatalf("after logout want 401 got %d", res.StatusCode) + } + + // 用新密码再登录 + res = postJSON(t, client, base+"/api/admin/login", + `{"username":"admin","password":"new-password-12"}`, nil) + env = decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("relogin: %d %+v", res.StatusCode, env) + } +} + +func TestCSRFRequiredForCookieMutating(t *testing.T) { + _, srv, client, _ := setup(t) + base := srv.URL + login(t, client, base) + + res := postJSON(t, client, base+"/api/admin/password", + `{"old_password":"`+testPassword+`","new_password":"new-password-12"}`, + nil) // 无 CSRF 头 + env := decodeEnv(t, res) + if res.StatusCode != 403 || env.Error == nil || env.Error.Code != "forbidden" { + t.Fatalf("want 403 forbidden, got %d %+v", res.StatusCode, env) + } +} + +func TestAPITokenAuthAndRestrictions(t *testing.T) { + _, srv, client, _ := setup(t) + base := srv.URL + login(t, client, base) + + res := postJSON(t, client, base+"/api/admin/tokens", + `{"name":"ops"}`, + map[string]string{"X-Nixmsg-Request": "1"}) + env := decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("create token: %d %+v", res.StatusCode, env) + } + var created struct { + ID string `json:"id"` + Token string `json:"token"` + Name string `json:"name"` + } + if err := json.Unmarshal(env.Data, &created); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(created.Token, "nxm_") { + t.Fatalf("token prefix: %q", created.Token) + } + + tokClient := &http.Client{} + hdr := map[string]string{"Authorization": "Bearer " + created.Token} + + res = doReq(t, tokClient, http.MethodGet, base+"/api/admin/me", "", hdr) + env = decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("token me: %d %+v", res.StatusCode, env) + } + var me map[string]any + _ = json.Unmarshal(env.Data, &me) + if me["auth"] != "token" { + t.Fatalf("auth=%v", me["auth"]) + } + + // 普通管理接口鉴权通过(业务 501) + res = doReq(t, tokClient, http.MethodGet, base+"/api/admin/overview", "", hdr) + env = decodeEnv(t, res) + if res.StatusCode != http.StatusNotImplemented { + t.Fatalf("overview want 501 got %d %+v", res.StatusCode, env) + } + + // 禁止 password / tokens + res = postJSON(t, tokClient, base+"/api/admin/password", + `{"old_password":"x","new_password":"new-password-12"}`, hdr) + env = decodeEnv(t, res) + if res.StatusCode != 403 { + t.Fatalf("token password want 403 got %d", res.StatusCode) + } + + res = doReq(t, tokClient, http.MethodGet, base+"/api/admin/tokens", "", hdr) + env = decodeEnv(t, res) + if res.StatusCode != 403 { + t.Fatalf("token list want 403 got %d", res.StatusCode) + } + + // 停用后立即失效 + res = doReq(t, client, http.MethodPatch, base+"/api/admin/tokens/"+created.ID, + `{"enabled":false}`, + map[string]string{"X-Nixmsg-Request": "1", "Content-Type": "application/json"}) + env = decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("disable: %d %+v", res.StatusCode, env) + } + + res = doReq(t, tokClient, http.MethodGet, base+"/api/admin/me", "", hdr) + env = decodeEnv(t, res) + if res.StatusCode != 401 { + t.Fatalf("disabled token want 401 got %d %+v", res.StatusCode, env) + } +} + +func TestLoginLock(t *testing.T) { + _, srv, _, _ := setup(t) + base := srv.URL + + for i := 0; i < 9; i++ { + client := &http.Client{} + res := postJSON(t, client, base+"/api/admin/login", + `{"username":"admin","password":"wrong-password!!"}`, nil) + env := decodeEnv(t, res) + if res.StatusCode != 401 { + t.Fatalf("fail %d: want 401 got %d %+v", i, res.StatusCode, env) + } + } + client := &http.Client{} + res := postJSON(t, client, base+"/api/admin/login", + `{"username":"admin","password":"wrong-password!!"}`, nil) + env := decodeEnv(t, res) + if res.StatusCode != 429 || env.Error == nil || env.Error.Code != "rate_limited" { + t.Fatalf("want 429 rate_limited got %d %+v", res.StatusCode, env) + } + + res = postJSON(t, client, base+"/api/admin/login", + `{"username":"admin","password":"`+testPassword+`"}`, nil) + env = decodeEnv(t, res) + if res.StatusCode != 429 { + t.Fatalf("locked correct login want 429 got %d", res.StatusCode) + } +} + +func TestBadAPITokenCountsTowardLock(t *testing.T) { + _, srv, _, _ := setup(t) + base := srv.URL + tokClient := &http.Client{} + hdr := map[string]string{"Authorization": "Bearer nxm_" + strings.Repeat("a", 43)} + + for i := 0; i < 9; i++ { + res := doReq(t, tokClient, http.MethodGet, base+"/api/admin/me", "", hdr) + env := decodeEnv(t, res) + if res.StatusCode != 401 { + t.Fatalf("bad token %d: want 401 got %d %+v", i, res.StatusCode, env) + } + } + res := doReq(t, tokClient, http.MethodGet, base+"/api/admin/me", "", hdr) + env := decodeEnv(t, res) + if res.StatusCode != 429 { + t.Fatalf("want lock 429 got %d %+v", res.StatusCode, env) + } +} + +func TestCookieSetAttributes(t *testing.T) { + _, srv, client, _ := setup(t) + base := srv.URL + res := postJSON(t, client, base+"/api/admin/login", + `{"username":"admin","password":"`+testPassword+`"}`, nil) + _ = decodeEnv(t, res) + + u, _ := url.Parse(base) + cookies := client.Jar.Cookies(u) + found := false + for _, c := range cookies { + if c.Name == "nixmsg_admin" { + found = true + if c.Value == "" { + t.Fatal("empty cookie") + } + } + } + if !found { + t.Fatal("cookie not set") + } +} + +func TestMountableHandler(t *testing.T) { + dir := t.TempDir() + db, err := store.Open(filepath.Join(dir, "data"), "FULL") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + hash := auth.NewStubHashPool() + _ = admin.SeedAdminPassword(context.Background(), db, hash, testPassword) + h := admin.New(admin.Deps{ + DB: db, + Hash: hash, + Tokens: admin.NewRandomAPITokens(), + Locks: admin.NewMemoryLoginLocks(), + }) + mux := http.NewServeMux() + mux.Handle("/api/admin/", h) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + body := `{"username":"admin","password":"` + testPassword + `"}` + res, err := http.Post(srv.URL+"/api/admin/login", "application/json", bytes.NewBufferString(body)) + if err != nil { + t.Fatal(err) + } + env := decodeEnv(t, res) + if !env.OK { + t.Fatalf("mount login failed: %+v", env) + } +} diff --git a/internal/admin/apitokens.go b/internal/admin/apitokens.go new file mode 100644 index 0000000..2b556d4 --- /dev/null +++ b/internal/admin/apitokens.go @@ -0,0 +1,39 @@ +package admin + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "strings" + + "git.asio.asia/nixevol/NixMsg/internal/auth" +) + +// RandomAPITokens 生成真实 nxm_ 令牌(P3 合入前供 A1 使用)。 +// 哈希为 SHA-256 原始字节,与 DEVELOPMENT 一致。 +type RandomAPITokens struct{} + +// NewRandomAPITokens 返回可注入的 APITokens 实现。 +func NewRandomAPITokens() *RandomAPITokens { return &RandomAPITokens{} } + +func (RandomAPITokens) Issue(_ context.Context) (string, []byte, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", nil, err + } + tok := "nxm_" + base64.RawURLEncoding.EncodeToString(buf) + sum := sha256.Sum256([]byte(tok)) + return tok, sum[:], nil +} + +func (RandomAPITokens) HashToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +func (RandomAPITokens) LooksLikeAPIToken(credential string) bool { + return strings.HasPrefix(credential, "nxm_") +} + +var _ auth.APITokens = (*RandomAPITokens)(nil) diff --git a/internal/admin/audit.go b/internal/admin/audit.go new file mode 100644 index 0000000..41dcf6e --- /dev/null +++ b/internal/admin/audit.go @@ -0,0 +1,12 @@ +package admin + +// audit 写结构化操作日志;不写密码、令牌和正文。 +func (h *Handler) audit(actor, action, object, result, ip string) { + h.log.Info("admin_audit", + "actor", actor, + "action", action, + "object", object, + "result", result, + "ip", ip, + ) +} diff --git a/internal/admin/auth.go b/internal/admin/auth.go new file mode 100644 index 0000000..c6e2e56 --- /dev/null +++ b/internal/admin/auth.go @@ -0,0 +1,180 @@ +package admin + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "errors" + "net/http" + "strings" + "time" + + "git.asio.asia/nixevol/NixMsg/internal/auth" + "git.asio.asia/nixevol/NixMsg/internal/httpx" +) + +type authKind string + +const ( + authCookie authKind = "cookie" + authToken authKind = "token" +) + +type principal struct { + Kind authKind + TokenName string + TokenID string + Session string +} + +type ctxKey int + +const principalKey ctxKey = 1 + +func principalFrom(ctx context.Context) (principal, bool) { + p, ok := ctx.Value(principalKey).(principal) + return p, ok +} + +func (h *Handler) auth(next http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := httpx.ClientIP(r, h.trusted) + + if locked, retry := h.locks.Check(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked { + w.Header().Set("Retry-After", formatRetryAfter(retry)) + httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试") + return + } + + p, errCode, errMsg, status := h.authenticate(r, ip) + if status != 0 { + if status == http.StatusTooManyRequests { + w.Header().Set("Retry-After", "300") + } + httpx.WriteError(w, status, errCode, errMsg) + return + } + + if p.Kind == authCookie && isMutating(r.Method) { + if r.Header.Get(csrfHeader) != csrfValue { + httpx.WriteError(w, http.StatusForbidden, "forbidden", "缺少 X-Nixmsg-Request 头") + return + } + } + + if p.Kind == authToken && isTokenForbiddenPath(r.URL.Path) { + httpx.WriteError(w, http.StatusForbidden, "forbidden", "API 令牌无权访问该接口") + return + } + + ctx := context.WithValue(r.Context(), principalKey, p) + next(w, r.WithContext(ctx)) + }) +} + +func isMutating(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + default: + return false + } +} + +func isTokenForbiddenPath(path string) bool { + if path == "/api/admin/password" { + return true + } + return path == "/api/admin/tokens" || strings.HasPrefix(path, "/api/admin/tokens/") +} + +func (h *Handler) authenticate(r *http.Request, ip string) (principal, string, string, int) { + authz := r.Header.Get("Authorization") + if strings.HasPrefix(strings.ToLower(authz), "bearer ") { + raw := strings.TrimSpace(authz[len("Bearer "):]) + if raw == "" || !h.tokens.LooksLikeAPIToken(raw) { + return authFail(h, ip) + } + hash := h.tokens.HashToken(raw) + info, err := h.lookupAPIToken(r.Context(), hash) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return authFail(h, ip) + } + return principal{}, "internal", "内部错误", http.StatusInternalServerError + } + if !info.Enabled { + return authFail(h, ip) + } + h.touchLastUsed(r.Context(), info.ID) + return principal{Kind: authToken, TokenName: info.Name, TokenID: info.ID}, "", "", 0 + } + + c, err := r.Cookie(cookieName) + if err != nil || c.Value == "" { + return principal{}, "unauthorized", "未登录", http.StatusUnauthorized + } + hashHex := hashSessionHex(c.Value) + ok, err := h.sessionValid(r.Context(), hashHex) + if err != nil { + return principal{}, "internal", "内部错误", http.StatusInternalServerError + } + if !ok { + return principal{}, "unauthorized", "未登录", http.StatusUnauthorized + } + return principal{Kind: authCookie, Session: c.Value}, "", "", 0 +} + +func authFail(h *Handler, ip string) (principal, string, string, int) { + if locked, _ := h.locks.Fail(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked { + return principal{}, "rate_limited", "登录已锁定,请稍后再试", http.StatusTooManyRequests + } + return principal{}, "unauthorized", "令牌无效", http.StatusUnauthorized +} + +func hashSessionHex(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func newSessionToken() (plain string, hashHex string, err error) { + buf := make([]byte, 32) + if _, err = rand.Read(buf); err != nil { + return "", "", err + } + plain = base64.RawURLEncoding.EncodeToString(buf) + sum := sha256.Sum256([]byte(plain)) + return plain, hex.EncodeToString(sum[:]), nil +} + +func formatRetryAfter(d time.Duration) string { + sec := int(d.Seconds()) + if sec < 1 { + sec = 1 + } + return itoa(sec) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [16]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} + +func actorString(p principal) string { + if p.Kind == authToken { + return "token:" + p.TokenName + } + return "admin" +} diff --git a/internal/admin/handler.go b/internal/admin/handler.go new file mode 100644 index 0000000..f66d13e --- /dev/null +++ b/internal/admin/handler.go @@ -0,0 +1,138 @@ +package admin + +import ( + "log/slog" + "net" + "net/http" + "sync" + "time" + + "git.asio.asia/nixevol/NixMsg/internal/auth" + "git.asio.asia/nixevol/NixMsg/internal/store" +) + +const ( + cookieName = "nixmsg_admin" + csrfHeader = "X-Nixmsg-Request" + csrfValue = "1" + adminUsername = "admin" + settingAdminHash = "admin_password_hash" + defaultSessionTTL = 12 * time.Hour + minPasswordLen = 12 + lastUsedMinGap = time.Minute +) + +// Deps 是管理 Handler 的依赖。 +type Deps struct { + DB *store.DB + Hash auth.HashPool + Tokens auth.APITokens + Locks auth.LoginLocks + Logger *slog.Logger + + // TrustedProxies 受信任代理网段。 + TrustedProxies []*net.IPNet + // SessionTTL 会话有效期;零值用 12 小时。 + SessionTTL time.Duration + // SecureCookies 为 true 时 Cookie 始终带 Secure;否则按请求是否 HTTPS 决定。 + SecureCookies bool +} + +// Handler 是可挂载的管理接口(路由前缀 /api/admin/)。 +type Handler struct { + db *store.DB + hash auth.HashPool + tokens auth.APITokens + locks auth.LoginLocks + log *slog.Logger + trusted []*net.IPNet + ttl time.Duration + forceSec bool + + mux *http.ServeMux + + lastUsedMu sync.Mutex + lastUsed map[string]time.Time // api token id -> last DB write +} + +// New 构造可挂载的管理 Handler。返回值实现 http.Handler。 +func New(d Deps) *Handler { + if d.Logger == nil { + d.Logger = slog.Default() + } + if d.Locks == nil { + d.Locks = NewMemoryLoginLocks() + } + ttl := d.SessionTTL + if ttl <= 0 { + ttl = defaultSessionTTL + } + h := &Handler{ + db: d.DB, + hash: d.Hash, + tokens: d.Tokens, + locks: d.Locks, + log: d.Logger, + trusted: d.TrustedProxies, + ttl: ttl, + forceSec: d.SecureCookies, + mux: http.NewServeMux(), + lastUsed: make(map[string]time.Time), + } + h.routes() + return h +} + +// ServeHTTP 实现 http.Handler。 +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.mux.ServeHTTP(w, r) +} + +func (h *Handler) routes() { + // 公开 + h.mux.HandleFunc("POST /api/admin/login", h.handleLogin) + + // 需鉴权 + h.mux.Handle("POST /api/admin/logout", h.auth(h.handleLogout)) + h.mux.Handle("GET /api/admin/me", h.auth(h.handleMe)) + h.mux.Handle("POST /api/admin/password", h.auth(h.handlePassword)) + + // 令牌管理:仅 Cookie + h.mux.Handle("GET /api/admin/tokens", h.auth(h.handleTokenList)) + h.mux.Handle("POST /api/admin/tokens", h.auth(h.handleTokenCreate)) + h.mux.Handle("PATCH /api/admin/tokens/{id}", h.auth(h.handleTokenPatch)) + h.mux.Handle("DELETE /api/admin/tokens/{id}", h.auth(h.handleTokenDelete)) + + // 其余管理路由:鉴权生效,业务暂 501 + for _, p := range stubRoutes { + h.mux.Handle(p, h.auth(h.handleNotImplemented)) + } +} + +var stubRoutes = []string{ + "GET /api/admin/overview", + "GET /api/admin/endpoints", + "POST /api/admin/endpoints", + "POST /api/admin/endpoints/import", + "POST /api/admin/endpoints/batch", + "GET /api/admin/endpoints/{id}", + "PATCH /api/admin/endpoints/{id}", + "DELETE /api/admin/endpoints/{id}", + "POST /api/admin/endpoints/{id}/kick", + "POST /api/admin/endpoints/{id}/reset-login-password", + "PUT /api/admin/endpoints/{id}/talk-password", + "POST /api/admin/endpoints/{id}/unlock", + "GET /api/admin/registration", + "PUT /api/admin/registration", + "GET /api/admin/groups", + "POST /api/admin/groups", + "GET /api/admin/groups/{id}", + "PATCH /api/admin/groups/{id}", + "DELETE /api/admin/groups/{id}", + "POST /api/admin/groups/{id}/members", + "DELETE /api/admin/groups/{id}/members/{endpointId}", + "POST /api/admin/groups/{id}/transfer", + "GET /api/admin/messages", + "GET /api/admin/messages/{seq}", + "GET /api/admin/settings", +} diff --git a/internal/admin/login.go b/internal/admin/login.go new file mode 100644 index 0000000..e5779b1 --- /dev/null +++ b/internal/admin/login.go @@ -0,0 +1,169 @@ +package admin + +import ( + "database/sql" + "errors" + "net/http" + "strings" + + "git.asio.asia/nixevol/NixMsg/internal/auth" + "git.asio.asia/nixevol/NixMsg/internal/httpx" +) + +func (h *Handler) setSessionCookie(w http.ResponseWriter, r *http.Request, value string, maxAge int) { + secure := h.forceSec || httpx.IsHTTPS(r, h.trusted) + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: value, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: secure, + MaxAge: maxAge, + }) +} + +func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { + ip := httpx.ClientIP(r, h.trusted) + if locked, retry := h.locks.Check(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked { + w.Header().Set("Retry-After", formatRetryAfter(retry)) + httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试") + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效") + return + } + if req.Username != adminUsername { + h.failLogin(w, ip) + return + } + + phc, err := h.getAdminPasswordHash(r.Context()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + h.failLogin(w, ip) + return + } + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + ok, err := h.hash.Verify(r.Context(), auth.PasswordAdmin, req.Password, phc) + if err != nil || !ok { + h.failLogin(w, ip) + return + } + + h.locks.Clear(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}) + + plain, hashHex, err := newSessionToken() + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + if err := h.createSession(r.Context(), hashHex, h.ttl); err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + h.setSessionCookie(w, r, plain, int(h.ttl.Seconds())) + h.audit("admin", "login", "", "ok", ip) + httpx.WriteOK(w, map[string]any{"username": adminUsername}) +} + +func (h *Handler) failLogin(w http.ResponseWriter, ip string) { + locked, retry := h.locks.Fail(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}) + if locked { + w.Header().Set("Retry-After", formatRetryAfter(retry)) + httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试") + return + } + httpx.WriteError(w, http.StatusUnauthorized, "unauthorized", "用户名或密码错误") +} + +func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { + p, _ := principalFrom(r.Context()) + ip := httpx.ClientIP(r, h.trusted) + if p.Kind == authCookie && p.Session != "" { + _ = h.deleteSession(r.Context(), hashSessionHex(p.Session)) + } + h.setSessionCookie(w, r, "", -1) + h.audit(actorString(p), "logout", "", "ok", ip) + httpx.WriteOK(w, map[string]any{}) +} + +func (h *Handler) handleMe(w http.ResponseWriter, r *http.Request) { + p, _ := principalFrom(r.Context()) + authMode := "cookie" + if p.Kind == authToken { + authMode = "token" + } + httpx.WriteOK(w, map[string]any{ + "username": adminUsername, + "auth": authMode, + }) +} + +func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { + p, _ := principalFrom(r.Context()) + ip := httpx.ClientIP(r, h.trusted) + + var req struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if err := httpx.DecodeJSON(r, &req); err != nil { + h.audit(actorString(p), "password_change", "", "bad_request", ip) + httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效") + return + } + if len(req.NewPassword) < minPasswordLen { + h.audit(actorString(p), "password_change", "", "bad_request", ip) + httpx.WriteError(w, http.StatusBadRequest, "bad_request", "新密码至少 12 位") + return + } + + phc, err := h.getAdminPasswordHash(r.Context()) + if err != nil { + h.audit(actorString(p), "password_change", "", "error", ip) + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + ok, err := h.hash.Verify(r.Context(), auth.PasswordAdmin, req.OldPassword, phc) + if err != nil || !ok { + h.audit(actorString(p), "password_change", "", "unauthorized", ip) + httpx.WriteError(w, http.StatusUnauthorized, "unauthorized", "旧密码错误") + return + } + newPHC, err := h.hash.Hash(r.Context(), auth.PasswordAdmin, req.NewPassword) + if err != nil { + h.audit(actorString(p), "password_change", "", "error", ip) + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + if err := h.setAdminPasswordHash(r.Context(), newPHC); err != nil { + h.audit(actorString(p), "password_change", "", "error", ip) + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + // 保留当前会话,作废其它会话 + if p.Session != "" { + _ = h.deleteOtherSessions(r.Context(), hashSessionHex(p.Session)) + } + h.audit(actorString(p), "password_change", "", "ok", ip) + httpx.WriteOK(w, map[string]any{}) +} + +func (h *Handler) handleNotImplemented(w http.ResponseWriter, r *http.Request) { + p, _ := principalFrom(r.Context()) + ip := httpx.ClientIP(r, h.trusted) + action := strings.ToLower(r.Method) + " " + r.URL.Path + if isMutating(r.Method) { + h.audit(actorString(p), action, "", "not_implemented", ip) + } + httpx.WriteError(w, http.StatusNotImplemented, "not_implemented", "接口尚未实现") +} diff --git a/internal/admin/memlock.go b/internal/admin/memlock.go new file mode 100644 index 0000000..bfd0ec6 --- /dev/null +++ b/internal/admin/memlock.go @@ -0,0 +1,125 @@ +package admin + +import ( + "sync" + "time" + + "git.asio.asia/nixevol/NixMsg/internal/auth" +) + +// MemoryLoginLocks 是内存登录锁定(重启清零)。 +// P3 正式实现合入前,A1 用本实现满足管理员 IP 锁定;参数对齐 DEVELOPMENT 第 5 节。 +type MemoryLoginLocks struct { + mu sync.Mutex + entries map[string]*lockState + now func() time.Time +} + +type lockState struct { + fails []time.Time + lockedUntil time.Time +} + +// NewMemoryLoginLocks 创建内存锁定计数器。 +func NewMemoryLoginLocks() *MemoryLoginLocks { + return &MemoryLoginLocks{ + entries: make(map[string]*lockState), + now: time.Now, + } +} + +func (l *MemoryLoginLocks) key(k auth.LockKey) string { + return string(k.Kind) + "|" + k.EndpointID + "|" + k.PeerID + "|" + k.IP +} + +func (l *MemoryLoginLocks) params(kind auth.LockKind) (window time.Duration, threshold int, lockFor time.Duration) { + switch kind { + case auth.LockLoginEndpoint, auth.LockTalkTarget: + return time.Hour, 50, time.Hour + default: + // LockLoginEndpointIP / LockTalkPair / LockAdminIP / LockRegisterIP + return 5 * time.Minute, 10, 5 * time.Minute + } +} + +// Check 实现 auth.LoginLocks。 +func (l *MemoryLoginLocks) Check(key auth.LockKey) (bool, time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + st := l.entries[l.key(key)] + if st == nil { + return false, 0 + } + if now.Before(st.lockedUntil) { + return true, st.lockedUntil.Sub(now) + } + return false, 0 +} + +// Fail 实现 auth.LoginLocks。 +func (l *MemoryLoginLocks) Fail(key auth.LockKey) (bool, time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + k := l.key(key) + st := l.entries[k] + if st == nil { + st = &lockState{} + l.entries[k] = st + } + if now.Before(st.lockedUntil) { + return true, st.lockedUntil.Sub(now) + } + window, threshold, lockFor := l.params(key.Kind) + cutoff := now.Add(-window) + kept := st.fails[:0] + for _, t := range st.fails { + if t.After(cutoff) { + kept = append(kept, t) + } + } + kept = append(kept, now) + st.fails = kept + if len(st.fails) >= threshold { + st.lockedUntil = now.Add(lockFor) + st.fails = nil + return true, lockFor + } + return false, 0 +} + +// ClearEndpoint 实现 auth.LoginLocks。 +func (l *MemoryLoginLocks) ClearEndpoint(endpointID string) { + l.mu.Lock() + defer l.mu.Unlock() + for k := range l.entries { + // kind|endpoint|peer|ip + parts := splitLockKey(k) + if len(parts) >= 2 && parts[1] == endpointID { + delete(l.entries, k) + } + } +} + +// Clear 实现 auth.LoginLocks。 +func (l *MemoryLoginLocks) Clear(key auth.LockKey) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.entries, l.key(key)) +} + +func splitLockKey(k string) []string { + out := make([]string, 0, 4) + start := 0 + for i := 0; i < len(k); i++ { + if k[i] == '|' { + out = append(out, k[start:i]) + start = i + 1 + } + } + out = append(out, k[start:]) + return out +} + +var _ auth.LoginLocks = (*MemoryLoginLocks)(nil) diff --git a/internal/admin/store.go b/internal/admin/store.go new file mode 100644 index 0000000..d4e52b9 --- /dev/null +++ b/internal/admin/store.go @@ -0,0 +1,249 @@ +package admin + +import ( + "context" + "database/sql" + "encoding/hex" + "time" + + "git.asio.asia/nixevol/NixMsg/internal/auth" + "git.asio.asia/nixevol/NixMsg/internal/store" +) + +type apiTokenRow struct { + ID string + Name string + Enabled bool + CreatedAt time.Time + LastUsedAt *time.Time +} + +func (h *Handler) getAdminPasswordHash(ctx context.Context) (string, error) { + var v string + err := h.db.Read.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, settingAdminHash).Scan(&v) + if err != nil { + return "", err + } + return v, nil +} + +func (h *Handler) setAdminPasswordHash(ctx context.Context, phc string) error { + now := time.Now().UnixMilli() + return h.db.Queue.Do(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec( + `INSERT INTO settings(key, value, updated_at) VALUES(?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + settingAdminHash, phc, now, + ) + return err + }) +} + +func (h *Handler) createSession(ctx context.Context, hashHex string, ttl time.Duration) error { + now := time.Now() + return h.db.Queue.Do(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec( + `INSERT INTO admin_sessions(token_hash, created_at, expires_at) VALUES(?, ?, ?)`, + hashHex, now.UnixMilli(), now.Add(ttl).UnixMilli(), + ) + return err + }) +} + +func (h *Handler) sessionValid(ctx context.Context, hashHex string) (bool, error) { + var expires int64 + err := h.db.Read.QueryRowContext(ctx, + `SELECT expires_at FROM admin_sessions WHERE token_hash = ?`, hashHex, + ).Scan(&expires) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return time.Now().UnixMilli() < expires, nil +} + +func (h *Handler) deleteSession(ctx context.Context, hashHex string) error { + return h.db.Queue.Do(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec(`DELETE FROM admin_sessions WHERE token_hash = ?`, hashHex) + return err + }) +} + +func (h *Handler) deleteOtherSessions(ctx context.Context, keepHashHex string) error { + return h.db.Queue.Do(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec(`DELETE FROM admin_sessions WHERE token_hash != ?`, keepHashHex) + return err + }) +} + +func (h *Handler) lookupAPIToken(ctx context.Context, hash []byte) (apiTokenRow, error) { + hashHex := hex.EncodeToString(hash) + var ( + id, name string + enabled int + created, last sql.NullInt64 + ) + err := h.db.Read.QueryRowContext(ctx, + `SELECT id, name, enabled, created_at, last_used_at FROM api_tokens WHERE token_hash = ?`, + hashHex, + ).Scan(&id, &name, &enabled, &created, &last) + if err != nil { + return apiTokenRow{}, err + } + row := apiTokenRow{ + ID: id, + Name: name, + Enabled: enabled != 0, + } + if created.Valid { + row.CreatedAt = time.UnixMilli(created.Int64) + } + if last.Valid { + t := time.UnixMilli(last.Int64) + row.LastUsedAt = &t + } + return row, nil +} + +func (h *Handler) listAPITokens(ctx context.Context) ([]apiTokenRow, error) { + rows, err := h.db.Read.QueryContext(ctx, + `SELECT id, name, enabled, created_at, last_used_at FROM api_tokens ORDER BY created_at ASC`, + ) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []apiTokenRow + for rows.Next() { + var ( + id, name string + enabled int + created, last sql.NullInt64 + ) + if err := rows.Scan(&id, &name, &enabled, &created, &last); err != nil { + return nil, err + } + row := apiTokenRow{ID: id, Name: name, Enabled: enabled != 0} + if created.Valid { + row.CreatedAt = time.UnixMilli(created.Int64) + } + if last.Valid { + t := time.UnixMilli(last.Int64) + row.LastUsedAt = &t + } + out = append(out, row) + } + return out, rows.Err() +} + +func (h *Handler) insertAPIToken(ctx context.Context, id, name string, hash []byte) (time.Time, error) { + now := time.Now() + hashHex := hex.EncodeToString(hash) + err := h.db.Queue.Do(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec( + `INSERT INTO api_tokens(id, name, token_hash, enabled, created_at, last_used_at) VALUES(?, ?, ?, 1, ?, NULL)`, + id, name, hashHex, now.UnixMilli(), + ) + return err + }) + return now, err +} + +func (h *Handler) getAPITokenByID(ctx context.Context, id string) (apiTokenRow, error) { + var ( + name string + enabled int + created, last sql.NullInt64 + ) + err := h.db.Read.QueryRowContext(ctx, + `SELECT name, enabled, created_at, last_used_at FROM api_tokens WHERE id = ?`, id, + ).Scan(&name, &enabled, &created, &last) + if err != nil { + return apiTokenRow{}, err + } + row := apiTokenRow{ID: id, Name: name, Enabled: enabled != 0} + if created.Valid { + row.CreatedAt = time.UnixMilli(created.Int64) + } + if last.Valid { + t := time.UnixMilli(last.Int64) + row.LastUsedAt = &t + } + return row, nil +} + +func (h *Handler) updateAPIToken(ctx context.Context, id string, name *string, enabled *bool) error { + return h.db.Queue.Do(ctx, func(tx *sql.Tx) error { + var ( + curName string + curEn int + ) + if err := tx.QueryRow(`SELECT name, enabled FROM api_tokens WHERE id = ?`, id).Scan(&curName, &curEn); err != nil { + return err + } + newName := curName + newEn := curEn + if name != nil { + newName = *name + } + if enabled != nil { + if *enabled { + newEn = 1 + } else { + newEn = 0 + } + } + _, err := tx.Exec(`UPDATE api_tokens SET name = ?, enabled = ? WHERE id = ?`, newName, newEn, id) + return err + }) +} + +func (h *Handler) deleteAPIToken(ctx context.Context, id string) error { + return h.db.Queue.Do(ctx, func(tx *sql.Tx) error { + res, err := tx.Exec(`DELETE FROM api_tokens WHERE id = ?`, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil + }) +} + +func (h *Handler) touchLastUsed(ctx context.Context, id string) { + now := time.Now() + h.lastUsedMu.Lock() + prev, ok := h.lastUsed[id] + if ok && now.Sub(prev) < lastUsedMinGap { + h.lastUsedMu.Unlock() + return + } + h.lastUsed[id] = now + h.lastUsedMu.Unlock() + + _ = h.db.Queue.Do(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec(`UPDATE api_tokens SET last_used_at = ? WHERE id = ?`, now.UnixMilli(), id) + return err + }) +} + +// SeedAdminPassword 写入管理员密码哈希(测试与接线辅助);走 HashPool。 +func SeedAdminPassword(ctx context.Context, db *store.DB, hash auth.HashPool, password string) error { + phc, err := hash.Hash(ctx, auth.PasswordAdmin, password) + if err != nil { + return err + } + now := time.Now().UnixMilli() + return db.Queue.Do(ctx, func(tx *sql.Tx) error { + _, err := tx.Exec( + `INSERT INTO settings(key, value, updated_at) VALUES(?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + settingAdminHash, phc, now, + ) + return err + }) +} diff --git a/internal/admin/tokens.go b/internal/admin/tokens.go new file mode 100644 index 0000000..68dbde9 --- /dev/null +++ b/internal/admin/tokens.go @@ -0,0 +1,165 @@ +package admin + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "net/http" + "strings" + + "git.asio.asia/nixevol/NixMsg/internal/httpx" +) + +func (h *Handler) handleTokenList(w http.ResponseWriter, r *http.Request) { + items, err := h.listAPITokens(r.Context()) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + out := make([]map[string]any, 0, len(items)) + for _, it := range items { + row := map[string]any{ + "id": it.ID, + "name": it.Name, + "enabled": it.Enabled, + "created_at_ms": it.CreatedAt.UnixMilli(), + "last_used_at_ms": nil, + } + if it.LastUsedAt != nil { + row["last_used_at_ms"] = it.LastUsedAt.UnixMilli() + } + out = append(out, row) + } + httpx.WriteOK(w, map[string]any{ + "items": out, + "next_cursor": "", + "total": len(out), + }) +} + +func (h *Handler) handleTokenCreate(w http.ResponseWriter, r *http.Request) { + p, _ := principalFrom(r.Context()) + ip := httpx.ClientIP(r, h.trusted) + + var req struct { + Name string `json:"name"` + } + if err := httpx.DecodeJSON(r, &req); err != nil || strings.TrimSpace(req.Name) == "" { + h.audit(actorString(p), "token_create", "", "bad_request", ip) + httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称不能为空") + return + } + name := strings.TrimSpace(req.Name) + + plain, hash, err := h.tokens.Issue(r.Context()) + if err != nil { + h.audit(actorString(p), "token_create", "", "error", ip) + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + id, err := newTokenID() + if err != nil { + h.audit(actorString(p), "token_create", "", "error", ip) + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + created, err := h.insertAPIToken(r.Context(), id, name, hash) + if err != nil { + h.audit(actorString(p), "token_create", id, "error", ip) + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + h.audit(actorString(p), "token_create", id, "ok", ip) + httpx.WriteOK(w, map[string]any{ + "id": id, + "name": name, + "token": plain, + "created_at_ms": created.UnixMilli(), + }) +} + +func (h *Handler) handleTokenPatch(w http.ResponseWriter, r *http.Request) { + p, _ := principalFrom(r.Context()) + ip := httpx.ClientIP(r, h.trusted) + id := r.PathValue("id") + + var req struct { + Name *string `json:"name"` + Enabled *bool `json:"enabled"` + } + if err := httpx.DecodeJSON(r, &req); err != nil { + h.audit(actorString(p), "token_update", id, "bad_request", ip) + httpx.WriteError(w, http.StatusBadRequest, "bad_request", "请求体无效") + return + } + if req.Name == nil && req.Enabled == nil { + h.audit(actorString(p), "token_update", id, "bad_request", ip) + httpx.WriteError(w, http.StatusBadRequest, "bad_request", "无更新字段") + return + } + if req.Name != nil { + n := strings.TrimSpace(*req.Name) + if n == "" { + h.audit(actorString(p), "token_update", id, "bad_request", ip) + httpx.WriteError(w, http.StatusBadRequest, "bad_request", "名称不能为空") + return + } + req.Name = &n + } + + if err := h.updateAPIToken(r.Context(), id, req.Name, req.Enabled); err != nil { + if errors.Is(err, sql.ErrNoRows) { + h.audit(actorString(p), "token_update", id, "not_found", ip) + httpx.WriteError(w, http.StatusNotFound, "not_found", "令牌不存在") + return + } + h.audit(actorString(p), "token_update", id, "error", ip) + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + row, err := h.getAPITokenByID(r.Context(), id) + if err != nil { + h.audit(actorString(p), "token_update", id, "error", ip) + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + h.audit(actorString(p), "token_update", id, "ok", ip) + resp := map[string]any{ + "id": row.ID, + "name": row.Name, + "enabled": row.Enabled, + "created_at_ms": row.CreatedAt.UnixMilli(), + "last_used_at_ms": nil, + } + if row.LastUsedAt != nil { + resp["last_used_at_ms"] = row.LastUsedAt.UnixMilli() + } + httpx.WriteOK(w, resp) +} + +func (h *Handler) handleTokenDelete(w http.ResponseWriter, r *http.Request) { + p, _ := principalFrom(r.Context()) + ip := httpx.ClientIP(r, h.trusted) + id := r.PathValue("id") + if err := h.deleteAPIToken(r.Context(), id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + h.audit(actorString(p), "token_delete", id, "not_found", ip) + httpx.WriteError(w, http.StatusNotFound, "not_found", "令牌不存在") + return + } + h.audit(actorString(p), "token_delete", id, "error", ip) + httpx.WriteError(w, http.StatusInternalServerError, "internal", "内部错误") + return + } + h.audit(actorString(p), "token_delete", id, "ok", ip) + httpx.WriteOK(w, map[string]any{}) +} + +func newTokenID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/httpx/clientip.go b/internal/httpx/clientip.go new file mode 100644 index 0000000..35426fd --- /dev/null +++ b/internal/httpx/clientip.go @@ -0,0 +1,91 @@ +package httpx + +import ( + "net" + "net/http" + "strings" +) + +// ClientIP 按 DEVELOPMENT 4.5:仅当对端在 trusted 网段内时采信 +// X-Forwarded-For(从右往左第一个不在 trusted 内的地址)。 +func ClientIP(r *http.Request, trusted []*net.IPNet) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + ip := net.ParseIP(host) + if ip == nil { + return host + } + if !ipInNets(ip, trusted) { + return ip.String() + } + xff := r.Header.Get("X-Forwarded-For") + if xff == "" { + return ip.String() + } + parts := strings.Split(xff, ",") + for i := len(parts) - 1; i >= 0; i-- { + cand := strings.TrimSpace(parts[i]) + parsed := net.ParseIP(cand) + if parsed == nil { + continue + } + if !ipInNets(parsed, trusted) { + return parsed.String() + } + } + return ip.String() +} + +// IsHTTPS 判定请求是否视为 HTTPS(直连 TLS 或受信任代理的 X-Forwarded-Proto)。 +func IsHTTPS(r *http.Request, trusted []*net.IPNet) bool { + if r.TLS != nil { + return true + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + ip := net.ParseIP(host) + if ip == nil || !ipInNets(ip, trusted) { + return false + } + proto := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))) + return proto == "https" +} + +// ParseCIDRs 解析 CIDR 列表;非法项跳过。 +func ParseCIDRs(cidrs []string) []*net.IPNet { + var out []*net.IPNet + for _, c := range cidrs { + c = strings.TrimSpace(c) + if c == "" { + continue + } + _, n, err := net.ParseCIDR(c) + if err != nil { + // 允许单 IP 写成无掩码 + if ip := net.ParseIP(c); ip != nil { + if ip.To4() != nil { + _, n, err = net.ParseCIDR(ip.String() + "/32") + } else { + _, n, err = net.ParseCIDR(ip.String() + "/128") + } + } + } + if err == nil && n != nil { + out = append(out, n) + } + } + return out +} + +func ipInNets(ip net.IP, nets []*net.IPNet) bool { + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false +} diff --git a/internal/httpx/clientip_test.go b/internal/httpx/clientip_test.go new file mode 100644 index 0000000..c4a5d35 --- /dev/null +++ b/internal/httpx/clientip_test.go @@ -0,0 +1,34 @@ +package httpx_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "git.asio.asia/nixevol/NixMsg/internal/httpx" +) + +func TestClientIPWithoutProxy(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "203.0.113.9:1234" + r.Header.Set("X-Forwarded-For", "198.51.100.1") + ip := httpx.ClientIP(r, nil) + if ip != "203.0.113.9" { + t.Fatalf("got %q", ip) + } +} + +func TestParseCIDRsAndTrustedXFF(t *testing.T) { + trusted := httpx.ParseCIDRs([]string{"127.0.0.1/32"}) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "127.0.0.1:9999" + r.Header.Set("X-Forwarded-For", "198.51.100.7, 127.0.0.1") + ip := httpx.ClientIP(r, trusted) + if ip != "198.51.100.7" { + t.Fatalf("got %q", ip) + } + r.Header.Set("X-Forwarded-Proto", "https") + if !httpx.IsHTTPS(r, trusted) { + t.Fatal("expected https via proxy") + } +} diff --git a/internal/httpx/json.go b/internal/httpx/json.go new file mode 100644 index 0000000..ecfac96 --- /dev/null +++ b/internal/httpx/json.go @@ -0,0 +1,61 @@ +// Package httpx 提供管理接口共用的 HTTP 辅助(JSON 信封、客户端 IP、HTTPS 判定)。 +package httpx + +import ( + "encoding/json" + "errors" + "io" + "net/http" +) + +// ErrorBody 是失败响应里的 error 对象。 +type ErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// Envelope 是管理接口通用响应信封。 +type Envelope struct { + OK bool `json:"ok"` + Data any `json:"data,omitempty"` + Error *ErrorBody `json:"error,omitempty"` +} + +// WriteJSON 写入 JSON 响应。 +func WriteJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) +} + +// WriteOK 写入成功信封。 +func WriteOK(w http.ResponseWriter, data any) { + WriteJSON(w, http.StatusOK, Envelope{OK: true, Data: data}) +} + +// WriteError 写入失败信封。 +func WriteError(w http.ResponseWriter, status int, code, message string) { + WriteJSON(w, status, Envelope{ + OK: false, + Error: &ErrorBody{ + Code: code, + Message: message, + }, + }) +} + +// DecodeJSON 解码请求 JSON 体;空体对 dst 保持零值。 +func DecodeJSON(r *http.Request, dst any) error { + defer func() { _ = r.Body.Close() }() + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + return nil +}