feat: 实现管理鉴权、API 令牌与操作日志
This commit is contained in:
+37
-1
@@ -349,7 +349,43 @@
|
|||||||
|
|
||||||
## 后台接口 A
|
## 后台接口 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
|
## 后台网页 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