feat: 实现管理鉴权、API 令牌与操作日志
This commit is contained in:
+37
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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", "接口尚未实现")
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user