feat: 注册流程强制图形验证码

SSR 注册页与 /api/register 共用 CaptchaService,防刷同时保留换一张无 JS 刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 21:13:24 +08:00
parent 5e623f67fb
commit 54f5de07a4
12 changed files with 111 additions and 6 deletions

View File

@@ -30,7 +30,7 @@
## B. 认证与个人中心
- [x] 注册(用户名、密码、昵称、邮箱;可选邮箱验证码)— SSR `/register`
- [ ] 图形验证码接口(注册流程)
- [x] 图形验证码接口(注册流程)— SSR 注册页强制;`GET /api/captcha` + `POST /api/register` 校验
- [x] 登录 / 登出opaque session Cookie `jiang13_session`)— SSRSameSite=Lax登出/禁言/改密吊销
- [x] 忘记密码:邮箱验证码 + 重置 — SSR `/forgot-password`(依赖 SMTP 就绪)
- [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP

View File

@@ -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 }` |

View File

@@ -17,7 +17,7 @@
| 路径 | 说明 | SSR |
|------|------|-----|
| `/login` | 登录 / 登出 | 已迁 |
| `/register` | 注册(邮件就绪时要验证码) | 已迁 |
| `/register` | 注册(图形验证码;邮件就绪时要邮箱验证码) | 已迁 |
| `/forgot-password` | 忘记密码 | 已迁 |
### 1.2 前台

View File

@@ -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`;冲突时改代码并回写规格 |

View File

@@ -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;

View File

@@ -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()

View File

@@ -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)

View File

@@ -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 {

View File

@@ -28,6 +28,7 @@ type Deps struct {
Mail *services.MailService
SitePage *services.SitePageService
Report *services.ReportService
Captcha *services.CaptchaService
}
// SitePageLink 导航/页脚站点单页链接

34
services/captcha_test.go Normal file
View File

@@ -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")
}
}

View File

@@ -31,6 +31,14 @@
<label>确认密码
<input type="password" name="password2" required autocomplete="new-password" minlength="6"/>
</label>
<label>图形验证码
<div class="j13-form__row j13-captcha">
<input type="hidden" name="captcha_id" value="{{.CaptchaID}}"/>
{{if .CaptchaSVG}}<span class="j13-captcha__img" aria-hidden="true">{{.CaptchaSVG}}</span>{{end}}
<input name="captcha" required autocomplete="off" maxlength="8" placeholder="请输入图中字符"/>
<a href="/register" class="j13-btn-secondary">换一张</a>
</div>
</label>
<button type="submit">注册</button>
</form>
<p><a href="/login">已有账号?登录</a> · <a href="/">返回首页</a></p>

View File

@@ -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;