feat: 认证接口 JSON 化——/api/auth/login|register|logout
- 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 ./... 全绿
This commit is contained in:
11 files changed
+222
-106
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
+53
-30
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// 未知用户的键必须被计入失败次数(若限流器共享),
|
||||
|
||||
+63
-18
@@ -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
|
||||
|
||||
+4
-2
@@ -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": "允许用户注册",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
// 受保护的后台路由(仅管理员角色)。
|
||||
|
||||
+6
-2
@@ -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": "",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "dash_title"}}</h2>
|
||||
<p class="text-gray-500 mt-1">{{index .Tr "dash_welcome"}} <span class="font-medium text-gray-700">{{.Username}}</span>!</p>
|
||||
</div>
|
||||
<form action="/logout" method="post" class="m-0">
|
||||
<form action="/logout" method="post" class="m-0 logout-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</a>
|
||||
{{end}}
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<form action="/logout" method="post" class="m-0">
|
||||
<form action="/logout" method="post" class="m-0 logout-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer bg-transparent border-none">
|
||||
{{index .Tr "logout"}}
|
||||
@@ -224,6 +224,17 @@
|
||||
if (el) { el.textContent = msg; el.classList.remove('hidden'); }
|
||||
};
|
||||
|
||||
// Logout forms(class=logout-form)统一改走 JSON API 后跳转首页。
|
||||
document.addEventListener('submit', function (e) {
|
||||
var form = e.target;
|
||||
if (form.classList && form.classList.contains('logout-form')) {
|
||||
e.preventDefault();
|
||||
blogAPI('POST', '/api/auth/logout').then(function (r) {
|
||||
window.location.href = (r && r.redirect) || '/';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function toggleDropdown() {
|
||||
var menu = document.getElementById('dropdownMenu');
|
||||
menu.classList.toggle('hidden');
|
||||
|
||||
@@ -4,13 +4,11 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">{{index .Tr "login_title"}}</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
<div id="loginError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/login" method="post" class="space-y-5">
|
||||
<form id="loginForm" action="/login" method="post" class="space-y-5">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "login_username"}}</label>
|
||||
@@ -51,4 +49,21 @@
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('loginForm');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
if (btn) btn.disabled = true;
|
||||
blogAPI('POST', '/api/auth/login', blogForm(form, btn)).then(function (r) {
|
||||
if (btn) btn.disabled = false;
|
||||
if (r.ok) { window.location.href = r.redirect || '/'; }
|
||||
else { blogShowError('loginError', r.error || 'Failed to sign in.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -4,13 +4,11 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">{{index .Tr "register_title"}}</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
<div id="registerError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/register" method="post" class="space-y-5">
|
||||
<form id="registerForm" action="/register" method="post" class="space-y-5">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_username"}}</label>
|
||||
@@ -88,4 +86,21 @@
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('registerForm');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
if (btn) btn.disabled = true;
|
||||
blogAPI('POST', '/api/auth/register', blogForm(form, btn)).then(function (r) {
|
||||
if (btn) btn.disabled = false;
|
||||
if (r.ok) { window.location.href = r.redirect || '/'; }
|
||||
else { blogShowError('registerError', r.error || 'Registration failed.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user