From a18fa887cf4336ab8c9d2082174c7817569f09d1 Mon Sep 17 00:00:00 2001 From: wangfuduo Date: Tue, 13 Oct 2026 18:01:14 +0800 Subject: [PATCH] refactor: operate log and so on --- app/admin/controller/user.go | 8 + app/admin/middleware/limit.go | 52 +++ app/admin/middleware/operate_log.go | 400 +++++++++++++++++++++++ app/admin/middleware/operate_log_test.go | 358 ++++++++++++++++++++ app/admin/request/user.go | 6 +- app/admin/service/captcha.go | 168 ++++++++++ app/admin/service/captcha_test.go | 42 +++ app/admin/service/user.go | 45 ++- app/common/cache/keys.go | 4 + app/common/dao/admin_operate_log.go | 23 ++ app/common/err/err.go | 30 +- app/common/model/admin_operate_log.go | 22 ++ app/routers/admin_router.go | 7 +- base/config/error.go | 2 + scripts/ddl_yt_admin_operate_log.sql | 26 ++ 15 files changed, 1165 insertions(+), 28 deletions(-) create mode 100644 app/admin/middleware/limit.go create mode 100644 app/admin/middleware/operate_log.go create mode 100644 app/admin/middleware/operate_log_test.go create mode 100644 app/admin/service/captcha.go create mode 100644 app/admin/service/captcha_test.go create mode 100644 app/common/dao/admin_operate_log.go create mode 100644 app/common/model/admin_operate_log.go create mode 100644 scripts/ddl_yt_admin_operate_log.sql diff --git a/app/admin/controller/user.go b/app/admin/controller/user.go index f810058..9d29d5f 100644 --- a/app/admin/controller/user.go +++ b/app/admin/controller/user.go @@ -69,6 +69,14 @@ func (a *User) List(ctx *gin.Context) { ctx.JSON(http.StatusOK, response.NewListRsp(count, res)) } +func (a *User) Captcha(ctx *gin.Context) { + id, image := new(service.User).Captcha() + ctx.JSON(http.StatusOK, response.NewRsp(map[string]any{ + "id": id, + "image": image, + })) +} + func (a *User) Login(ctx *gin.Context) { var req request.UserLogin if err := ctx.ShouldBind(&req); err != nil { diff --git a/app/admin/middleware/limit.go b/app/admin/middleware/limit.go new file mode 100644 index 0000000..55cb226 --- /dev/null +++ b/app/admin/middleware/limit.go @@ -0,0 +1,52 @@ +package middleware + +import ( + "sync" + "time" + "yintai-api/base/config" + + "github.com/gin-gonic/gin" +) + +func LoginLimit() gin.HandlerFunc { + const maxPerSecond = 2 + var ( + mu sync.Mutex + hits = map[string][]time.Time{} + ) + return func(c *gin.Context) { + ip := c.ClientIP() + now := time.Now() + cutoff := now.Add(-time.Second) + + mu.Lock() + recent := make([]time.Time, 0, maxPerSecond) + for _, t := range hits[ip] { + if t.After(cutoff) { + recent = append(recent, t) + } + } + if len(recent) >= maxPerSecond { + hits[ip] = recent + mu.Unlock() + panic(config.ErrLimit.New()) + } + hits[ip] = append(recent, now) + if len(hits) > 1024 { + for k, v := range hits { + alive := false + for _, t := range v { + if t.After(cutoff) { + alive = true + break + } + } + if !alive { + delete(hits, k) + } + } + } + mu.Unlock() + c.Next() + } +} diff --git a/app/admin/middleware/operate_log.go b/app/admin/middleware/operate_log.go new file mode 100644 index 0000000..d7ccc62 --- /dev/null +++ b/app/admin/middleware/operate_log.go @@ -0,0 +1,400 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "time" + "unicode/utf8" + "yintai-api/app/common/cache" + "yintai-api/app/common/dao" + "yintai-api/app/common/model" + "yintai-api/base/global" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +const ( + operateBodyLimit = 8000 + operateQueryLimit = 1024 + operateHeaderLimit = 8000 + operateMessageLimit = 512 + operateAccountLimit = 64 + operateForwardLimit = 512 + operatePathLimit = 255 + operateActionLimit = 64 +) + +// 写操作、登录、退出、获取验证码,以及所有失败请求都记。成功的普通查询不记。 +var operateActions = map[string]string{ + "POST /yt/user/login": "登录", + "GET /yt/user/logout": "退出", + "GET /yt/user/captcha": "获取验证码", + "POST /yt/user": "创建用户", + "PUT /yt/user": "修改用户", + "DELETE /yt/user": "删除用户", + "GET /yt/user": "查询用户", + "POST /yt/resource": "创建资源", + "PUT /yt/resource": "修改资源", + "DELETE /yt/resource": "删除资源", + "GET /yt/resource": "查询资源", + "POST /yt/role": "创建角色", + "PUT /yt/role": "修改角色", + "DELETE /yt/role": "删除角色", + "GET /yt/role": "查询角色", + "GET /yt/role/:id": "查询角色", + "GET /yt/role/account/:id": "查询角色账号", + "GET /yt/translate": "翻译", + "POST /yt/category": "创建分类", + "PUT /yt/category": "修改分类", + "DELETE /yt/category": "删除分类", + "GET /yt/category": "查询分类", + "POST /yt/process": "创建历程", + "PUT /yt/process": "修改历程", + "DELETE /yt/process": "删除历程", + "GET /yt/process": "查询历程", + "GET /yt/process/:id": "查询历程", + "POST /yt/news": "创建新闻", + "PUT /yt/news": "修改新闻", + "DELETE /yt/news": "删除新闻", + "GET /yt/news": "查询新闻", + "GET /yt/news/:id": "查询新闻", + "POST /yt/job": "创建招聘", + "PUT /yt/job": "修改招聘", + "DELETE /yt/job": "删除招聘", + "GET /yt/job": "查询招聘", + "GET /yt/job/:id": "查询招聘", + "POST /yt/doc": "创建文档", + "PUT /yt/doc": "修改文档", + "DELETE /yt/doc": "删除文档", + "GET /yt/doc": "查询文档", + "GET /yt/doc/:id": "查询文档", + "POST /yt/page": "创建页面", + "PUT /yt/page": "修改页面", + "DELETE /yt/page": "删除页面", + "GET /yt/page": "查询页面", + "POST /yt/upload": "上传文件", + "GET /yt/upload/token": "获取上传凭证", +} + +var saveOperateLog = func(row *model.AdminOperateLog) error { + if global.GetDB() == nil { + return errDBNotReady + } + return dao.NewAdminOperateLog().Create(row) +} + +var loadOperateUser = func(token string) *model.UserWithRole { + token = strings.TrimSpace(token) + if token == "" { + return nil + } + client := global.GetRedis() + if client == nil { + return nil + } + val := client.Get(cache.NewKeys().User() + token).Val() + if val == "" { + return nil + } + var user model.UserWithRole + if err := json.Unmarshal([]byte(val), &user); err != nil { + return nil + } + return &user +} + +type operateDBError string + +func (e operateDBError) Error() string { return string(e) } + +const errDBNotReady operateDBError = "admin operate log: db not ready" + +type bodyWriter struct { + gin.ResponseWriter + buf bytes.Buffer +} + +func (w *bodyWriter) Write(b []byte) (int, error) { + w.buf.Write(b) + return w.ResponseWriter.Write(b) +} + +func (w *bodyWriter) WriteString(s string) (int, error) { + w.buf.WriteString(s) + return w.ResponseWriter.WriteString(s) +} + +// OperateLog 必须注册在 Recovery 之前。业务错误是 panic 抛出的, +// Recovery 接住并写完响应之后,这里才能把失败也记下来。 +func OperateLog() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + reqBody := readOperateBody(c) + user := loadOperateUser(c.GetHeader("token")) + writer := &bodyWriter{ResponseWriter: c.Writer} + c.Writer = writer + c.Next() + + resp := writer.buf.Bytes() + if user == nil && c.FullPath() == "/yt/user/login" { + if code, _, ok := parseOperateResult(resp); ok && code == 0 { + if token := loginToken(resp); token != "" { + if loaded := loadOperateUser(token); loaded != nil { + user = loaded + } + } + } + } + row := collectOperateLog(c, start, reqBody, resp, user) + if row == nil { + return + } + if err := saveOperateLog(row); err != nil { + log.Errorf("admin operate log %s %s: %v", row.Method, row.Path, err) + } + } +} + +func collectOperateLog(c *gin.Context, start time.Time, reqBody, resp []byte, user *model.UserWithRole) *model.AdminOperateLog { + route := c.FullPath() + if route == "" { + route = c.Request.URL.Path + } + code, message, _ := parseOperateResult(resp) + success := 0 + if code == 0 { + success = 1 + } + if !shouldRecord(c.Request.Method, route, success == 1) { + return nil + } + + account := "" + var userID, roleID int64 + if user != nil { + account = user.Account + userID = user.ID + roleID = user.RoleID + } else { + account = accountFromBody(reqBody) + } + + cost := int(time.Since(start).Milliseconds()) + if cost < 0 { + cost = 0 + } + return &model.AdminOperateLog{ + Account: cut(strings.TrimSpace(account), operateAccountLimit), + UserID: userID, + RoleID: roleID, + CreateTime: start.Unix(), + CostMs: cost, + IP: cut(c.ClientIP(), 64), + ForwardedFor: cut(c.GetHeader("X-Forwarded-For"), operateForwardLimit), + Method: cut(c.Request.Method, 16), + Path: cut(c.Request.URL.Path, operatePathLimit), + Query: cut(c.Request.URL.RawQuery, operateQueryLimit), + Request: requestText(c, reqBody), + Action: cut(operateAction(c.Request.Method, route), operateActionLimit), + Code: code, + Message: cut(message, operateMessageLimit), + Response: responseText(route, resp), + Success: success, + Header: headerText(c.Request.Header), + } +} + +func shouldRecord(method, route string, success bool) bool { + if !success { + return true + } + switch method { + case http.MethodPost, http.MethodPut, http.MethodDelete: + return true + } + return route == "/yt/user/logout" || route == "/yt/user/captcha" +} + +func operateAction(method, route string) string { + if name, ok := operateActions[method+" "+route]; ok { + return name + } + return method + " " + route +} + +func readOperateBody(c *gin.Context) []byte { + if strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "multipart/") { + return nil + } + if c.Request.Body == nil { + return nil + } + body, err := io.ReadAll(c.Request.Body) + _ = c.Request.Body.Close() + if err != nil { + c.Request.Body = io.NopCloser(bytes.NewReader(nil)) + return nil + } + c.Request.Body = io.NopCloser(bytes.NewReader(body)) + return body +} + +func requestText(c *gin.Context, body []byte) string { + if strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "multipart/") { + return cut(multipartRequest(c), operateBodyLimit) + } + return maskAndCut(body, false) +} + +func responseText(route string, body []byte) string { + return maskAndCut(body, route == "/yt/user/captcha") +} + +func multipartRequest(c *gin.Context) string { + form := c.Request.MultipartForm + if form == nil { + return "" + } + fields := make(map[string][]string, len(form.Value)) + for key, values := range form.Value { + copied := append([]string(nil), values...) + if strings.EqualFold(key, "token") { + for i := range copied { + copied[i] = maskToken(copied[i]) + } + } + fields[key] = copied + } + files := make(map[string][]map[string]any, len(form.File)) + for key, headers := range form.File { + items := make([]map[string]any, 0, len(headers)) + for _, header := range headers { + items = append(items, map[string]any{ + "filename": header.Filename, + "size": header.Size, + }) + } + files[key] = items + } + raw, err := json.Marshal(map[string]any{"fields": fields, "files": files}) + if err != nil { + return "" + } + return string(raw) +} + +func parseOperateResult(body []byte) (int, string, bool) { + if len(bytes.TrimSpace(body)) == 0 { + return -1, "", false + } + var rsp struct { + Code int `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &rsp); err != nil { + return -1, "", false + } + return rsp.Code, rsp.Message, true +} + +func accountFromBody(body []byte) string { + var req struct { + Account string `json:"account"` + } + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + return strings.TrimSpace(req.Account) +} + +func loginToken(body []byte) string { + var rsp struct { + Data struct { + Token string `json:"token"` + } `json:"data"` + } + if err := json.Unmarshal(body, &rsp); err != nil { + return "" + } + return rsp.Data.Token +} + +func maskAndCut(body []byte, omitImage bool) string { + if len(bytes.TrimSpace(body)) == 0 { + return "" + } + var value any + if err := json.Unmarshal(body, &value); err != nil { + return cut(string(body), operateBodyLimit) + } + maskJSON(value, omitImage) + raw, err := json.Marshal(value) + if err != nil { + return cut(string(body), operateBodyLimit) + } + return cut(string(raw), operateBodyLimit) +} + +func maskJSON(value any, omitImage bool) { + switch node := value.(type) { + case map[string]any: + for key, item := range node { + switch { + case strings.EqualFold(key, "token"): + node[key] = maskToken(asString(item)) + case omitImage && strings.EqualFold(key, "image"): + node[key] = "[omitted]" + default: + maskJSON(item, omitImage) + } + } + case []any: + for _, item := range node { + maskJSON(item, omitImage) + } + } +} + +func headerText(header http.Header) string { + copied := make(map[string]string, len(header)) + for key, values := range header { + val := strings.Join(values, ",") + switch strings.ToLower(key) { + case "token", "cookie", "authorization", "set-cookie": + val = maskToken(val) + } + copied[key] = val + } + raw, err := json.Marshal(copied) + if err != nil { + return "" + } + return cut(string(raw), operateHeaderLimit) +} + +func maskToken(token string) string { + if len(token) <= 12 { + return "****" + } + return token[:4] + "****" + token[len(token)-4:] +} + +func asString(value any) string { + text, _ := value.(string) + return text +} + +func cut(s string, n int) string { + if len(s) <= n { + return s + } + b := []byte(s[:n]) + for len(b) > 0 && !utf8.Valid(b) { + b = b[:len(b)-1] + } + return string(b) +} diff --git a/app/admin/middleware/operate_log_test.go b/app/admin/middleware/operate_log_test.go new file mode 100644 index 0000000..f515be1 --- /dev/null +++ b/app/admin/middleware/operate_log_test.go @@ -0,0 +1,358 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + "unicode/utf8" + commonmw "yintai-api/app/common/middleware" + "yintai-api/app/common/model" + "yintai-api/base/config" + + "github.com/gin-gonic/gin" +) + +func TestMain(m *testing.M) { + gin.SetMode(gin.TestMode) + m.Run() +} + +type savedLog struct { + row *model.AdminOperateLog +} + +func captureLog(t *testing.T) *savedLog { + t.Helper() + got := &savedLog{} + oldSave := saveOperateLog + oldLoad := loadOperateUser + saveOperateLog = func(row *model.AdminOperateLog) error { + got.row = row + return nil + } + loadOperateUser = func(token string) *model.UserWithRole { + if token == "good-token-value" { + return &model.UserWithRole{ + User: model.User{ID: 7, Account: "editor"}, + RoleID: 3, + } + } + return nil + } + t.Cleanup(func() { + saveOperateLog = oldSave + loadOperateUser = oldLoad + }) + return got +} + +func perform(handler gin.HandlerFunc, req *http.Request) *httptest.ResponseRecorder { + engine := gin.New() + engine.Use(OperateLog(), commonmw.Recovery) + engine.Handle(req.Method, req.URL.Path, handler) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, req) + return recorder +} + +func TestOperateLogSkipsSuccessfulQuery(t *testing.T) { + got := captureLog(t) + req := httptest.NewRequest(http.MethodGet, "/yt/page", nil) + perform(func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "OK"}) + }, req) + if got.row != nil { + t.Fatalf("query was logged: %+v", got.row) + } +} + +func TestOperateLogRecordsLoginFailure(t *testing.T) { + got := captureLog(t) + body := `{"account":"admin","password":"Secret123!","captcha_id":"cid","captcha":"W8K2"}` + req := httptest.NewRequest(http.MethodPost, "/yt/user/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "203.0.113.8") + var seenPassword string + perform(func(c *gin.Context) { + var req struct { + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + t.Errorf("bind body: %v", err) + } + seenPassword = req.Password + panic(config.LoginErr.New()) + }, req) + + if seenPassword != "Secret123!" { + t.Fatalf("handler password = %q", seenPassword) + } + if got.row == nil { + t.Fatal("login failure was not logged") + } + if got.row.Account != "admin" || got.row.UserID != 0 || got.row.Action != "登录" { + t.Fatalf("identity: %+v", got.row) + } + if got.row.Success != 0 || got.row.Code != 11013 { + t.Fatalf("result code=%d success=%d message=%s", got.row.Code, got.row.Success, got.row.Message) + } + if !strings.Contains(got.row.Request, "Secret123!") || !strings.Contains(got.row.Request, "W8K2") { + t.Fatalf("request = %s", got.row.Request) + } + if got.row.ForwardedFor != "203.0.113.8" { + t.Fatalf("forwarded = %s", got.row.ForwardedFor) + } +} + +func TestOperateLogRecordsFailedQuery(t *testing.T) { + got := captureLog(t) + req := httptest.NewRequest(http.MethodGet, "/yt/page", nil) + req.Header.Set("token", "bad-token") + perform(func(c *gin.Context) { + panic(config.ErrNeedLogin.New()) + }, req) + if got.row == nil { + t.Fatal("auth failure was not logged") + } + if got.row.Success != 0 || got.row.Code != 11009 || got.row.Action != "查询页面" { + t.Fatalf("row = %+v", got.row) + } + if strings.Contains(got.row.Header, "bad-token") { + t.Fatalf("header = %s", got.row.Header) + } +} + +func TestOperateLogUsesSessionUserAndMasksToken(t *testing.T) { + got := captureLog(t) + req := httptest.NewRequest(http.MethodPut, "/yt/page?id=9", strings.NewReader(`{"name":"founder","password":"Secret123!"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("token", "good-token-value") + perform(func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "message": "OK", + "data": gin.H{"token": "abcdefghijklmnopqrstuvwxyz"}, + }) + }, req) + if got.row == nil { + t.Fatal("update was not logged") + } + if got.row.Account != "editor" || got.row.UserID != 7 || got.row.RoleID != 3 { + t.Fatalf("user = %+v", got.row) + } + if got.row.Success != 1 || got.row.Action != "修改页面" || got.row.Query != "id=9" { + t.Fatalf("row = %+v", got.row) + } + if !strings.Contains(got.row.Request, "Secret123!") || strings.Contains(got.row.Response, "abcdefghijklmnopqrstuvwxyz") { + t.Fatalf("request=%s response=%s", got.row.Request, got.row.Response) + } + if strings.Contains(got.row.Header, "good-token-value") { + t.Fatalf("header = %s", got.row.Header) + } +} + +func TestOperateLogCaptchaOmitsImage(t *testing.T) { + got := captureLog(t) + req := httptest.NewRequest(http.MethodGet, "/yt/user/captcha", nil) + perform(func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "message": "OK", + "data": gin.H{"id": "abc", "image": "data:image/png;base64,AAAA"}, + }) + }, req) + if got.row == nil { + t.Fatal("captcha was not logged") + } + if got.row.Action != "获取验证码" || got.row.Success != 1 { + t.Fatalf("row = %+v", got.row) + } + if strings.Contains(got.row.Response, "base64") || !strings.Contains(got.row.Response, `"id":"abc"`) { + t.Fatalf("response = %s", got.row.Response) + } +} + +func TestOperateLogLoginSuccessLoadsUser(t *testing.T) { + got := captureLog(t) + req := httptest.NewRequest(http.MethodPost, "/yt/user/login", strings.NewReader(`{"account":"editor","password":"Secret123!"}`)) + req.Header.Set("Content-Type", "application/json") + perform(func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "message": "OK", + "data": gin.H{"token": "good-token-value"}, + }) + }, req) + if got.row == nil { + t.Fatal("login success was not logged") + } + if got.row.Account != "editor" || got.row.UserID != 7 || got.row.RoleID != 3 || got.row.Success != 1 { + t.Fatalf("row = %+v", got.row) + } + if strings.Contains(got.row.Response, "good-token-value") { + t.Fatalf("response = %s", got.row.Response) + } +} + +func TestOperateLogUploadKeepsFileMetaOnly(t *testing.T) { + got := captureLog(t) + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", "founder.png") + if err != nil { + t.Fatal(err) + } + if _, err = part.Write([]byte("file-bytes-secret")); err != nil { + t.Fatal(err) + } + if err = writer.WriteField("password", "Secret123!"); err != nil { + t.Fatal(err) + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/yt/upload", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("token", "good-token-value") + perform(func(c *gin.Context) { + if _, err := c.FormFile("file"); err != nil { + t.Errorf("form file: %v", err) + } + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "OK"}) + }, req) + if got.row == nil { + t.Fatal("upload was not logged") + } + if strings.Contains(got.row.Request, "file-bytes-secret") || !strings.Contains(got.row.Request, "Secret123!") { + t.Fatalf("request = %s", got.row.Request) + } + if !strings.Contains(got.row.Request, "founder.png") || got.row.Action != "上传文件" { + t.Fatalf("request = %s action = %s", got.row.Request, got.row.Action) + } +} + +func TestOperateLogStopsBeforeWriteAndMatchesExpected(t *testing.T) { + got := captureLog(t) + before := time.Now().Unix() + body := `{"account":"admin","password":"Secret123!","captcha_id":"cid","captcha":"W8K2"}` + req := httptest.NewRequest(http.MethodPost, "/yt/user/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "203.0.113.8") + req.RemoteAddr = "192.0.2.1:1234" + + var seenPassword string + recorder := perform(func(c *gin.Context) { + var login struct { + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&login); err != nil { + t.Errorf("bind body: %v", err) + } + seenPassword = login.Password + panic(config.LoginErr.New()) + }, req) + after := time.Now().Unix() + + if seenPassword != "Secret123!" { + t.Fatalf("handler saw password %q", seenPassword) + } + if got.row == nil { + t.Fatal("log stopped with no row") + } + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"code":11013`) { + t.Fatalf("http status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + row := *got.row + if row.CreateTime < before || row.CreateTime > after { + t.Fatalf("create_time %d not in [%d,%d]", row.CreateTime, before, after) + } + if row.CostMs < 0 || row.CostMs > 5000 { + t.Fatalf("cost_ms %d", row.CostMs) + } + if !strings.HasPrefix(row.Message, "请输入正确的账号和密码") { + t.Fatalf("message %q", row.Message) + } + wantResponse, err := json.Marshal(struct { + Code int `json:"code"` + Message string `json:"message"` + }{Code: 11013, Message: row.Message}) + if err != nil { + t.Fatal(err) + } + if row.Response != string(wantResponse) { + t.Fatalf("response\n got %s\nwant %s", row.Response, wantResponse) + } + + row.CreateTime = 0 + row.CostMs = 0 + row.Message = "" + row.Response = "" + diffLog(t, row, model.AdminOperateLog{ + Account: "admin", + UserID: 0, + RoleID: 0, + IP: "203.0.113.8", + ForwardedFor: "203.0.113.8", + Method: "POST", + Path: "/yt/user/login", + Query: "", + Request: `{"account":"admin","captcha":"W8K2","captcha_id":"cid","password":"Secret123!"}`, + Action: "登录", + Code: 11013, + Success: 0, + Header: `{"Content-Type":"application/json","X-Forwarded-For":"203.0.113.8"}`, + }) +} + +func diffLog(t *testing.T, got, want model.AdminOperateLog) { + t.Helper() + gv := reflect.ValueOf(got) + wv := reflect.ValueOf(want) + var diffs []string + for i := 0; i < gv.NumField(); i++ { + name := gv.Type().Field(i).Name + gotText := fmt.Sprint(gv.Field(i).Interface()) + wantText := fmt.Sprint(wv.Field(i).Interface()) + if gotText != wantText { + diffs = append(diffs, fmt.Sprintf("%s:\n got %s\n want %s", name, gotText, wantText)) + } + } + if len(diffs) > 0 { + t.Fatalf("row mismatch\n%s", strings.Join(diffs, "\n")) + } +} + +func TestOperateLogTruncatesLargeBody(t *testing.T) { + got := captureLog(t) + payload, err := json.Marshal(map[string]any{ + "code": 0, + "message": "OK", + "data": map[string]any{"content": strings.Repeat("页", 5000)}, + }) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/yt/page", strings.NewReader(`{"name":"founder"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("token", "good-token-value") + perform(func(c *gin.Context) { + c.Data(http.StatusOK, "application/json", payload) + }, req) + if got.row == nil { + t.Fatal("create was not logged") + } + if len(got.row.Response) > operateBodyLimit { + t.Fatalf("response len = %d", len(got.row.Response)) + } + if !utf8.ValidString(got.row.Response) { + t.Fatal("truncated response is not valid utf-8") + } +} diff --git a/app/admin/request/user.go b/app/admin/request/user.go index b66ceec..b611d67 100644 --- a/app/admin/request/user.go +++ b/app/admin/request/user.go @@ -29,8 +29,10 @@ type UserList struct { } type UserLogin struct { - Account string `json:"account"` - Password string `json:"password"` + Account string `json:"account"` + Password string `json:"password"` + CaptchaID string `json:"captcha_id"` + Captcha string `json:"captcha"` } type UserLogout struct { diff --git a/app/admin/service/captcha.go b/app/admin/service/captcha.go new file mode 100644 index 0000000..67f79f5 --- /dev/null +++ b/app/admin/service/captcha.go @@ -0,0 +1,168 @@ +package service + +import ( + "bytes" + "encoding/base64" + "image" + "image/color" + "image/png" + "math/rand/v2" + "strings" + "time" + "yintai-api/app/common/cache" + err2 "yintai-api/app/common/err" + + "github.com/smbrave/goutil" +) + +const captchaAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" + +var captchaFont = map[rune][7]byte{ + '2': {0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111}, + '3': {0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110}, + '4': {0b00100, 0b01000, 0b10000, 0b10010, 0b11111, 0b00010, 0b00010}, + '5': {0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110}, + '6': {0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110}, + '7': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000}, + '8': {0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110}, + '9': {0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100}, + 'A': {0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001}, + 'B': {0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110}, + 'C': {0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110}, + 'D': {0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110}, + 'E': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111}, + 'F': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000}, + 'G': {0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01111}, + 'H': {0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001}, + 'J': {0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100}, + 'K': {0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001}, + 'L': {0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111}, + 'M': {0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001}, + 'N': {0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001}, + 'P': {0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000}, + 'Q': {0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101}, + 'R': {0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001}, + 'S': {0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110}, + 'T': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100}, + 'U': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110}, + 'V': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100}, + 'W': {0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b10101, 0b01010}, + 'X': {0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001}, + 'Y': {0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100}, + 'Z': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111}, +} + +func (a *User) Captcha() (string, string) { + code := randomCaptcha(4) + id := goutil.RandomStr(16) + if err := cache.GetRedis().Set(cache.NewKeys().Captcha(id), code, 10*time.Minute).Err(); err != nil { + err2.DbErr("", err) + } + return id, "data:image/png;base64," + base64.StdEncoding.EncodeToString(captchaPNG(code)) +} + +func verifyCaptcha(id, code string) { + id = strings.TrimSpace(id) + code = strings.TrimSpace(code) + if id == "" { + err2.CaptchaErr() + } + key := cache.NewKeys().Captcha(id) + redix := cache.GetRedis() + saved := redix.Get(key).Val() + redix.Del(key) + if code == "" || saved == "" || !strings.EqualFold(saved, code) { + err2.CaptchaErr() + } +} + +func randomCaptcha(n int) string { + buf := make([]byte, n) + for i := range buf { + buf[i] = captchaAlphabet[rand.IntN(len(captchaAlphabet))] + } + return string(buf) +} + +func captchaPNG(code string) []byte { + const scale = 3 + charW, charH := 5*scale, 7*scale + gap, padX, padY := 10, 14, 12 + width := padX*2 + len(code)*charW + (len(code)-1)*gap + height := padY*2 + charH + 8 + img := image.NewRGBA(image.Rect(0, 0, width, height)) + bg := color.RGBA{245, 246, 248, 255} + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, bg) + } + } + for range 8 { + c := color.RGBA{uint8(160 + rand.IntN(70)), uint8(160 + rand.IntN(70)), uint8(160 + rand.IntN(70)), 255} + x1, y1 := rand.IntN(width), rand.IntN(height) + x2, y2 := rand.IntN(width), rand.IntN(height) + drawLine(img, x1, y1, x2, y2, c) + } + for i, r := range code { + glyph, ok := captchaFont[r] + if !ok { + continue + } + ox := padX + i*(charW+gap) + rand.IntN(3) - 1 + oy := padY + rand.IntN(5) + ink := color.RGBA{uint8(rand.IntN(80)), uint8(rand.IntN(80)), uint8(rand.IntN(80)), 255} + for row := 0; row < 7; row++ { + bits := glyph[row] + for col := 0; col < 5; col++ { + if bits&(1< x2 { + sx = -1 + } + if y1 > y2 { + sy = -1 + } + err := dx - dy + for { + if image.Pt(x1, y1).In(img.Bounds()) { + img.Set(x1, y1, c) + } + if x1 == x2 && y1 == y2 { + return + } + e2 := 2 * err + if e2 > -dy { + err -= dy + x1 += sx + } + if e2 < dx { + err += dx + y1 += sy + } + } +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} diff --git a/app/admin/service/captcha_test.go b/app/admin/service/captcha_test.go new file mode 100644 index 0000000..ea45c75 --- /dev/null +++ b/app/admin/service/captcha_test.go @@ -0,0 +1,42 @@ +package service + +import ( + "bytes" + "image/png" + "strings" + "testing" +) + +func TestRandomCaptcha(t *testing.T) { + code := randomCaptcha(4) + if len(code) != 4 { + t.Fatalf("len = %d", len(code)) + } + for _, r := range code { + if !strings.ContainsRune(captchaAlphabet, r) { + t.Fatalf("unexpected char %q", r) + } + if _, ok := captchaFont[r]; !ok { + t.Fatalf("missing glyph %q", r) + } + } +} + +func TestCaptchaPNG(t *testing.T) { + raw := captchaPNG("W8K2") + img, err := png.Decode(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + if img.Bounds().Dx() < 40 || img.Bounds().Dy() < 20 { + t.Fatalf("image too small: %v", img.Bounds()) + } +} + +func TestCaptchaFontCoversAlphabet(t *testing.T) { + for _, r := range captchaAlphabet { + if _, ok := captchaFont[r]; !ok { + t.Fatalf("alphabet char %q has no glyph", r) + } + } +} diff --git a/app/admin/service/user.go b/app/admin/service/user.go index b8b3deb..c5f4b08 100644 --- a/app/admin/service/user.go +++ b/app/admin/service/user.go @@ -35,9 +35,7 @@ func (a *User) Create(req request.UserCreate) int64 { } req.Password = strings.TrimSpace(req.Password) - if len(req.Password) < 1 { - err2.ParamErr(": password too short") - } + checkPassword(req.Password) if cast.ToInt64(req.Role) != 0 { // 检查 role 是否存在 @@ -118,13 +116,11 @@ func (a *User) Update(req request.UserUpdate) { } } - // 不能修改密码 - //req.Password = strings.TrimSpace(req.Password) - //if len(req.Password) < 1 { - // err2.ParamErr(": password too short") - //} - // - //user.Password = goutil.Md5(req.Password + Salt) + req.Password = strings.TrimSpace(req.Password) + if req.Password != "" { + checkPassword(req.Password) + user.Password = goutil.Md5(req.Password + model.Salt) + } err = global.GetDB().Transaction(func(tx *gorm.DB) error { if err = dao.NewUser(tx).Update(user); err != nil { @@ -154,6 +150,29 @@ func (a *User) Update(req request.UserUpdate) { } +func checkPassword(password string) { + n := len([]rune(password)) + if n < 8 || n > 16 { + err2.ParamErr("password length must be between 8 and 16") + } + var upper, lower, digit, special bool + for _, r := range password { + switch { + case r >= 'A' && r <= 'Z': + upper = true + case r >= 'a' && r <= 'z': + lower = true + case r >= '0' && r <= '9': + digit = true + default: + special = true + } + } + if !upper || !lower || !digit || !special { + err2.ParamErr("password must include uppercase, lowercase, digit and special character") + } +} + func (a *User) Delete(req request.UserDelete) { var user *model.User var err error @@ -190,18 +209,20 @@ func (a *User) List(req request.UserList) (any, int64) { } func (a *User) Login(req request.UserLogin) any { + verifyCaptcha(req.CaptchaID, req.Captcha) + user, err := dao.NewUser().GetWithRole(req.Account) if err != nil { err2.DbErr("", err) } if user == nil { - err2.NotEsxErr("") + err2.LoginErr() return "" } if user.Password != goutil.Md5(req.Password+model.Salt) { - err2.PriErr(": wrong password") + err2.LoginErr() } token := fmt.Sprintf("%s", goutil.RandomStr(64)) diff --git a/app/common/cache/keys.go b/app/common/cache/keys.go index 6b0c6b7..cd63eed 100644 --- a/app/common/cache/keys.go +++ b/app/common/cache/keys.go @@ -31,3 +31,7 @@ func (k Keys) UserToken(account string) string { func (k Keys) Api(roleID int64) string { return fmt.Sprintf("%s%d", "home:user:api:", roleID) } + +func (k Keys) Captcha(id string) string { + return "home:user:captcha:" + id +} diff --git a/app/common/dao/admin_operate_log.go b/app/common/dao/admin_operate_log.go new file mode 100644 index 0000000..473be75 --- /dev/null +++ b/app/common/dao/admin_operate_log.go @@ -0,0 +1,23 @@ +package dao + +import ( + "yintai-api/app/common/model" + + "gorm.io/gorm" +) + +type AdminOperateLog struct { + db *gorm.DB +} + +func NewAdminOperateLog(db ...*gorm.DB) *AdminOperateLog { + return &AdminOperateLog{db: getDB(db...)} +} + +func (r *AdminOperateLog) TableName() string { + return "yt_admin_operate_log" +} + +func (r *AdminOperateLog) Create(m *model.AdminOperateLog) error { + return r.db.Table(r.TableName()).Create(m).Error +} diff --git a/app/common/err/err.go b/app/common/err/err.go index 3bfc5fc..9ddd7e5 100644 --- a/app/common/err/err.go +++ b/app/common/err/err.go @@ -6,46 +6,54 @@ import ( "github.com/gogap/errors" ) -func errCatch(template errors.ErrCodeTemplate, err ...interface{}) { +func errCatch(template errors.ErrCodeTemplate, err ...any) { panic(template.New().Append(err...)) } -func ParamErr(err ...interface{}) { +func ParamErr(err ...any) { errCatch(config.ErrParam, err) } -func DbErr(err ...interface{}) { +func DbErr(err ...any) { errCatch(config.ErrDb, err) } -func PriErr(err ...interface{}) { +func PriErr(err ...any) { errCatch(config.ErrPriv, err) } -func EsxErr(err ...interface{}) { +func EsxErr(err ...any) { errCatch(config.ErrExist, err) } -func NotEsxErr(err ...interface{}) { +func NotEsxErr(err ...any) { errCatch(config.ErrNotExists, err) } -func InnerErr(err ...interface{}) { +func InnerErr(err ...any) { errCatch(config.ErrInternal, err) } -func TokenErr(err ...interface{}) { +func TokenErr(err ...any) { errCatch(config.ErrNeedLogin, err) } -func ExtNotSupportErr(err ...interface{}) { +func ExtNotSupportErr(err ...any) { errCatch(config.ErrExt, err) } -func TranslateErr(err ...interface{}) { +func TranslateErr(err ...any) { errCatch(config.TranslateErr, err) } -func InUseErr(err ...interface{}) { +func InUseErr(err ...any) { errCatch(config.InUseErr, err) } + +func LoginErr(err ...any) { + errCatch(config.LoginErr, err) +} + +func CaptchaErr(err ...any) { + errCatch(config.CaptchaErr, err) +} diff --git a/app/common/model/admin_operate_log.go b/app/common/model/admin_operate_log.go new file mode 100644 index 0000000..97d96bd --- /dev/null +++ b/app/common/model/admin_operate_log.go @@ -0,0 +1,22 @@ +package model + +type AdminOperateLog struct { + ID int64 + Account string + UserID int64 + RoleID int64 + CreateTime int64 + CostMs int + IP string + ForwardedFor string + Method string + Path string + Query string + Request string + Action string + Code int + Message string + Response string + Success int + Header string +} diff --git a/app/routers/admin_router.go b/app/routers/admin_router.go index cd4ad90..7a77a2f 100644 --- a/app/routers/admin_router.go +++ b/app/routers/admin_router.go @@ -14,8 +14,8 @@ func initAdminRouter(engine *gin.Engine) { noAdminGroup := engine.Group("/yt") adminGroup := engine.Group("/yt") - adminGroup.Use(middleware2.Recovery).Use(middleware.Login).Use(middleware.Permission()) - noAdminGroup.Use(middleware2.Recovery) + adminGroup.Use(middleware.OperateLog()).Use(middleware2.Recovery).Use(middleware.Login).Use(middleware.Permission()) + noAdminGroup.Use(middleware.OperateLog()).Use(middleware2.Recovery) user := new(controller.User) adminGroup.POST("/user", user.Create) @@ -23,7 +23,8 @@ func initAdminRouter(engine *gin.Engine) { adminGroup.PUT("/user", user.Update) adminGroup.DELETE("/user", user.Delete) adminGroup.GET("/user/logout", user.Logout) - noAdminGroup.POST("/user/login", user.Login) + noAdminGroup.GET("/user/captcha", middleware.LoginLimit(), user.Captcha) + noAdminGroup.POST("/user/login", middleware.LoginLimit(), user.Login) resource := new(controller.Resource) adminGroup.GET("/resource", resource.List) diff --git a/base/config/error.go b/base/config/error.go index f0656ff..ee14d31 100644 --- a/base/config/error.go +++ b/base/config/error.go @@ -33,4 +33,6 @@ var ( ErrExt = errors.T(11010, "不支持的文件格式") TranslateErr = errors.T(11011, "翻译失败") InUseErr = errors.T(11012, "正在使用,如需删除请需求关联") + LoginErr = errors.T(11013, "请输入正确的账号和密码") + CaptchaErr = errors.T(11014, "验证码错误") ) diff --git a/scripts/ddl_yt_admin_operate_log.sql b/scripts/ddl_yt_admin_operate_log.sql new file mode 100644 index 0000000..8650c9a --- /dev/null +++ b/scripts/ddl_yt_admin_operate_log.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS `yt_admin_operate_log` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `account` varchar(64) NOT NULL DEFAULT '' COMMENT '登录名,登录失败时取请求里的账号', + `user_id` bigint NOT NULL DEFAULT 0 COMMENT '用户ID,未能识别时为0', + `role_id` bigint NOT NULL DEFAULT 0 COMMENT '角色ID', + `create_time` bigint NOT NULL DEFAULT 0 COMMENT '操作时间,unix秒', + `cost_ms` int NOT NULL DEFAULT 0 COMMENT '耗时,毫秒', + `ip` varchar(64) NOT NULL DEFAULT '' COMMENT '客户端IP', + `forwarded_for` varchar(512) NOT NULL DEFAULT '' COMMENT '原始X-Forwarded-For', + `method` varchar(16) NOT NULL DEFAULT '' COMMENT '请求方法', + `path` varchar(255) NOT NULL DEFAULT '' COMMENT '请求路径', + `query` varchar(1024) NOT NULL DEFAULT '' COMMENT 'URL查询参数', + `request` text NOT NULL COMMENT '请求体,密码明文,token已打码,超长截断', + `action` varchar(64) NOT NULL DEFAULT '' COMMENT '动作名称', + `code` int NOT NULL DEFAULT 0 COMMENT '业务码,0表示成功', + `message` varchar(512) NOT NULL DEFAULT '' COMMENT '返回文案', + `response` text NOT NULL COMMENT '响应体,token已打码,验证码图片不保存,超长截断', + `success` tinyint NOT NULL DEFAULT 0 COMMENT '1成功 0失败', + `header` text NOT NULL COMMENT '请求头,token和cookie已打码', + PRIMARY KEY (`id`), + KEY `idx_account_time` (`account`, `create_time`), + KEY `idx_create_time` (`create_time`), + KEY `idx_path_time` (`path`, `create_time`), + KEY `idx_ip_time` (`ip`, `create_time`), + KEY `idx_success_time` (`success`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员操作日志';