feat: 注册流程强制图形验证码
SSR 注册页与 /api/register 共用 CaptchaService,防刷同时保留换一张无 JS 刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`)— SSR;SameSite=Lax;登出/禁言/改密吊销
|
- [x] 登录 / 登出(opaque session Cookie `jiang13_session`)— SSR;SameSite=Lax;登出/禁言/改密吊销
|
||||||
- [x] 忘记密码:邮箱验证码 + 重置 — SSR `/forgot-password`(依赖 SMTP 就绪)
|
- [x] 忘记密码:邮箱验证码 + 重置 — SSR `/forgot-password`(依赖 SMTP 就绪)
|
||||||
- [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP)
|
- [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP)
|
||||||
|
|||||||
@@ -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 }` |
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
| 路径 | 说明 | SSR |
|
| 路径 | 说明 | SSR |
|
||||||
|------|------|-----|
|
|------|------|-----|
|
||||||
| `/login` | 登录 / 登出 | 已迁 |
|
| `/login` | 登录 / 登出 | 已迁 |
|
||||||
| `/register` | 注册(邮件就绪时要验证码) | 已迁 |
|
| `/register` | 注册(图形验证码;邮件就绪时要邮箱验证码) | 已迁 |
|
||||||
| `/forgot-password` | 忘记密码 | 已迁 |
|
| `/forgot-password` | 忘记密码 | 已迁 |
|
||||||
|
|
||||||
### 1.2 前台
|
### 1.2 前台
|
||||||
|
|||||||
@@ -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`;冲突时改代码并回写规格 |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
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>确认密码
|
<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user