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

View File

@@ -102,7 +102,7 @@
| 方法 | 路径 | Body | 响应 | | 方法 | 路径 | 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/login` | Form: username, password | 种 cookie |
| POST | `/api/register/email-code` | JSON `{ email }` | `{ message }` | | POST | `/api/register/email-code` | JSON `{ email }` | `{ message }` |
| POST | `/api/password-reset/email-code` | JSON `{ email }` | `{ message }` | | POST | `/api/password-reset/email-code` | JSON `{ email }` | `{ message }` |

View File

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

View File

@@ -12,7 +12,7 @@
|----|------| |----|------|
| 公开页渲染 | Go `html/template` **真 SSR** | | 公开页渲染 | Go `html/template` **真 SSR** |
| 浏览器写操作 | `routers/web` HTML 表单 POST + CSRF + PRG | | 浏览器写操作 | `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/` | | 渐进增强 | `web_src/``public/assets/``/ssr-assets/` |
| 发布 | 单二进制 + `go:embed` | | 发布 | 单二进制 + `go:embed` |
| 业务语义 | `01``07`;冲突时改代码并回写规格 | | 业务语义 | `01``07`;冲突时改代码并回写规格 |

View File

@@ -432,6 +432,24 @@ body.j13-body {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.j13-form__row input { flex: 1; margin-top: 0; } .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 { .j13-btn-secondary {
margin-top: 0 !important; margin-top: 0 !important;
padding: 0.45rem 0.75rem; padding: 0.45rem 0.75rem;

View File

@@ -237,11 +237,17 @@ func (h *Handlers) APIRegister(c *gin.Context) {
Nickname string `json:"nickname" form:"nickname"` Nickname string `json:"nickname" form:"nickname"`
Email string `json:"email" form:"email" binding:"required"` Email string `json:"email" form:"email" binding:"required"`
EmailCode string `json:"email_code" form:"email_code"` 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 { if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return return
} }
if h.Captcha == nil || !h.Captcha.Verify(req.CaptchaID, req.Captcha) {
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码错误或已过期"})
return
}
userCount := h.Auth.UserCount() userCount := h.Auth.UserCount()
mailReady := h.Settings.MailReady() mailReady := h.Settings.MailReady()

View File

@@ -107,6 +107,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Mail: mailSvc, Mail: mailSvc,
SitePage: sitePageSvc, SitePage: sitePageSvc,
Report: reportSvc, Report: reportSvc,
Captcha: captchaSvc,
}, authMW) }, authMW)
r.GET("/media/thumb/*filepath", h.ServeImageThumb) 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("/health", h.APIHealth)
r.GET("/robots.txt", h.RobotsTxt) r.GET("/robots.txt", h.RobotsTxt)
r.GET("/sitemap.xml", h.SitemapXML) r.GET("/sitemap.xml", h.SitemapXML)
// 机器注册辅助(与 SSR 注册共用 CaptchaService
r.GET("/api/captcha", h.APICaptcha)
r.POST("/api/register", h.APIRegister)
// OIDC Provider外部机器 / Gitea SSO // OIDC Provider外部机器 / Gitea SSO
r.GET("/.well-known/openid-configuration", h.OIDCDiscovery) r.GET("/.well-known/openid-configuration", h.OIDCDiscovery)

View File

@@ -1,6 +1,7 @@
package web package web
import ( import (
"html/template"
"net/http" "net/http"
"strings" "strings"
@@ -22,6 +23,8 @@ type registerData struct {
Email string Email string
MailReady bool MailReady bool
RequireEmailCode bool RequireEmailCode bool
CaptchaID string
CaptchaSVG template.HTML
} }
type registerForm struct { type registerForm struct {
@@ -151,6 +154,12 @@ func (d Deps) RegisterPost(c *gin.Context) {
d.renderRegister(ctx, "操作过于频繁,请稍后再试", form) d.renderRegister(ctx, "操作过于频繁,请稍后再试", form)
return 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() mailReady := d.Settings.MailReady()
if mailReady { if mailReady {
code := strings.TrimSpace(c.PostForm("email_code")) 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) { func (d Deps) renderRegisterWithChrome(ctx *webctx.Context, chrome PageChrome, errMsg string, form registerForm) {
chrome.Error = errMsg chrome.Error = errMsg
mailReady := d.Settings.MailReady() mailReady := d.Settings.MailReady()
ctx.HTML(http.StatusOK, "auth/register", registerData{ data := registerData{
PageChrome: chrome, PageChrome: chrome,
Username: form.Username, Username: form.Username,
Nickname: form.Nickname, Nickname: form.Nickname,
Email: form.Email, Email: form.Email,
MailReady: mailReady, MailReady: mailReady,
RequireEmailCode: 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 { type forgotPasswordData struct {

View File

@@ -28,6 +28,7 @@ type Deps struct {
Mail *services.MailService Mail *services.MailService
SitePage *services.SitePageService SitePage *services.SitePageService
Report *services.ReportService Report *services.ReportService
Captcha *services.CaptchaService
} }
// SitePageLink 导航/页脚站点单页链接 // 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>确认密码 <label>确认密码
<input type="password" name="password2" required autocomplete="new-password" minlength="6"/> <input type="password" name="password2" required autocomplete="new-password" minlength="6"/>
</label> </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> <button type="submit">注册</button>
</form> </form>
<p><a href="/login">已有账号?登录</a> · <a href="/">返回首页</a></p> <p><a href="/login">已有账号?登录</a> · <a href="/">返回首页</a></p>

View File

@@ -432,6 +432,24 @@ body.j13-body {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.j13-form__row input { flex: 1; margin-top: 0; } .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 { .j13-btn-secondary {
margin-top: 0 !important; margin-top: 0 !important;
padding: 0.45rem 0.75rem; padding: 0.45rem 0.75rem;