package auth import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/tls" "encoding/hex" "encoding/json" "errors" "fmt" "infinite-canvas/backend/internal/kernel" "log" "math/big" "mime" "net" "net/mail" "net/smtp" "strconv" "strings" "time" "infinite-canvas/backend/internal/model" "gorm.io/gorm" ) const emailSettingKey = "email" const registrationEmailPurpose = "registration" const registrationCodeTTL = 10 * time.Minute var defaultRegistrationEmailDomains = []string{ "gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com", "icloud.com", "yahoo.com", "foxmail.com", } type EmailSettingRequest struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` Password string `json:"password"` Encryption string `json:"encryption"` FromEmail string `json:"fromEmail"` FromName string `json:"fromName"` RegistrationAllowedDomains []string `json:"registrationAllowedDomains"` } type PublicEmailSetting struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` Encryption string `json:"encryption"` FromEmail string `json:"fromEmail"` FromName string `json:"fromName"` FromNameInherited bool `json:"fromNameInherited"` HasPassword bool `json:"hasPassword"` RegistrationAllowedDomains []string `json:"registrationAllowedDomains"` UpdatedBy string `json:"updatedBy"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type EmailSettingValue struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` Password string `json:"password"` Encryption string `json:"encryption"` FromEmail string `json:"fromEmail"` FromName string `json:"fromName"` RegistrationAllowedDomains []string `json:"registrationAllowedDomains"` } func (s *Service) AdminEmailSetting(actor *model.User) (*PublicEmailSetting, error) { if err := s.host.RequireAdmin(actor); err != nil { return nil, err } setting, value, err := s.readEmailSetting() if err != nil { return nil, err } return s.publicEmailSetting(setting, value), nil } func (s *Service) UpdateEmailSetting(actor *model.User, req EmailSettingRequest) (*PublicEmailSetting, error) { if err := s.host.RequireAdmin(actor); err != nil { return nil, err } currentSetting, current, err := s.readEmailSetting() if err != nil { return nil, err } allowedDomains := req.RegistrationAllowedDomains if allowedDomains == nil { allowedDomains = current.RegistrationAllowedDomains } next := normalizeEmailSetting(EmailSettingValue{ Enabled: req.Enabled, Host: req.Host, Port: req.Port, Username: req.Username, Password: req.Password, Encryption: req.Encryption, FromEmail: req.FromEmail, FromName: req.FromName, RegistrationAllowedDomains: allowedDomains, }) if next.Password == "" { next.Password = current.Password } if err := validateEmailSetting(next); err != nil { return nil, err } stored := next stored.Password, err = s.host.EncryptSecret(next.Password) if err != nil { return nil, err } encoded, err := json.Marshal(stored) if err != nil { return nil, err } setting := model.SystemSetting{Key: emailSettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID} if currentSetting != nil { setting.CreatedAt = currentSetting.CreatedAt } if err := s.repo.SaveSystemSetting(&setting); err != nil { return nil, err } return s.publicEmailSetting(&setting, next), nil } func (s *Service) EmailEnabled() (bool, error) { _, value, err := s.readEmailSetting() if err != nil { return false, err } return value.Enabled && value.Host != "" && value.Port > 0 && value.FromEmail != "", nil } 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 } count, err := s.repo.UserCount() if err != nil { return err } if count == 0 { return kernel.BadAuthRequest("首个管理员账号不需要邮箱验证码") } registrationEnabled, err := s.RegistrationEnabled() if err != nil { return err } if !registrationEnabled { return kernel.Forbidden("管理员未开放新用户注册") } if _, err := s.repo.UserByEmail(email); err == nil { return kernel.BadAuthRequest("邮箱已被注册") } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } _, setting, err := s.readEmailSetting() if err != nil { return err } if err := validateRegistrationEmailDomain(email, setting.RegistrationAllowedDomains); err != nil { return err } if !setting.Enabled { return kernel.Forbidden("平台尚未启用注册邮件,请联系管理员") } s.emailCodeMu.Lock() defer s.emailCodeMu.Unlock() if latest, err := s.repo.LatestEmailVerificationCode(email, registrationEmailPurpose); err == nil && time.Since(latest.CreatedAt) < time.Minute { seconds := max(1, int((time.Until(latest.CreatedAt.Add(time.Minute))+time.Second-1)/time.Second)) return &EmailCodeCooldownError{Seconds: seconds} } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } code, err := randomNumericCode(6) if err != nil { return err } codeHash, err := s.emailVerificationCodeHash(registrationEmailPurpose, email, code) if err != nil { return err } now := time.Now() record := model.EmailVerificationCode{ID: kernel.NewID(), Email: email, CodeHash: codeHash, Purpose: registrationEmailPurpose, ExpiresAt: now.Add(registrationCodeTTL), CreatedAt: now} if err := s.repo.Create(&record); err != nil { return err } setting = resolveEmailSender(setting, s.host.BrandName()) if err := s.deliverEmail(setting, email, setting.FromName+"注册验证码", registrationEmailBody(setting.FromName, code)); err != nil { cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID) if cleanupErr != nil { return errors.Join( fmt.Errorf("发送注册邮件失败:%w", err), fmt.Errorf("清理失效验证码失败:%w", cleanupErr), ) } return fmt.Errorf("发送注册邮件失败:%w", err) } if cleanupErr := s.repo.DeleteExpiredEmailVerificationCodes(now.Add(-24 * time.Hour)); cleanupErr != nil { log.Printf("expired registration code cleanup failed: error=%v", cleanupErr) } return nil } 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 } if !emailEnabled { return nil, kernel.Forbidden("平台尚未启用注册邮件,请联系管理员") } code := strings.TrimSpace(rawCode) if len(code) != 6 { return nil, kernel.BadAuthRequest("请输入 6 位邮箱验证码") } record, err := s.repo.LatestEmailVerificationCode(email, registrationEmailPurpose) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, kernel.BadAuthRequest("请先获取邮箱验证码") } if err != nil { return nil, err } 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 } if !hmac.Equal([]byte(hash), []byte(record.CodeHash)) { return nil, kernel.BadAuthRequest("邮箱验证码不正确") } return record, nil } func (s *Service) emailVerificationCodeHash(purpose string, email string, code string) (string, error) { key, err := s.host.SettingsEncryptionKey() if err != nil { return "", err } mac := hmac.New(sha256.New, key) _, _ = mac.Write([]byte(strings.TrimSpace(purpose) + ":" + NormalizeEmail(email) + ":" + strings.TrimSpace(code))) return hex.EncodeToString(mac.Sum(nil)), nil } func (s *Service) readEmailSetting() (*model.SystemSetting, EmailSettingValue, error) { setting, err := s.repo.SystemSetting(emailSettingKey) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, normalizeEmailSetting(EmailSettingValue{}), nil } if err != nil { return nil, EmailSettingValue{}, err } value := EmailSettingValue{} if err := json.Unmarshal([]byte(setting.ValueJSON), &value); err != nil { return nil, EmailSettingValue{}, errors.New("邮件配置格式无效") } value.Password, err = s.host.DecryptSecret(value.Password) if err != nil { return nil, EmailSettingValue{}, err } return setting, normalizeEmailSetting(value), nil } func validateEmailSetting(value EmailSettingValue) error { if err := validateRegistrationEmailDomains(value.RegistrationAllowedDomains); err != nil { return err } if !value.Enabled { return nil } if value.Host == "" || value.Port < 1 || value.Port > 65535 || value.FromEmail == "" { return kernel.BadAuthRequest("启用邮件前请完整填写 SMTP 主机、端口和发件邮箱") } if err := ValidateEmail(value.FromEmail); err != nil { return kernel.BadAuthRequest("发件邮箱格式不正确") } if value.Username != "" && value.Password == "" { return kernel.BadAuthRequest("SMTP 用户名已填写,请同时填写密码") } if strings.ContainsAny(value.FromName, "\r\n") { return kernel.BadAuthRequest("发件人名称不能包含换行") } return nil } func normalizeEmailSetting(value EmailSettingValue) EmailSettingValue { value.Host = strings.TrimSpace(value.Host) value.Username = strings.TrimSpace(value.Username) value.Password = strings.TrimSpace(value.Password) value.FromEmail = NormalizeEmail(value.FromEmail) value.FromName = strings.TrimSpace(value.FromName) if value.RegistrationAllowedDomains == nil { value.RegistrationAllowedDomains = append([]string(nil), defaultRegistrationEmailDomains...) } value.RegistrationAllowedDomains = normalizeEmailDomains(value.RegistrationAllowedDomains) if value.Port == 0 { value.Port = 587 } switch value.Encryption { case "tls", "none": default: value.Encryption = "starttls" } return value } func (s *Service) publicEmailSetting(setting *model.SystemSetting, value EmailSettingValue) *PublicEmailSetting { inherited := value.FromName == "" || value.FromName == DefaultBrandName value = resolveEmailSender(value, s.host.BrandName()) result := &PublicEmailSetting{ Enabled: value.Enabled, Host: value.Host, Port: value.Port, Username: value.Username, Encryption: value.Encryption, FromEmail: value.FromEmail, FromName: value.FromName, FromNameInherited: inherited, HasPassword: value.Password != "", RegistrationAllowedDomains: value.RegistrationAllowedDomains, } if setting != nil { result.UpdatedBy = setting.UpdatedBy result.CreatedAt = setting.CreatedAt result.UpdatedAt = setting.UpdatedAt } return result } func normalizeEmailDomains(values []string) []string { result := make([]string, 0, len(values)) seen := make(map[string]bool, len(values)) for _, raw := range values { value := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "@"))) value = strings.TrimSuffix(value, ".") if value == "" || seen[value] { continue } seen[value] = true result = append(result, value) } return result } func validateRegistrationEmailDomains(allowed []string) error { if len(allowed) > 100 { return kernel.BadAuthRequest("电子邮件域名白名单最多填写 100 项") } for _, domain := range allowed { if !validEmailDomain(domain) { return kernel.BadAuthRequest("电子邮件域名格式不正确:" + domain) } } return nil } func validEmailDomain(value string) bool { if len(value) > 253 || !strings.Contains(value, ".") || strings.HasPrefix(value, ".") || strings.HasSuffix(value, ".") { return false } for _, label := range strings.Split(value, ".") { if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { return false } for _, char := range label { if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' { return false } } } return true } func (s *Service) validateRegistrationEmailDomain(email string) error { _, setting, err := s.readEmailSetting() if err != nil { return err } return validateRegistrationEmailDomain(email, setting.RegistrationAllowedDomains) } func validateRegistrationEmailDomain(email string, allowedDomains []string) error { parts := strings.Split(email, "@") if len(parts) != 2 { return kernel.BadAuthRequest("邮箱格式不正确") } domain := strings.ToLower(parts[1]) if len(allowedDomains) == 0 { return nil } for _, allowed := range allowedDomains { if domain == allowed { return nil } } return kernel.BadAuthRequest("该邮箱域名不在管理员设置的白名单内") } func resolveEmailSender(value EmailSettingValue, brandName string) EmailSettingValue { if value.FromName == "" || value.FromName == DefaultBrandName { value.FromName = brandName } return value } func sendSMTPMail(setting EmailSettingValue, recipient string, subject string, body string) error { address := net.JoinHostPort(setting.Host, strconv.Itoa(setting.Port)) tlsConfig := &tls.Config{ServerName: setting.Host, MinVersion: tls.VersionTLS12} dialer := &net.Dialer{Timeout: 12 * time.Second} var client *smtp.Client var err error if setting.Encryption == "tls" { connection, dialErr := tls.DialWithDialer(dialer, "tcp", address, tlsConfig) if dialErr != nil { return dialErr } client, err = smtp.NewClient(connection, setting.Host) } else { connection, dialErr := dialer.Dial("tcp", address) if dialErr != nil { return dialErr } client, err = smtp.NewClient(connection, setting.Host) if err == nil && setting.Encryption == "starttls" { err = client.StartTLS(tlsConfig) } } if err != nil { return err } defer client.Close() if setting.Username != "" { if err := client.Auth(smtp.PlainAuth("", setting.Username, setting.Password, setting.Host)); err != nil { return err } } if err := client.Mail(setting.FromEmail); err != nil { return err } if err := client.Rcpt(recipient); err != nil { return err } wc, err := client.Data() if err != nil { return err } from := mail.Address{Name: setting.FromName, Address: setting.FromEmail} message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", from.String(), recipient, mime.QEncoding.Encode("UTF-8", subject), body) if _, err := wc.Write([]byte(message)); err != nil { _ = wc.Close() return err } if err := wc.Close(); err != nil { return err } return client.Quit() } func (s *Service) deliverEmail(setting EmailSettingValue, recipient string, subject string, body string) error { if s.mailSender != nil { return s.mailSender(setting, recipient, subject, body) } return sendSMTPMail(setting, recipient, subject, body) } func randomNumericCode(length int) (string, error) { limit := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(length)), nil) value, err := rand.Int(rand.Reader, limit) if err != nil { return "", err } return fmt.Sprintf("%0*d", length, value.Int64()), nil } func registrationEmailBody(brandName string, code string) string { return "你正在注册" + brandName + "。\n\n验证码:" + code + "\n\n验证码 10 分钟内有效。若非本人操作,请忽略本邮件。" }