feat(auth): 登录注册 - 添加短信通知与组合验证支持

This commit is contained in:
ddCat committed 2026-09-22 23:46:15 +08:00
1 parent 6c013ebf7e
commit bc0d109428
55 files changed
+4013 -103

No files matched your search

+82 -16
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/repository"
"log"
"net/mail"
"regexp"
@@ -31,6 +32,9 @@ type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
EmailCode string `json:"emailCode"`
Phone string `json:"phone"`
SMSCode string `json:"smsCode"`
Ticket string `json:"ticket"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
AcceptedTerms bool `json:"acceptedTerms"`
@@ -42,11 +46,14 @@ type LoginRequest struct {
}
type PublicAuthSettings struct {
FirstUser bool `json:"firstUser"`
RegistrationEnabled bool `json:"registrationEnabled"`
LinuxDOEnabled bool `json:"linuxdoEnabled"`
EmailEnabled bool `json:"emailEnabled"`
EmailCodeRequired bool `json:"emailCodeRequired"`
VerificationPolicy
SMSBindingAvailable bool `json:"smsBindingAvailable"`
EmailBindingAvailable bool `json:"emailBindingAvailable"`
FirstUser bool `json:"firstUser"`
RegistrationEnabled bool `json:"registrationEnabled"`
LinuxDOEnabled bool `json:"linuxdoEnabled"`
EmailEnabled bool `json:"emailEnabled"`
EmailCodeRequired bool `json:"emailCodeRequired"`
}
type AuthSessionResult struct {
@@ -79,7 +86,28 @@ func (s *Service) PublicAuthSettings() (*PublicAuthSettings, error) {
if err != nil {
return nil, err
}
return &PublicAuthSettings{FirstUser: false, RegistrationEnabled: registrationEnabled, LinuxDOEnabled: s.LinuxDOEnabled(), EmailEnabled: emailEnabled, EmailCodeRequired: true}, nil
p, err := s.verificationPolicy()
if err != nil {
return nil, err
}
smsLogin, err := s.smsAvailable("login")
if err != nil {
return nil, err
}
smsRegister, err := s.smsAvailable("register")
if err != nil {
return nil, err
}
smsBind, err := s.smsAvailable("bind")
if err != nil {
return nil, err
}
p.SMSLogin = p.SMSLogin && smsLogin
p.EmailLogin = p.EmailLogin && emailEnabled
p.SMSRegistration = p.SMSRegistration && smsRegister
p.EmailRegistration = p.EmailRegistration && emailEnabled
p.SMSAndEmailRegistration = p.SMSAndEmailRegistration && smsRegister && emailEnabled
return &PublicAuthSettings{VerificationPolicy: p, SMSBindingAvailable: smsBind, EmailBindingAvailable: emailEnabled, FirstUser: false, RegistrationEnabled: registrationEnabled, LinuxDOEnabled: s.LinuxDOEnabled(), EmailEnabled: emailEnabled, EmailCodeRequired: p.EmailRegistration || p.SMSAndEmailRegistration}, nil
}
func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
@@ -107,6 +135,8 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
return nil, err
}
var verifiedCode *model.EmailVerificationCode
var verification *model.AuthVerification
phone := ""
if count > 0 {
registrationEnabled, err := s.RegistrationEnabled()
if err != nil {
@@ -115,15 +145,36 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
if !registrationEnabled {
return nil, kernel.Forbidden("管理员未开放新用户注册")
}
if email == "" {
return nil, kernel.BadAuthRequest("请输入邮箱")
}
if err := s.validateRegistrationEmailDomain(email); err != nil {
return nil, err
}
verifiedCode, err = s.VerifyRegistrationEmailCode(email, req.EmailCode)
if err != nil {
return nil, err
if req.Ticket != "" {
verification, err = s.verifyTicket("register", VerificationConfirm{Ticket: req.Ticket, EmailCode: req.EmailCode, SMSCode: req.SMSCode})
if err != nil {
return nil, err
}
if verification.Email != email || (verification.Phone != "" && strings.TrimSpace(req.Phone) != verification.Phone && "+86"+strings.TrimSpace(req.Phone) != verification.Phone) || (verification.Phone == "" && req.Phone != "") {
return nil, invalidVerification()
}
phone = verification.Phone
if err := s.contactAvailable(email, phone, ""); err != nil {
return nil, err
}
} else {
p, err := s.verificationPolicy()
if err != nil {
return nil, err
}
if !p.allows("register", "email") || req.Phone != "" {
return nil, kernel.BadAuthRequest("请先获取本次注册验证码")
}
if email == "" {
return nil, kernel.BadAuthRequest("请输入邮箱")
}
if err := s.validateRegistrationEmailDomain(email); err != nil {
return nil, err
}
verifiedCode, err = s.VerifyRegistrationEmailCode(email, req.EmailCode)
if err != nil {
return nil, err
}
}
}
if _, err := s.repo.UserByUsername(username); err == nil {
@@ -147,6 +198,7 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
ID: kernel.NewID(),
Username: username,
Email: email,
Phone: phone,
DisplayName: displayName,
Role: model.UserRoleUser,
Status: model.UserStatusActive,
@@ -157,7 +209,21 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
if count == 0 {
user.Role = model.UserRoleAdmin
}
if verifiedCode != nil {
if verification != nil {
if email != "" {
user.EmailVerifiedAt = &now
}
if phone != "" {
user.PhoneVerifiedAt = &now
}
if err := s.repo.CreateUserWithVerification(&user, verification.ID); err != nil {
if errors.Is(err, repository.ErrVerificationInvalid) {
return nil, invalidVerification()
}
return nil, err
}
} else if verifiedCode != nil {
user.EmailVerifiedAt = &now
if err := s.repo.CreateUserWithEmailVerification(&user, verifiedCode.ID, time.Now()); err != nil {
return nil, err
}
+17
View File
@@ -143,6 +143,13 @@ func (s *Service) EmailEnabled() (bool, error) {
}
func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
p, err := s.verificationPolicy()
if err != nil {
return err
}
if !p.allows("register", "email") {
return kernel.Forbidden("邮箱单独验证注册未开启")
}
email := NormalizeEmail(rawEmail)
if err := ValidateEmail(email); err != nil {
return err
@@ -215,6 +222,13 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
}
func (s *Service) VerifyRegistrationEmailCode(email string, rawCode string) (*model.EmailVerificationCode, error) {
p, err := s.verificationPolicy()
if err != nil {
return nil, err
}
if !p.allows("register", "email") {
return nil, kernel.Forbidden("邮箱单独验证注册未开启")
}
emailEnabled, err := s.EmailEnabled()
if err != nil {
return nil, err
@@ -236,6 +250,9 @@ func (s *Service) VerifyRegistrationEmailCode(email string, rawCode string) (*mo
if time.Now().After(record.ExpiresAt) {
return nil, kernel.BadAuthRequest("邮箱验证码已过期,请重新获取")
}
if err := s.repo.AttemptLegacyEmailVerification(record.ID); err != nil {
return nil, invalidVerification()
}
hash, err := s.emailVerificationCodeHash(registrationEmailPurpose, email, code)
if err != nil {
return nil, err
+8
View File
@@ -51,8 +51,16 @@ type Service struct {
mailSender func(EmailSettingValue, string, string, string) error
emailCodeMu sync.Mutex
registrationMu sync.Mutex
sms SMSDelivery
}
type SMSDelivery interface {
Available(string) (bool, error)
SendCode(context.Context, string, string, string) error
}
func (s *Service) SetSMSDelivery(delivery SMSDelivery) { s.sms = delivery }
func New(repo *repository.Repository, host Host, mailSender func(EmailSettingValue, string, string, string) error) *Service {
if host == nil {
host = nopHost{}
+7
View File
@@ -227,6 +227,13 @@ func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDO
return nil, err
}
} else if errors.Is(err, gorm.ErrRecordNotFound) {
policy, policyErr := s.verificationPolicy()
if policyErr != nil {
return nil, policyErr
}
if policy.SMSAndEmailRegistration {
return nil, kernel.Forbidden("平台要求短信和邮箱验证,请先通过注册页面创建账号;第三方登录不能绕过双重验证")
}
registrationEnabled, settingErr := s.RegistrationEnabled()
if settingErr != nil {
return nil, settingErr
+321
View File
@@ -0,0 +1,321 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"gorm.io/gorm"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"infinite-canvas/backend/internal/sms"
"strings"
"time"
)
type VerificationRequest struct {
Purpose string `json:"purpose"`
Method string `json:"method"`
Email string `json:"email"`
Phone string `json:"phone"`
Password string `json:"password"`
}
type VerificationTicket struct {
Ticket string `json:"ticket"`
ExpiresIn int `json:"expiresIn"`
RetryAfter int `json:"retryAfter"`
}
type VerificationConfirm struct {
Ticket string `json:"ticket"`
EmailCode string `json:"emailCode"`
SMSCode string `json:"smsCode"`
}
func (s *Service) verificationHash(value string) (string, error) {
key, err := s.host.SettingsEncryptionKey()
if err != nil {
return "", err
}
if len(key) < 16 {
return "", kernel.NewAppError(503, "验证码加密密钥不可用")
}
h := hmac.New(sha256.New, key)
h.Write([]byte(value))
return hex.EncodeToString(h.Sum(nil)), nil
}
func (s *Service) checkVerificationPolicy(p VerificationPolicy, purpose, method string) error {
if !p.allows(purpose, method) {
return kernel.Forbidden("此验证方式未开启,请刷新页面")
}
if purpose == "register" {
enabled, err := s.RegistrationEnabled()
if err != nil {
return err
}
if !enabled {
return kernel.Forbidden("管理员未开放新用户注册")
}
}
return nil
}
func (s *Service) StartVerification(ctx context.Context, actor *model.User, req VerificationRequest) (*VerificationTicket, error) {
p, err := s.verificationPolicy()
if err != nil {
return nil, err
}
if err := s.checkVerificationPolicy(p, req.Purpose, req.Method); err != nil {
return nil, err
}
email, phone := "", ""
if req.Method == "email" || req.Method == "sms_email" {
email = NormalizeEmail(req.Email)
if err := ValidateEmail(email); err != nil {
return nil, err
}
enabled, err := s.EmailEnabled()
if err != nil {
return nil, err
}
if !enabled {
return nil, kernel.NewAppError(503, "邮件服务未启用")
}
if req.Purpose == "register" {
if err := s.validateRegistrationEmailDomain(email); err != nil {
return nil, err
}
}
}
if req.Method == "sms" || req.Method == "sms_email" {
phone, err = sms.NormalizePhone(req.Phone)
if err != nil {
return nil, err
}
enabled, err := s.smsAvailable(req.Purpose)
if err != nil {
return nil, err
}
if !enabled {
return nil, kernel.NewAppError(503, "此场景短信服务未启用")
}
}
userID := ""
if req.Purpose == "bind" {
if actor == nil || actor.Status != model.UserStatusActive {
return nil, kernel.Unauthorized("请先登录")
}
if !verifyPassword(req.Password, actor.PasswordHash) {
return nil, kernel.Unauthorized("当前密码不正确")
}
userID = actor.ID
}
// Existing, active, verified contacts only. Responses do not disclose whether
// an account exists; unknown accounts receive an unusable, equally shaped ticket.
if req.Purpose == "login" {
var user *model.User
if email != "" {
user, err = s.repo.UserByEmail(email)
} else {
user, err = s.repo.UserByPhone(phone)
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if err == nil && user.Status == model.UserStatusActive && ((email != "" && user.EmailVerifiedAt != nil) || (phone != "" && user.PhoneVerifiedAt != nil)) {
userID = user.ID
}
}
if req.Purpose != "login" {
if err := s.contactAvailable(email, phone, userID); err != nil {
return nil, err
}
}
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, err
}
now := time.Now()
v := &model.AuthVerification{ID: kernel.NewID(), TokenHash: HashToken(hex.EncodeToString(secret)), Purpose: req.Purpose, Method: req.Method, UserID: userID, Email: email, Phone: phone, PolicyHash: p.hash(), CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute)}
var emailCode, smsCode string
if email != "" {
emailCode, err = randomNumericCode(6)
if err != nil {
return nil, err
}
v.EmailHash, err = s.verificationHash(v.ID + ":email:" + email + ":" + emailCode)
if err != nil {
return nil, err
}
}
if phone != "" {
smsCode, err = randomNumericCode(6)
if err != nil {
return nil, err
}
v.PhoneHash, err = s.verificationHash(v.ID + ":sms:" + phone + ":" + smsCode)
if err != nil {
return nil, err
}
}
limits := []repository.NotificationLimit{}
for _, target := range []string{email, phone} {
if target == "" {
continue
}
hash, err := s.verificationHash("delivery:" + target)
if err != nil {
return nil, err
}
// Daily shared key also enforces a sliding 60-second cooldown across scenes.
limits = append(limits, repository.NotificationLimit{Key: "auth-day:" + hash + ":" + now.UTC().Format("2006-01-02"), Limit: 20, Cooldown: 60 * time.Second, ExpiresAt: now.Add(48 * time.Hour)}, repository.NotificationLimit{Key: "auth-hour:" + hash + ":" + now.UTC().Format("2006-01-02T15"), Limit: 5, ExpiresAt: now.Add(2 * time.Hour)})
}
if err := s.repo.CreateAuthVerification(v, limits); err != nil {
if errors.Is(err, repository.ErrNotificationLimit) {
return nil, kernel.RateLimited("发送过于频繁,请至少等待 60 秒后重试(每小时最多 5 次)")
}
return nil, err
}
ticket := &VerificationTicket{Ticket: v.ID + "." + hex.EncodeToString(secret), ExpiresIn: 600, RetryAfter: 60}
if req.Purpose == "login" && userID == "" {
return ticket, nil
}
if phone != "" {
if err := s.sms.SendCode(ctx, req.Purpose, phone, smsCode); err != nil {
return nil, err
}
}
if email != "" {
_, setting, err := s.readEmailSetting()
if err != nil {
return nil, err
}
setting = resolveEmailSender(setting, s.host.BrandName())
if err := s.deliverEmail(setting, email, setting.FromName+"身份验证码", fmt.Sprintf("您的验证码是:%s\n10 分钟内有效,仅用于本次身份验证。请勿向他人透露验证码。", emailCode)); err != nil {
return nil, kernel.NewAppError(503, "邮件发送失败,请稍后重试")
}
}
if err := s.repo.SetAuthVerificationReady(v.ID); err != nil {
return nil, err
}
return ticket, nil
}
func (s *Service) contactAvailable(email, phone, ownID string) error {
for _, kind := range []string{"email", "sms"} {
var user *model.User
var err error
if kind == "email" {
if email == "" {
continue
}
user, err = s.repo.UserByEmail(email)
} else {
if phone == "" {
continue
}
user, err = s.repo.UserByPhone(phone)
}
if err == nil && user.ID != ownID {
return kernel.BadAuthRequest("该邮箱或手机号已被使用")
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
return nil
}
func invalidVerification() error {
return kernel.BadAuthRequest("验证码错误、已失效或验证次数已用尽,请重新获取")
}
func (s *Service) verifyTicket(purpose string, req VerificationConfirm) (*model.AuthVerification, error) {
id, secret, ok := strings.Cut(req.Ticket, ".")
if !ok || len(secret) != 64 || len(id) > 36 {
return nil, invalidVerification()
}
v, err := s.repo.AuthVerification(id)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, invalidVerification()
}
if err != nil {
return nil, err
}
if !hmac.Equal([]byte(v.TokenHash), []byte(HashToken(secret))) || v.Purpose != purpose {
return nil, invalidVerification()
}
p, err := s.verificationPolicy()
if err != nil {
return nil, err
}
if err := s.checkVerificationPolicy(p, v.Purpose, v.Method); err != nil {
return nil, err
}
if p.hash() != v.PolicyHash {
return nil, kernel.BadAuthRequest("验证策略已更新,请重新获取验证码")
}
if err := s.repo.AttemptAuthVerification(v.ID, time.Now()); err != nil {
if errors.Is(err, repository.ErrVerificationInvalid) {
return nil, invalidVerification()
}
return nil, err
}
if v.Email != "" {
hash, err := s.verificationHash(v.ID + ":email:" + v.Email + ":" + strings.TrimSpace(req.EmailCode))
if err != nil {
return nil, err
}
if !hmac.Equal([]byte(hash), []byte(v.EmailHash)) {
return nil, invalidVerification()
}
}
if v.Phone != "" {
hash, err := s.verificationHash(v.ID + ":sms:" + v.Phone + ":" + strings.TrimSpace(req.SMSCode))
if err != nil {
return nil, err
}
if !hmac.Equal([]byte(hash), []byte(v.PhoneHash)) {
return nil, invalidVerification()
}
}
return v, nil
}
func (s *Service) LoginVerification(req VerificationConfirm) (*AuthSessionResult, error) {
v, err := s.verifyTicket("login", req)
if err != nil {
return nil, err
}
user, err := s.repo.CompleteAuthVerification(v, false)
if err != nil {
if errors.Is(err, repository.ErrVerificationInvalid) {
return nil, invalidVerification()
}
return nil, err
}
if err := s.host.EnsureSignupBonus(user.ID); err != nil {
return nil, err
}
s.host.RecordActivity(user.ID, "login", 1)
return s.createAuthSession(user)
}
func (s *Service) BindVerification(actor *model.User, req VerificationConfirm) (*AuthUser, error) {
if actor == nil {
return nil, kernel.Unauthorized("请先登录")
}
v, err := s.verifyTicket("bind", req)
if err != nil {
return nil, err
}
if v.UserID != actor.ID {
return nil, invalidVerification()
}
if err := s.contactAvailable(v.Email, v.Phone, actor.ID); err != nil {
return nil, err
}
user, err := s.repo.CompleteAuthVerification(v, true)
if err != nil {
return nil, invalidVerification()
}
result, err := s.PublicAuthUser(user)
return &result, err
}
@@ -0,0 +1,97 @@
package auth
import (
"encoding/json"
"errors"
"gorm.io/gorm"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
)
const authPolicyKey = "auth_verification_policy"
type VerificationPolicy struct {
SMSLogin bool `json:"smsLogin"`
EmailLogin bool `json:"emailLogin"`
SMSRegistration bool `json:"smsRegistration"`
EmailRegistration bool `json:"emailRegistration"`
SMSAndEmailRegistration bool `json:"smsAndEmailRegistration"`
}
func (s *Service) verificationPolicy() (VerificationPolicy, error) {
setting, err := s.repo.SystemSetting(authPolicyKey)
if errors.Is(err, gorm.ErrRecordNotFound) {
return VerificationPolicy{EmailRegistration: true}, nil
}
if err != nil {
return VerificationPolicy{}, err
}
var p VerificationPolicy
if json.Unmarshal([]byte(setting.ValueJSON), &p) != nil {
return p, kernel.NewAppError(503, "登录注册策略格式无效")
}
return p, nil
}
func (s *Service) AdminVerificationPolicy(actor *model.User) (*VerificationPolicy, error) {
if err := s.host.RequireAdmin(actor); err != nil {
return nil, err
}
p, err := s.verificationPolicy()
return &p, err
}
func (s *Service) UpdateVerificationPolicy(actor *model.User, p VerificationPolicy) (*VerificationPolicy, error) {
if err := s.host.RequireAdmin(actor); err != nil {
return nil, err
}
if p.SMSAndEmailRegistration && (p.SMSRegistration || p.EmailRegistration) {
return nil, kernel.BadAuthRequest("短信+邮箱同时验证不能与单一验证注册同时开启")
}
if p.EmailLogin || p.EmailRegistration || p.SMSAndEmailRegistration {
enabled, err := s.EmailEnabled()
if err != nil {
return nil, err
}
if !enabled {
return nil, kernel.BadAuthRequest("请先配置并开启邮件服务")
}
}
for scene, required := range map[string]bool{"login": p.SMSLogin, "register": p.SMSRegistration || p.SMSAndEmailRegistration} {
if !required {
continue
}
enabled, err := s.smsAvailable(scene)
if err != nil {
return nil, err
}
if !enabled {
return nil, kernel.BadAuthRequest("请先启用短信插件并配置对应场景的短信渠道:" + scene)
}
}
encoded, _ := json.Marshal(p)
if err := s.repo.SaveSystemSetting(&model.SystemSetting{Key: authPolicyKey, ValueJSON: string(encoded), UpdatedBy: actor.ID}); err != nil {
return nil, err
}
return &p, nil
}
func (s *Service) smsAvailable(scene string) (bool, error) {
if s.sms == nil {
return false, nil
}
return s.sms.Available(scene)
}
func (p VerificationPolicy) allows(purpose, method string) bool {
if purpose == "bind" {
return method == "sms" || method == "email"
}
if purpose == "login" {
return (method == "sms" && p.SMSLogin) || (method == "email" && p.EmailLogin)
}
if purpose == "register" {
if p.SMSAndEmailRegistration {
return method == "sms_email"
}
return (method == "sms" && p.SMSRegistration) || (method == "email" && p.EmailRegistration)
}
return false
}
func (p VerificationPolicy) hash() string { raw, _ := json.Marshal(p); return HashToken(string(raw)) }