feat(auth): 登录注册 - 添加短信通知与组合验证支持
This commit is contained in:
1 parent
6c013ebf7e
commit
bc0d109428
55 files changed
+4013
-103
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) }
|
||||
Reference in new issue
Block a user