- 会话 cookie 增加 Secure 标志;新增 [web].cookie_secure 配置 (默认 true,仅本地 HTTP 调试关闭;缺失字段按安全默认处理) - SMTP/IMAP/POP3 认证接入封禁体系(store.RecordAuthFailure 与 Web 共用 ban_entries):失败计数达 ban.max_fail_attempts 即封禁 IP, 已封禁 IP 拒绝认证,堵住协议层暴力破解 - 附件存储路径遍历防护重写:FullPath 白名单校验(UUID 文件名格式) + baseDir 前缀兜底,非法路径返回错误;Save 扩展名白名单化 - 初始管理员不再使用 admin/admin:密码取 MAILGO_ADMIN_PASSWORD 或 随机生成并打印一次;新增 MustChangePassword 首登强制改密 (管理员重置密码同样触发) - 外发中继默认验证 TLS 证书(保护 AUTH 凭据,防 MITM),直投 MX 保持机会式 TLS;新增 outbound.relay_tls_insecure 开关(默认 false) - 新增安全响应头中间件:HSTS、X-Frame-Options DENY、nosniff、 Referrer-Policy、基础 CSP(frame-ancestors 'none' 防点击劫持, connect-src/form-action 'self' 防数据外泄) - LDAP/OAuth 登录错误统一为通用文案,原始错误只写日志, 不再回显邮箱/内部细节(防用户枚举与信息泄露) - 新增 25 个回归测试:cookie 标志、封禁阈值、路径遍历用例、 中继 TLS 验证(自签证书 STARTTLS 集成)、安全头、OAuth 文案 部署注意:升级后所有会话失效需重新登录;若直接以 HTTP 提供 服务需显式配置 cookie_secure = false。
152 lines
4.5 KiB
Go
152 lines
4.5 KiB
Go
package store
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"mail_go/internal/db"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// UserStore defines the interface for user data operations.
|
|
type UserStore interface {
|
|
Create(user *db.User) error
|
|
GetByID(id uint) (*db.User, error)
|
|
GetByUsername(username string, domainID uint) (*db.User, error)
|
|
GetByEmail(email string) (*db.User, error)
|
|
Authenticate(email, password string) (*db.User, error)
|
|
Update(user *db.User) error
|
|
Delete(id uint) error
|
|
List(domainID uint, page, size int) ([]db.User, int64, error)
|
|
ListAll(page, size int) ([]db.User, int64, error)
|
|
UpdateUsedBytes(id uint, delta int64) error
|
|
UpdatePassword(userID uint, hashedPassword string) error
|
|
}
|
|
|
|
// userStoreGorm implements UserStore using GORM.
|
|
type userStoreGorm struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// newUserStore creates a new GORM-backed UserStore.
|
|
func newUserStore(database *gorm.DB) UserStore {
|
|
return &userStoreGorm{db: database}
|
|
}
|
|
|
|
// Create inserts a new user record.
|
|
func (s *userStoreGorm) Create(user *db.User) error {
|
|
return s.db.Create(user).Error
|
|
}
|
|
|
|
// GetByID retrieves a user by primary key.
|
|
func (s *userStoreGorm) GetByID(id uint) (*db.User, error) {
|
|
var user db.User
|
|
if err := s.db.Preload("Domain").First(&user, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// GetByUsername retrieves a user by username and domain ID.
|
|
func (s *userStoreGorm) GetByUsername(username string, domainID uint) (*db.User, error) {
|
|
var user db.User
|
|
if err := s.db.Where("username = ? AND domain_id = ?", username, domainID).First(&user).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// GetByEmail retrieves a user by email address (user@domain format).
|
|
func (s *userStoreGorm) GetByEmail(email string) (*db.User, error) {
|
|
parts := strings.SplitN(email, "@", 2)
|
|
if len(parts) != 2 {
|
|
return nil, ErrInvalidEmail
|
|
}
|
|
username := parts[0]
|
|
domainName := parts[1]
|
|
|
|
var user db.User
|
|
if err := s.db.Joins("JOIN domains ON domains.id = users.domain_id").
|
|
Where("users.username = ? AND domains.name = ?", username, domainName).
|
|
Preload("Domain").
|
|
First(&user).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// Authenticate verifies an email/password combination and returns the user on success.
|
|
func (s *userStoreGorm) Authenticate(email, password string) (*db.User, error) {
|
|
user, err := s.GetByEmail(email)
|
|
if err != nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
if !user.IsActive {
|
|
return nil, ErrUserInactive
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
// Update saves changes to an existing user record.
|
|
func (s *userStoreGorm) Update(user *db.User) error {
|
|
return s.db.Save(user).Error
|
|
}
|
|
|
|
// Delete removes a user by ID (soft delete if supported, hard delete otherwise).
|
|
func (s *userStoreGorm) Delete(id uint) error {
|
|
return s.db.Delete(&db.User{}, id).Error
|
|
}
|
|
|
|
// List retrieves a paginated list of users for a given domain.
|
|
func (s *userStoreGorm) List(domainID uint, page, size int) ([]db.User, int64, error) {
|
|
var users []db.User
|
|
var total int64
|
|
|
|
query := s.db.Where("domain_id = ?", domainID)
|
|
if err := query.Model(&db.User{}).Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
offset := (page - 1) * size
|
|
if err := s.db.Preload("Domain").Where("domain_id = ?", domainID).Offset(offset).Limit(size).Find(&users).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return users, total, nil
|
|
}
|
|
|
|
// UpdateUsedBytes atomically adjusts the UsedBytes field by delta.
|
|
func (s *userStoreGorm) UpdateUsedBytes(id uint, delta int64) error {
|
|
return s.db.Model(&db.User{}).Where("id = ?", id).
|
|
Update("used_bytes", gorm.Expr("used_bytes + ?", delta)).Error
|
|
}
|
|
|
|
// UpdatePassword updates the password hash for a user and clears the
|
|
// must-change-password flag (the user has now set their own password).
|
|
func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error {
|
|
return s.db.Model(&db.User{}).Where("id = ?", userID).
|
|
Updates(map[string]interface{}{
|
|
"password_hash": hashedPassword,
|
|
"must_change_password": false,
|
|
}).Error
|
|
}
|
|
|
|
// ListAll retrieves a paginated list of all users across all domains.
|
|
func (s *userStoreGorm) ListAll(page, size int) ([]db.User, int64, error) {
|
|
var users []db.User
|
|
var total int64
|
|
|
|
if err := s.db.Model(&db.User{}).Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
offset := (page - 1) * size
|
|
if err := s.db.Preload("Domain").Offset(offset).Limit(size).Find(&users).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return users, total, nil
|
|
}
|