feat: 实现管理鉴权、API 令牌与操作日志

This commit is contained in:
Nixevol
2026-09-30 06:58:54 +08:00
parent 308b0b9edd
commit 0e068c6ade
13 changed files with 1655 additions and 1 deletions
+37 -1
View File
@@ -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
+355
View File
@@ -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)
}
}
+39
View File
@@ -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)
+12
View File
@@ -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,
)
}
+180
View File
@@ -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"
}
+138
View File
@@ -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",
}
+169
View File
@@ -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", "接口尚未实现")
}
+125
View File
@@ -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)
+249
View File
@@ -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
})
}
+165
View File
@@ -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
}
+91
View File
@@ -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
}
+34
View File
@@ -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")
}
}
+61
View File
@@ -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
}