feat: 注册流程强制图形验证码
SSR 注册页与 /api/register 共用 CaptchaService,防刷同时保留换一张无 JS 刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 }` |
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
| 路径 | 说明 | SSR |
|
||||
|------|------|-----|
|
||||
| `/login` | 登录 / 登出 | 已迁 |
|
||||
| `/register` | 注册(邮件就绪时要验证码) | 已迁 |
|
||||
| `/register` | 注册(图形验证码;邮件就绪时要邮箱验证码) | 已迁 |
|
||||
| `/forgot-password` | 忘记密码 | 已迁 |
|
||||
|
||||
### 1.2 前台
|
||||
|
||||
@@ -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`;冲突时改代码并回写规格 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
34
services/captcha_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user