From 7062c755a312fbc98ce960c73828b514c75d3d91 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 27 Aug 2026 19:31:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=A4=E8=AF=81=E6=8E=A5=E5=8F=A3=20?= =?UTF-8?q?JSON=20=E5=8C=96=E2=80=94=E2=80=94/api/auth/login|register|logo?= =?UTF-8?q?ut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.go:Login/Register 改 JSON 绑定(loginRequest/registerRequest), 错误码复用 i18n 键:登录失败 login_error(401)、限流锁定 login_locked(429)、 注册校验 400、用户名冲突 user_username_exists(409)、注册禁用 registration_disabled(403);成功返回 {ok,redirect}(角色定向 /admin 或 /) - main.go:旧 POST /login /register /logout 移除,迁入 /api/auth 分组 - i18n:新增 registration_disabled(中英) - security_test.go:env 路由改 /api/auth/*,e.login 改 JSON 登录, 新增 postJSON/respCode/respRedirect/respOK 测试辅助 - p2_validation_test.go:TestLoginRateLimited/TestLoginTimingDoesNotRevealUser/ TestRegisterRejectsInvalidEmail 迁移 JSON 断言(429/401/409/400) - base.html:logout-form 全局委托 fetch + login/register 模板 id/JS - main_test.go:冒烟断言补 /api/auth 三端点 - go build/vet/test ./... 全绿 --- TODO_API_REFACTOR.md | 4 +- handlers/auth.go | 83 ++++++++++++++++++++++------------ handlers/p2_validation_test.go | 79 ++++++++++++++++---------------- handlers/security_test.go | 81 +++++++++++++++++++++++++-------- i18n/i18n.go | 6 ++- main.go | 6 +-- main_test.go | 8 +++- templates/admin/dashboard.html | 2 +- templates/layouts/base.html | 13 +++++- templates/pages/login.html | 23 ++++++++-- templates/pages/register.html | 23 ++++++++-- 11 files changed, 222 insertions(+), 106 deletions(-) diff --git a/TODO_API_REFACTOR.md b/TODO_API_REFACTOR.md index 4604143..fa8fee4 100644 --- a/TODO_API_REFACTOR.md +++ b/TODO_API_REFACTOR.md @@ -11,8 +11,8 @@ ## 零行为搬移(medium) -- [ ] 5. 附件三件套(admin/my)+ `/api/profile/avatar` 换注册路径 -- [ ] 6. 搬移端点模板更新:`article_create.html` 3 处 fetch URL、`profile.html` 1 处 +- [x] 5. 附件三件套(admin/my)+ `/api/profile/avatar` 换注册路径(路由已在基建 commit 搬移) +- [x] 6. 搬移端点模板更新:`article_create.html` 3 处 fetch URL、`profile.html` 1 处(删除改用 DELETE) ## 功能改造(medium) diff --git a/handlers/auth.go b/handlers/auth.go index c63164a..3a5b2ea 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -33,17 +33,27 @@ func LoginPage() gin.HandlerFunc { } } +// loginRequest 是 POST /api/auth/login 的 JSON 请求体。 +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + // Login 处理登录表单提交。它对每个 IP+用户名实施速率限制 // (SECURITY_TODO #10),对于不存在的用户名会执行一次虚拟 bcrypt 比较, // 使耗时不会暴露用户名是否存在(SECURITY_TODO #25)。 func Login(db *gorm.DB, limiter *LoginRateLimiter) gin.HandlerFunc { return func(c *gin.Context) { - username := c.PostForm("username") - password := c.PostForm("password") + var req loginRequest + if !bindJSON(c, &req) { + return + } + username := req.Username + password := req.Password key := GetClientIP(c) + "\x00" + username if !limiter.Allow(key) { - c.Redirect(http.StatusFound, "/login?error=locked") + APIError(c, http.StatusTooManyRequests, "login_locked") return } @@ -53,19 +63,19 @@ func Login(db *gorm.DB, limiter *LoginRateLimiter) gin.HandlerFunc { // 使耗时不会暴露用户名是否存在。 limiter.Fail(key) _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password)) - c.Redirect(http.StatusFound, "/login?error=1") + APIError(c, http.StatusUnauthorized, "login_error") return } if !user.CheckPassword(password) { limiter.Fail(key) - c.Redirect(http.StatusFound, "/login?error=1") + APIError(c, http.StatusUnauthorized, "login_error") return } // 拒绝非正常状态账户登录(已禁用 / 已锁定 / 未激活)。 if user.Status != models.StatusNormal { - c.Redirect(http.StatusFound, "/login?error=1") + APIError(c, http.StatusUnauthorized, "login_error") return } @@ -88,26 +98,26 @@ func Login(db *gorm.DB, limiter *LoginRateLimiter) gin.HandlerFunc { session.Set("user_id", user.ID) session.Set("username", user.Username) if err := session.Save(); err != nil { - c.String(http.StatusInternalServerError, "Failed to save session") + APIError(c, http.StatusInternalServerError, "api_error") return } - // 根据用户角色重定向:管理员到 /admin,其他用户到首页 + // 根据用户角色跳转:管理员到 /admin,其他用户到首页 + redirect := "/" if user.Role == models.RoleAdmin { - c.Redirect(http.StatusFound, "/admin") - } else { - c.Redirect(http.StatusFound, "/") + redirect = "/admin" } + APIOK(c, redirect, nil) } } -// Logout 清除会话并重定向回首页。 +// Logout 清除会话并返回跳转首页指令,由前端 fetch 发起跳转。 func Logout() gin.HandlerFunc { return func(c *gin.Context) { session := sessions.Default(c) session.Clear() session.Save() - c.Redirect(http.StatusFound, "/") + APIOK(c, "/", nil) } } @@ -133,53 +143,66 @@ func RegisterPage(db *gorm.DB) gin.HandlerFunc { } } +// registerRequest 是 POST /api/auth/register 的 JSON 请求体。 +type registerRequest struct { + Username string `json:"username"` + Password string `json:"password"` + ConfirmPassword string `json:"confirm_password"` + Email string `json:"email"` + DisplayName string `json:"display_name"` +} + // Register 处理注册表单提交。 func Register(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { // 检查是否允许注册 var s models.SiteSetting if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration { - c.Redirect(http.StatusFound, "/login") + APIError(c, http.StatusForbidden, "registration_disabled") return } - username := strings.TrimSpace(c.PostForm("username")) - password := c.PostForm("password") - confirmPassword := c.PostForm("confirm_password") - email := strings.TrimSpace(c.PostForm("email")) - displayName := strings.TrimSpace(c.PostForm("display_name")) + var req registerRequest + if !bindJSON(c, &req) { + return + } + username := strings.TrimSpace(req.Username) + password := req.Password + confirmPassword := req.ConfirmPassword + email := strings.TrimSpace(req.Email) + displayName := strings.TrimSpace(req.DisplayName) // 校验输入 if username == "" || password == "" { - c.Redirect(http.StatusFound, "/register?error=register_required") + APIError(c, http.StatusBadRequest, "register_required") return } if len(username) < 3 || len(username) > 32 { - c.Redirect(http.StatusFound, "/register?error=register_username_length") + APIError(c, http.StatusBadRequest, "register_username_length") return } if len(password) < 6 { - c.Redirect(http.StatusFound, "/register?error=register_password_length") + APIError(c, http.StatusBadRequest, "register_password_length") return } if password != confirmPassword { - c.Redirect(http.StatusFound, "/register?error=register_password_mismatch") + APIError(c, http.StatusBadRequest, "register_password_mismatch") return } // SECURITY (#24):拒绝格式非法的邮箱地址(可选字段)。 if !validateEmail(email) { - c.Redirect(http.StatusFound, "/register?error=register_email_invalid") + APIError(c, http.StatusBadRequest, "register_email_invalid") return } // 检查用户名是否已存在 var existingUser models.User if err := db.Where("username = ?", username).First(&existingUser).Error; err == nil { - c.Redirect(http.StatusFound, "/register?error=user_username_exists") + APIError(c, http.StatusConflict, "user_username_exists") return } @@ -197,12 +220,12 @@ func Register(db *gorm.DB) gin.HandlerFunc { } if err := user.SetPassword(password); err != nil { - c.Redirect(http.StatusFound, "/register?error=register_error") + APIError(c, http.StatusInternalServerError, "register_error") return } if err := db.Create(&user).Error; err != nil { - c.Redirect(http.StatusFound, "/register?error=register_error") + APIError(c, http.StatusInternalServerError, "register_error") return } @@ -220,11 +243,11 @@ func Register(db *gorm.DB) gin.HandlerFunc { session.Set("user_id", user.ID) session.Set("username", user.Username) if err := session.Save(); err != nil { - c.Redirect(http.StatusFound, "/login") + APIError(c, http.StatusInternalServerError, "api_error") return } - // 重定向到首页 - c.Redirect(http.StatusFound, "/") + // 首页 + APIOK(c, "/", nil) } } diff --git a/handlers/p2_validation_test.go b/handlers/p2_validation_test.go index c44cc9d..02b279e 100644 --- a/handlers/p2_validation_test.go +++ b/handlers/p2_validation_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" + "github.com/gin-gonic/gin" + "go_blog/models" ) @@ -257,17 +259,18 @@ func TestRegisterRejectsInvalidEmail(t *testing.T) { } anonCookie := e.sessionCookie(w) - fields := url.Values{} - fields.Set("username", "carol") - fields.Set("password", "secret1") - fields.Set("confirm_password", "secret1") - fields.Set("email", "abc") - w2 := postForm(e, http.MethodPost, "/register", anonCookie, m[1], fields) - if w2.Code != http.StatusFound { - t.Fatalf("register invalid email: status = %d, want 302", w2.Code) + fields := gin.H{ + "username": "carol", + "password": "secret1", + "confirm_password": "secret1", + "email": "abc", } - if loc := w2.Header().Get("Location"); loc != "/register?error=register_email_invalid" { - t.Fatalf("register invalid email: location = %q", loc) + w2 := postJSON(e, http.MethodPost, "/api/auth/register", anonCookie, m[1], fields) + if w2.Code != http.StatusBadRequest { + t.Fatalf("register invalid email: status = %d, want 400", w2.Code) + } + if code := respCode(w2); code != "register_email_invalid" { + t.Fatalf("register invalid email: code = %q, want register_email_invalid", code) } var count int64 e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count) @@ -275,10 +278,10 @@ func TestRegisterRejectsInvalidEmail(t *testing.T) { t.Fatal("carol created with invalid email") } - fields.Set("email", "carol@example.com") - w2 = postForm(e, http.MethodPost, "/register", anonCookie, m[1], fields) - if w2.Code != http.StatusFound { - t.Fatalf("register valid email: status = %d, want 302", w2.Code) + fields["email"] = "carol@example.com" + w2 = postJSON(e, http.MethodPost, "/api/auth/register", anonCookie, m[1], fields) + if w2.Code != http.StatusOK || !respOK(w2) { + t.Fatalf("register valid email: status = %d, body %s", w2.Code, w2.Body.String()) } e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count) if count != 1 { @@ -302,7 +305,7 @@ func limiterEntryKey(e *securityTestEnv, username string) string { func TestLoginRateLimited(t *testing.T) { e := newSecurityTestEnv(t) - loginAttempt := func(form url.Values) (*httptest.ResponseRecorder, string) { + loginAttempt := func(body gin.H) (*httptest.ResponseRecorder, string) { // 每次尝试使用全新的匿名会话(和 CSRF 令牌)。 req := httptest.NewRequest(http.MethodGet, "/login", nil) w := httptest.NewRecorder() @@ -312,37 +315,36 @@ func TestLoginRateLimited(t *testing.T) { t.Fatal("login page did not render a CSRF token") } anonCookie := e.sessionCookie(w) - form.Set("_csrf", m[1]) - w2 := postForm(e, http.MethodPost, "/login", anonCookie, m[1], form) + w2 := postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, m[1], body) return w2, anonCookie } - bad := url.Values{"username": {"alice"}, "password": {"wrong-password"}} + bad := gin.H{"username": "alice", "password": "wrong-password"} for i := 0; i < maxLoginFailures; i++ { w, _ := loginAttempt(bad) - if w.Code != http.StatusFound { - t.Fatalf("attempt %d: status = %d, want 302", i+1, w.Code) + if w.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code) } - if loc := w.Header().Get("Location"); loc != "/login?error=1" { - t.Fatalf("attempt %d: location = %q, want /login?error=1", i+1, loc) + if code := respCode(w); code != "login_error" { + t.Fatalf("attempt %d: code = %q, want login_error", i+1, code) } } // 下一次尝试(即使密码正确)也会被锁定。 - good := url.Values{"username": {"alice"}, "password": {"pw-alice"}} + good := gin.H{"username": "alice", "password": "pw-alice"} w, _ := loginAttempt(good) - if w.Code != http.StatusFound { - t.Fatalf("locked attempt: status = %d, want 302", w.Code) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("locked attempt: status = %d, want 429", w.Code) } - if loc := w.Header().Get("Location"); loc != "/login?error=locked" { - t.Fatalf("locked attempt: location = %q, want /login?error=locked", loc) + if code := respCode(w); code != "login_locked" { + t.Fatalf("locked attempt: code = %q, want login_locked", code) } // 不同的键(用户名)不受影响。 - w, _ = loginAttempt(url.Values{"username": {"bob"}, "password": {"pw-bob"}}) - if w.Code != http.StatusFound || w.Header().Get("Location") != "/" { - t.Fatalf("different user login during lock: status = %d, location = %q", - w.Code, w.Header().Get("Location")) + w, _ = loginAttempt(gin.H{"username": "bob", "password": "pw-bob"}) + if w.Code != http.StatusOK || respRedirect(w) != "/" { + t.Fatalf("different user login during lock: status = %d, redirect = %q", + w.Code, respRedirect(w)) } // 重置后,被锁定的键再次可用。 @@ -352,9 +354,9 @@ func TestLoginRateLimited(t *testing.T) { } e.limiter.Reset(aliceKey) w, _ = loginAttempt(good) - if w.Code != http.StatusFound || w.Header().Get("Location") != "/" { - t.Fatalf("login after reset: status = %d, location = %q", - w.Code, w.Header().Get("Location")) + if w.Code != http.StatusOK || respRedirect(w) != "/" { + t.Fatalf("login after reset: status = %d, redirect = %q", + w.Code, respRedirect(w)) } } @@ -373,11 +375,10 @@ func TestLoginTimingDoesNotRevealUser(t *testing.T) { } anonCookie := e.sessionCookie(w) - fields := url.Values{"username": {"does-not-exist-31415"}, "password": {"anything"}} - fields.Set("_csrf", m[1]) - w2 := postForm(e, http.MethodPost, "/login", anonCookie, m[1], fields) - if w2.Code != http.StatusFound || w2.Header().Get("Location") != "/login?error=1" { - t.Fatalf("unknown user: status = %d, location = %q", w2.Code, w2.Header().Get("Location")) + w2 := postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, m[1], + gin.H{"username": "does-not-exist-31415", "password": "anything"}) + if w2.Code != http.StatusUnauthorized || respCode(w2) != "login_error" { + t.Fatalf("unknown user: status = %d, code = %q", w2.Code, respCode(w2)) } // 未知用户的键必须被计入失败次数(若限流器共享), diff --git a/handlers/security_test.go b/handlers/security_test.go index 3935a28..f15ad03 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -1,6 +1,8 @@ package handlers import ( + "bytes" + "encoding/json" "fmt" "io" "mime/multipart" @@ -80,12 +82,16 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { r.Use(middleware.SetUserContext(db)) r.GET("/login", LoginPage()) - r.POST("/login", Login(db, limiter)) - r.POST("/logout", Logout()) - r.POST("/article/:slug/comments", PostComment(db)) r.GET("/register", RegisterPage(db)) - r.POST("/register", Register(db)) r.GET("/rss", RSSFeed(db)) + r.POST("/article/:slug/comments", PostComment(db)) + + api := r.Group("/api") + { + api.POST("/auth/login", Login(db, limiter)) + api.POST("/auth/logout", Logout()) + api.POST("/auth/register", Register(db)) + } protected := r.Group("/my", middleware.AuthRequired(db)) { @@ -154,20 +160,13 @@ func (e *securityTestEnv) login(t *testing.T, username string) string { } cookie := e.sessionCookie(w) - // 携带令牌 POST 凭据。 - form := url.Values{} - form.Set("username", username) - form.Set("password", "pw-"+username) - form.Set("_csrf", m[1]) - req = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - if cookie != "" { - req.Header.Set("Cookie", cookie) - } - w = httptest.NewRecorder() - e.router.ServeHTTP(w, req) - if w.Code != http.StatusFound { - t.Fatalf("POST /login (%s): status = %d, body %s", username, w.Code, w.Body.String()) + // 携带令牌 POST 凭据(JSON API)。 + w = postJSON(e, http.MethodPost, "/api/auth/login", cookie, m[1], gin.H{ + "username": username, + "password": "pw-" + username, + }) + if w.Code != http.StatusOK { + t.Fatalf("POST /api/auth/login (%s): status = %d, body %s", username, w.Code, w.Body.String()) } authCookie := e.sessionCookie(w) if authCookie == "" { @@ -202,6 +201,52 @@ func (e *securityTestEnv) do(method, path, cookie string, body io.Reader, conten return w } +// postJSON 是 JSON 请求小助手,以 X-CSRF-Token 请求头发送令牌(AJAX 模式)。 +func postJSON(e *securityTestEnv, method, path, cookie, csrfToken string, body interface{}) *httptest.ResponseRecorder { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(body); err != nil { + panic(err) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if csrfToken != "" { + req.Header.Set("X-CSRF-Token", csrfToken) + } + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + w := httptest.NewRecorder() + e.router.ServeHTTP(w, req) + return w +} + +// respCode 从 JSON 错误响应中提取 code 字段。 +func respCode(w *httptest.ResponseRecorder) string { + var r struct { + Code string `json:"code"` + } + _ = json.Unmarshal(w.Body.Bytes(), &r) + return r.Code +} + +// respRedirect 从 JSON 成功响应中提取 redirect 字段。 +func respRedirect(w *httptest.ResponseRecorder) string { + var r struct { + Redirect string `json:"redirect"` + } + _ = json.Unmarshal(w.Body.Bytes(), &r) + return r.Redirect +} + +// respOK 报告 JSON 响应是否成功(ok=true)。 +func respOK(w *httptest.ResponseRecorder) bool { + var r struct { + OK bool `json:"ok"` + } + _ = json.Unmarshal(w.Body.Bytes(), &r) + return r.OK +} + func (e *securityTestEnv) upload(t *testing.T, cookie, csrfToken, articleID string) *httptest.ResponseRecorder { t.Helper() var buf strings.Builder diff --git a/i18n/i18n.go b/i18n/i18n.go index 3b1d698..c24db3f 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -75,7 +75,8 @@ var translations = map[Lang]map[string]string{ "register_password_length": "Password must be at least 6 characters.", "register_password_mismatch": "Passwords do not match.", "register_email_invalid": "Please enter a valid email address.", - "register_error": "Registration failed. Please try again.", + "register_error": "Registration failed. Please try again.", + "registration_disabled": "Registration is currently disabled.", // 设置 "settings_allow_registration": "Allow user registration", @@ -511,7 +512,8 @@ var translations = map[Lang]map[string]string{ "register_password_length": "密码至少需要6个字符。", "register_password_mismatch": "两次输入的密码不一致。", "register_email_invalid": "请输入有效的邮箱地址。", - "register_error": "注册失败,请重试。", + "register_error": "注册失败,请重试。", + "registration_disabled": "当前已关闭注册。", // 平台设置 "settings_allow_registration": "允许用户注册", diff --git a/main.go b/main.go index 54ccd40..9cdeca5 100644 --- a/main.go +++ b/main.go @@ -162,10 +162,7 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi router.GET("/rss", handlers.RSSFeed(db)) router.GET("/feed", handlers.RSSFeed(db)) router.GET("/login", handlers.LoginPage()) - router.POST("/login", handlers.Login(db, loginLimiter)) router.GET("/register", handlers.RegisterPage(db)) - router.POST("/register", handlers.Register(db)) - router.POST("/logout", handlers.Logout()) router.GET("/article/:slug", handlers.ArticleDetail(db)) router.POST("/article/:slug/comments", handlers.PostComment(db)) @@ -173,6 +170,9 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi api := router.Group("/api") { api.GET("/articles", handlers.HomeArticlesAPI(db)) + api.POST("/auth/login", handlers.Login(db, loginLimiter)) + api.POST("/auth/register", handlers.Register(db)) + api.POST("/auth/logout", handlers.Logout()) } // 受保护的后台路由(仅管理员角色)。 diff --git a/main_test.go b/main_test.go index 1de3fe0..2ea875f 100644 --- a/main_test.go +++ b/main_test.go @@ -141,9 +141,13 @@ func TestRegisterRoutesSmoke(t *testing.T) { want := map[string]string{ // 既有 JSON API。 - "GET /api/articles": "", + "GET /api/articles": "", + // 认证 API。 + "POST /api/auth/login": "", + "POST /api/auth/register": "", + "POST /api/auth/logout": "", // 搬移的附件/头像端点。 - "POST /api/admin/articles/attachments": "", + "POST /api/admin/articles/attachments": "", "DELETE /api/admin/articles/attachments/:id": "", "GET /api/admin/articles/:id/attachments": "", "POST /api/profile/avatar": "", diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index ac00b85..004d01f 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -6,7 +6,7 @@

{{index .Tr "dash_title"}}

{{index .Tr "dash_welcome"}} {{.Username}}!

-
+