P1: - 外链图片:markdown 渲染层只放行站内与自有 CDN 图片。配置里的 image_bucket_cdn_host 常写成 host/path,统一归一化后再比对,避免把自家图一起剥掉 - 密码:页面/提问密码最小 6 位;密码比对挪到验证码之后;页面密码失败锁定 5 次/10 分钟 - 客户端 IP:CF-Connecting-IP / EO-Client-IP 只在 TCP 对端可信(回环或 trusted_proxies) 时采信,公网对端带来的权威头一律忽略 P2/P3: - 跳转参数收敛到 shared.SafeRedirectTarget:拒反斜杠与控制字符(url.Parse 会把 %0d%0a 解成 CRLF,伪造 Location),最后再用 path.Clean 验一次前缀 - 注册不再暴露「邮箱已注册 / 已停用」:与全新注册共用同一个成功出口,只在日志留痕 - CSP:默认 enforce,白名单由配置派生,覆盖 umami(运行时注入)、Turnstile 与 按 sentry_dsn 条件放行的 Sentry CDN - /user 与 /_ 下发 no-store;补 X-XSS-Protection、Permissions-Policy,并把安全头前移到 静态资源中间件之前(原先静态文件拿不到 nosniff/HSTS) - 封禁事实落库 ban_records;429 统一带 Retry-After 与本地化文案 - token 比较改常量时间;探索页页码上限;JSON 响应显式 Content-Type 全局复查补的两处: - 私聊 SendMessage 的 POST 缺 conv.OwnerID 校验,被拉黑者可换 domain 继续发消息 - 页面密码 next 与登录 to 同源的反斜杠外跳 验收:verify_p1_fixes 4/4、verify_round2 19/19、verify_page_next 6/6、 verify_chat_owner 3/3(含变异对照);单测与 go build ./... 全通过。
122 lines
4.6 KiB
Go
122 lines
4.6 KiB
Go
// Copyright 2022 E99p1ant. All rights reserved.
|
||
// Use of this source code is governed by a MIT-style
|
||
// license that can be found in the LICENSE file.
|
||
|
||
package auth
|
||
|
||
import (
|
||
"github.com/flamego/recaptcha"
|
||
"github.com/pkg/errors"
|
||
"github.com/sirupsen/logrus"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/captcha"
|
||
"github.com/tamakyi/TamaBox/internal/conf"
|
||
"github.com/tamakyi/TamaBox/internal/context"
|
||
"github.com/tamakyi/TamaBox/internal/db"
|
||
"github.com/tamakyi/TamaBox/internal/form"
|
||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||
"github.com/tamakyi/TamaBox/internal/shared"
|
||
)
|
||
|
||
func Register(ctx context.Context) {
|
||
ctx.Success("auth/register")
|
||
}
|
||
|
||
func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.RecaptchaV3) {
|
||
if ctx.HasError() {
|
||
ctx.Success("auth/register")
|
||
return
|
||
}
|
||
|
||
// Check captcha code (unified: recaptcha, turnstile, or math).
|
||
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
|
||
if err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
|
||
ctx.SetErrorFlash(err.Error())
|
||
ctx.Redirect("/register")
|
||
return
|
||
}
|
||
if !ok {
|
||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
|
||
ctx.Redirect("/register")
|
||
return
|
||
}
|
||
|
||
if err := db.Users.Create(ctx.Request().Context(), db.CreateUserOptions{
|
||
Name: f.Name,
|
||
Password: f.Password,
|
||
Email: f.Email,
|
||
Avatar: conf.Upload.DefaultAvatarURL,
|
||
Domain: f.Domain,
|
||
Background: conf.Upload.DefaultBackground,
|
||
Intro: "问你想问的",
|
||
Qrcodebackcolor: "#FDF8F0",
|
||
Qrcodecolor: "#C45500",
|
||
Usernamecolor: "#000000",
|
||
Introcolor: "#000000",
|
||
Dotscale: "0.35",
|
||
BackgroundImage: conf.Upload.DefaultBackgroundImageURL,
|
||
Backgroundimagealpha: "0.8",
|
||
Qrcodepdpcolor: "#C45500",
|
||
Acfunlink: "none",
|
||
Bililink: "none",
|
||
Weibolink: "none",
|
||
Bloglink: "box.shiroko.one",
|
||
Xiaohongshulink: "none",
|
||
Language: i18n.Resolve(f.Language), // 注册时选择的界面语言;留空回退站点默认
|
||
}); err != nil {
|
||
switch {
|
||
// 防枚举:邮箱是否已注册,对外必须与"注册成功"不可区分。
|
||
//
|
||
// 注册接口原本会直接回"该邮箱已被注册",而找回密码接口(ForgotPasswordAction)
|
||
// 早就统一成"已发送"。两处口径不一致的结果是:任何人都能批量确认
|
||
// "某个邮箱是不是这个匿名提问箱的用户"—— 这是成员关系泄露,比一般站更敏感。
|
||
// 真实原因只写服务端日志(运营排查要看得到到底撞的是哪一类)。
|
||
case errors.Is(err, db.ErrDuplicateEmail):
|
||
logrus.WithContext(ctx.Request().Context()).
|
||
WithField("email", f.Email).
|
||
Warn("注册命中已存在的邮箱,按成功流程静默处理(防枚举)")
|
||
registerSucceeded(ctx)
|
||
return
|
||
case errors.Is(err, db.ErrDeactivatedEmail):
|
||
logrus.WithContext(ctx.Request().Context()).
|
||
WithField("email", f.Email).
|
||
Warn("注册命中已停用账号的邮箱,按成功流程静默处理(防枚举)")
|
||
registerSucceeded(ctx)
|
||
return
|
||
// 域名占用不遮蔽:域名是公开命名空间,/_/<domain> 本来就可探测,
|
||
// 遮蔽只会让用户以为自己注册成功、后续登不上又找不到原因。
|
||
case errors.Is(err, db.ErrDuplicateDomain):
|
||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateDomain")))
|
||
case errors.Is(err, db.ErrDeactivatedDomain):
|
||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDeactivatedDomain")))
|
||
case errors.Is(err, db.ErrBadCredential):
|
||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errBadCredential")))
|
||
|
||
default:
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create new user")
|
||
ctx.SetInternalError()
|
||
}
|
||
|
||
ctx.Success("auth/register")
|
||
return
|
||
}
|
||
|
||
registerSucceeded(ctx)
|
||
}
|
||
|
||
// registerSucceeded 是注册接口唯一的"成功"出口。
|
||
//
|
||
// 抽成函数是为了让"真的建成功了"和"因为邮箱已存在而没建"走同一条路径 ——
|
||
// 一旦两处分别写,响应体的措辞、跳转目标、flash 文案迟早会漂移出差异,
|
||
// 差异就是枚举信号。新增失败分支时若想遮蔽,直接调用本函数即可。
|
||
func registerSucceeded(ctx context.Context) {
|
||
// 成功提示跟随用户注册时选择的语言
|
||
lang := "zh-CN"
|
||
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
|
||
lang = l
|
||
}
|
||
ctx.SetSuccessFlash(i18n.T(lang, "auth.register.success"))
|
||
ctx.Redirect("/login")
|
||
}
|