From 54f5de07a4f2532a2c46054846c007d42b4af0ba Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 29 Aug 2026 21:13:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B3=A8=E5=86=8C=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E5=9B=BE=E5=BD=A2=E9=AA=8C=E8=AF=81=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSR 注册页与 /api/register 共用 CaptchaService,防刷同时保留换一张无 JS 刷新。 Co-authored-by: Cursor --- docs/rebuild-spec/02-features.md | 2 +- docs/rebuild-spec/04-api.md | 2 +- docs/rebuild-spec/06-pages-ux.md | 2 +- .../rebuild-spec/08-gitea-ssr-architecture.md | 2 +- public/assets/site.css | 18 ++++++++++ routers/api/handlers.go | 6 ++++ routers/setup.go | 4 +++ routers/web/auth.go | 20 +++++++++-- routers/web/chrome.go | 1 + services/captcha_test.go | 34 +++++++++++++++++++ templates/auth/register.tmpl | 8 +++++ web_src/css/site.css | 18 ++++++++++ 12 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 services/captcha_test.go diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 6dc687a..9e1e66c 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -30,7 +30,7 @@ ## B. 认证与个人中心 - [x] 注册(用户名、密码、昵称、邮箱;可选邮箱验证码)— SSR `/register` -- [ ] 图形验证码接口(注册流程) +- [x] 图形验证码接口(注册流程)— SSR 注册页强制;`GET /api/captcha` + `POST /api/register` 校验 - [x] 登录 / 登出(opaque session Cookie `jiang13_session`)— SSR;SameSite=Lax;登出/禁言/改密吊销 - [x] 忘记密码:邮箱验证码 + 重置 — SSR `/forgot-password`(依赖 SMTP 就绪) - [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP) diff --git a/docs/rebuild-spec/04-api.md b/docs/rebuild-spec/04-api.md index 98dac1a..244951a 100644 --- a/docs/rebuild-spec/04-api.md +++ b/docs/rebuild-spec/04-api.md @@ -102,7 +102,7 @@ | 方法 | 路径 | Body | 响应 | |------|------|------|------| -| POST | `/api/register` | Form: username, password, nickname, email, email_code? | 成功后通常种 cookie | +| POST | `/api/register` | Form/JSON: username, password, nickname, email, captcha_id, captcha, email_code? | 成功后通常种 cookie | | POST | `/api/login` | Form: username, password | 种 cookie | | POST | `/api/register/email-code` | JSON `{ email }` | `{ message }` | | POST | `/api/password-reset/email-code` | JSON `{ email }` | `{ message }` | diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index d87e357..bafb0b5 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -17,7 +17,7 @@ | 路径 | 说明 | SSR | |------|------|-----| | `/login` | 登录 / 登出 | 已迁 | -| `/register` | 注册(邮件就绪时要验证码) | 已迁 | +| `/register` | 注册(图形验证码;邮件就绪时要邮箱验证码) | 已迁 | | `/forgot-password` | 忘记密码 | 已迁 | ### 1.2 前台 diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md index 945e49c..b67e989 100644 --- a/docs/rebuild-spec/08-gitea-ssr-architecture.md +++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md @@ -12,7 +12,7 @@ |----|------| | 公开页渲染 | Go `html/template` **真 SSR** | | 浏览器写操作 | `routers/web` HTML 表单 POST + CSRF + PRG | -| JSON `/api` | **仅机器客户端**(OIDC 等);不服务已迁页面 UI | +| JSON `/api` | **仅机器客户端**(OIDC、health、`/api/captcha`、`/api/register` 等);不服务已迁页面 UI | | 渐进增强 | `web_src/` → `public/assets/` → `/ssr-assets/` | | 发布 | 单二进制 + `go:embed` | | 业务语义 | `01`–`07`;冲突时改代码并回写规格 | diff --git a/public/assets/site.css b/public/assets/site.css index 1cf6c74..4de600d 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -432,6 +432,24 @@ body.j13-body { margin-top: 0.25rem; } .j13-form__row input { flex: 1; margin-top: 0; } +.j13-captcha { + align-items: center; +} +.j13-captcha__img { + display: inline-flex; + flex-shrink: 0; + border: 1px solid var(--j13-border); + border-radius: var(--j13-radius); + overflow: hidden; + line-height: 0; +} +.j13-captcha__img svg { display: block; } +.j13-captcha a.j13-btn-secondary { + flex-shrink: 0; + text-decoration: none; + display: inline-flex; + align-items: center; +} .j13-btn-secondary { margin-top: 0 !important; padding: 0.45rem 0.75rem; diff --git a/routers/api/handlers.go b/routers/api/handlers.go index 4abd801..70ada6b 100644 --- a/routers/api/handlers.go +++ b/routers/api/handlers.go @@ -237,11 +237,17 @@ func (h *Handlers) APIRegister(c *gin.Context) { Nickname string `json:"nickname" form:"nickname"` Email string `json:"email" form:"email" binding:"required"` EmailCode string `json:"email_code" form:"email_code"` + CaptchaID string `json:"captcha_id" form:"captcha_id"` + Captcha string `json:"captcha" form:"captcha"` } if err := c.ShouldBind(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) return } + if h.Captcha == nil || !h.Captcha.Verify(req.CaptchaID, req.Captcha) { + c.JSON(http.StatusBadRequest, gin.H{"error": "验证码错误或已过期"}) + return + } userCount := h.Auth.UserCount() mailReady := h.Settings.MailReady() diff --git a/routers/setup.go b/routers/setup.go index 9a42cfc..b2897a1 100644 --- a/routers/setup.go +++ b/routers/setup.go @@ -107,6 +107,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Mail: mailSvc, SitePage: sitePageSvc, Report: reportSvc, + Captcha: captchaSvc, }, authMW) r.GET("/media/thumb/*filepath", h.ServeImageThumb) @@ -114,6 +115,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { r.GET("/health", h.APIHealth) r.GET("/robots.txt", h.RobotsTxt) r.GET("/sitemap.xml", h.SitemapXML) + // 机器注册辅助(与 SSR 注册共用 CaptchaService) + r.GET("/api/captcha", h.APICaptcha) + r.POST("/api/register", h.APIRegister) // OIDC Provider(外部机器 / Gitea SSO) r.GET("/.well-known/openid-configuration", h.OIDCDiscovery) diff --git a/routers/web/auth.go b/routers/web/auth.go index 65c554d..124c5c0 100644 --- a/routers/web/auth.go +++ b/routers/web/auth.go @@ -1,6 +1,7 @@ package web import ( + "html/template" "net/http" "strings" @@ -22,6 +23,8 @@ type registerData struct { Email string MailReady bool RequireEmailCode bool + CaptchaID string + CaptchaSVG template.HTML } type registerForm struct { @@ -151,6 +154,12 @@ func (d Deps) RegisterPost(c *gin.Context) { d.renderRegister(ctx, "操作过于频繁,请稍后再试", form) return } + captchaID := strings.TrimSpace(c.PostForm("captcha_id")) + captchaAns := strings.TrimSpace(c.PostForm("captcha")) + if d.Captcha == nil || !d.Captcha.Verify(captchaID, captchaAns) { + d.renderRegister(ctx, "验证码错误或已过期", form) + return + } mailReady := d.Settings.MailReady() if mailReady { code := strings.TrimSpace(c.PostForm("email_code")) @@ -197,14 +206,21 @@ func (d Deps) renderRegister(ctx *webctx.Context, errMsg string, form registerFo func (d Deps) renderRegisterWithChrome(ctx *webctx.Context, chrome PageChrome, errMsg string, form registerForm) { chrome.Error = errMsg mailReady := d.Settings.MailReady() - ctx.HTML(http.StatusOK, "auth/register", registerData{ + data := registerData{ PageChrome: chrome, Username: form.Username, Nickname: form.Nickname, Email: form.Email, MailReady: mailReady, RequireEmailCode: mailReady, - }) + } + if d.Captcha != nil { + if id, svg, err := d.Captcha.Generate(); err == nil { + data.CaptchaID = id + data.CaptchaSVG = template.HTML(svg) + } + } + ctx.HTML(http.StatusOK, "auth/register", data) } type forgotPasswordData struct { diff --git a/routers/web/chrome.go b/routers/web/chrome.go index 28d8b40..a0b1cdb 100644 --- a/routers/web/chrome.go +++ b/routers/web/chrome.go @@ -28,6 +28,7 @@ type Deps struct { Mail *services.MailService SitePage *services.SitePageService Report *services.ReportService + Captcha *services.CaptchaService } // SitePageLink 导航/页脚站点单页链接 diff --git a/services/captcha_test.go b/services/captcha_test.go new file mode 100644 index 0000000..c053de2 --- /dev/null +++ b/services/captcha_test.go @@ -0,0 +1,34 @@ +package services + +import "testing" + +func TestCaptchaGenerateAndVerify(t *testing.T) { + s := NewCaptchaService() + id, svg, err := s.Generate() + if err != nil { + t.Fatal(err) + } + if id == "" || len(svg) < 20 { + t.Fatalf("bad captcha output id=%q svgLen=%d", id, len(svg)) + } + s.mu.Lock() + ans := s.entries[id].answer + s.mu.Unlock() + if ans == "" { + t.Fatal("empty stored answer") + } + if !s.Verify(id, ans) { + t.Fatalf("correct answer %q should pass", ans) + } + if s.Verify(id, ans) { + t.Fatal("captcha should be one-time") + } + + id2, _, err := s.Generate() + if err != nil { + t.Fatal(err) + } + if s.Verify(id2, "XXXX") { + t.Fatal("wrong answer should fail") + } +} diff --git a/templates/auth/register.tmpl b/templates/auth/register.tmpl index f5abbe9..da3f6f8 100644 --- a/templates/auth/register.tmpl +++ b/templates/auth/register.tmpl @@ -31,6 +31,14 @@ +

已有账号?登录 · 返回首页

diff --git a/web_src/css/site.css b/web_src/css/site.css index 1cf6c74..4de600d 100644 --- a/web_src/css/site.css +++ b/web_src/css/site.css @@ -432,6 +432,24 @@ body.j13-body { margin-top: 0.25rem; } .j13-form__row input { flex: 1; margin-top: 0; } +.j13-captcha { + align-items: center; +} +.j13-captcha__img { + display: inline-flex; + flex-shrink: 0; + border: 1px solid var(--j13-border); + border-radius: var(--j13-radius); + overflow: hidden; + line-height: 0; +} +.j13-captcha__img svg { display: block; } +.j13-captcha a.j13-btn-secondary { + flex-shrink: 0; + text-decoration: none; + display: inline-flex; + align-items: center; +} .j13-btn-secondary { margin-top: 0 !important; padding: 0.45rem 0.75rem;