diff --git a/common/constants.go b/common/constants.go index 69a746e88a5c..547cce773c0e 100644 --- a/common/constants.go +++ b/common/constants.go @@ -50,6 +50,7 @@ var WeChatAuthEnabled = false var TelegramOAuthEnabled = false var TurnstileCheckEnabled = false var RegisterEnabled = true +var SingleDeviceLoginEnabled = true var EmailDomainRestrictionEnabled = false // 是否启用邮箱域名限制 var EmailAliasRestrictionEnabled = false // 是否启用邮箱别名限制 diff --git a/common/verification.go b/common/verification.go index 41fd3c943e7e..8dac2c2b270b 100644 --- a/common/verification.go +++ b/common/verification.go @@ -1,10 +1,13 @@ package common import ( + "context" + "errors" "strings" "sync" "time" + "github.com/go-redis/redis/v8" "github.com/google/uuid" ) @@ -23,6 +26,8 @@ var verificationMap map[string]verificationValue var verificationMapMaxSize = 10 var VerificationValidMinutes = 10 +const verificationRedisKeyPrefix = "verification:" + func GenerateVerificationCode(length int) string { code := uuid.New().String() code = strings.Replace(code, "-", "", -1) @@ -33,6 +38,15 @@ func GenerateVerificationCode(length int) string { } func RegisterVerificationCodeWithKey(key string, code string, purpose string) { + key = normalizeVerificationKey(key) + if RedisEnabled && RDB != nil { + err := RDB.Set(context.Background(), verificationRedisKey(key, purpose), code, verificationTTL()).Err() + if err == nil { + return + } + SysLog("failed to save verification code to Redis, falling back to memory: " + err.Error()) + } + verificationMutex.Lock() defer verificationMutex.Unlock() verificationMap[purpose+key] = verificationValue{ @@ -45,6 +59,19 @@ func RegisterVerificationCodeWithKey(key string, code string, purpose string) { } func VerifyCodeWithKey(key string, code string, purpose string) bool { + key = normalizeVerificationKey(key) + code = strings.TrimSpace(code) + if RedisEnabled && RDB != nil { + value, err := RDB.Get(context.Background(), verificationRedisKey(key, purpose)).Result() + if err == nil { + return code == value + } + if errors.Is(err, redis.Nil) { + return false + } + SysLog("failed to read verification code from Redis, falling back to memory: " + err.Error()) + } + verificationMutex.Lock() defer verificationMutex.Unlock() value, okay := verificationMap[purpose+key] @@ -56,6 +83,13 @@ func VerifyCodeWithKey(key string, code string, purpose string) bool { } func DeleteKey(key string, purpose string) { + key = normalizeVerificationKey(key) + if RedisEnabled && RDB != nil { + if err := RDB.Del(context.Background(), verificationRedisKey(key, purpose)).Err(); err != nil { + SysLog("failed to delete verification code from Redis: " + err.Error()) + } + } + verificationMutex.Lock() defer verificationMutex.Unlock() delete(verificationMap, purpose+key) @@ -71,6 +105,18 @@ func removeExpiredPairs() { } } +func verificationRedisKey(key string, purpose string) string { + return verificationRedisKeyPrefix + purpose + ":" + key +} + +func verificationTTL() time.Duration { + return time.Duration(VerificationValidMinutes) * time.Minute +} + +func normalizeVerificationKey(key string) string { + return strings.ToLower(strings.TrimSpace(key)) +} + func init() { verificationMutex.Lock() defer verificationMutex.Unlock() diff --git a/controller/channel.go b/controller/channel.go index b0dd22861507..fbe3cd36238f 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -68,6 +68,14 @@ func clearChannelInfo(channel *model.Channel) { } } +func normalizeChannelBaseURL(channel *model.Channel) { + if channel == nil || channel.BaseURL == nil { + return + } + normalized := strings.TrimRight(strings.TrimSpace(*channel.BaseURL), "/") + channel.BaseURL = &normalized +} + func GetAllChannels(c *gin.Context) { pageInfo := common.GetPageQuery(c) channelData := make([]*model.Channel, 0) @@ -570,6 +578,7 @@ func AddChannel(c *gin.Context) { common.ApiError(c, err) return } + normalizeChannelBaseURL(addChannelRequest.Channel) // 使用统一的校验函数 if err := validateChannel(addChannelRequest.Channel, true); err != nil { @@ -846,6 +855,7 @@ func UpdateChannel(c *gin.Context) { common.ApiError(c, err) return } + normalizeChannelBaseURL(&channel.Channel) // 使用统一的校验函数 if err := validateChannel(&channel.Channel, false); err != nil { diff --git a/controller/user.go b/controller/user.go index 5d026ee005ec..506eb36b9647 100644 --- a/controller/user.go +++ b/controller/user.go @@ -92,7 +92,7 @@ func Login(c *gin.Context) { // setup session & cookies and then return user info func setupLogin(user *model.User, c *gin.Context) { // 每次登录都轮换会话令牌,确保同账号仅保留最新设备会话 - sessionToken, err := model.RotateUserSessionToken(user.Id) + sessionToken, err := model.IssueUserSessionToken(user.Id) if err != nil { common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed) return @@ -408,6 +408,7 @@ func GetSelf(c *gin.Context) { "role": user.Role, "status": user.Status, "email": user.Email, + "phone": user.Phone, "github_id": user.GitHubId, "discord_id": user.DiscordId, "oidc_id": user.OidcId, diff --git a/middleware/auth.go b/middleware/auth.go index 950ec19a00fd..0c8f93361ebc 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -123,7 +123,7 @@ func authHelper(c *gin.Context, minRole int) { return } } - // Session 登录态下,校验会话令牌是否仍为最新(单设备登录策略) + // Session 登录态下,校验会话令牌是否仍为最新;关闭单设备登录时,多设备共享同一个令牌。 if !useAccessToken { sessionToken, _ := session.Get("session_token").(string) idInt, ok := sessionNumberToInt(id) diff --git a/model/option.go b/model/option.go index c54e860c371a..d07e417e7fff 100644 --- a/model/option.go +++ b/model/option.go @@ -44,6 +44,7 @@ func InitOptionMap() { common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled) common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled) + common.OptionMap["SingleDeviceLoginEnabled"] = strconv.FormatBool(common.SingleDeviceLoginEnabled) common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled) common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled) common.OptionMap["LogConsumeEnabled"] = strconv.FormatBool(common.LogConsumeEnabled) @@ -63,6 +64,13 @@ func InitOptionMap() { common.OptionMap["SMTPToken"] = "" common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled) common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin) + common.OptionMap["SmsEnabled"] = "false" + common.OptionMap["SmsProvider"] = "" + common.OptionMap["SmsSignName"] = "" + common.OptionMap["SmsTemplateCode"] = "" + common.OptionMap["SmsAccessKeyId"] = "" + common.OptionMap["SmsAccessKeySecret"] = "" + common.OptionMap["SmsRegisterTemplate"] = "您的验证码是 {{code}},{{minutes}} 分钟内有效。如非本人操作,请忽略。" common.OptionMap["Notice"] = "" common.OptionMap["About"] = "" common.OptionMap["HomePageContent"] = "" @@ -270,6 +278,8 @@ func updateOptionMap(key string, value string) (err error) { common.TurnstileCheckEnabled = boolValue case "RegisterEnabled": common.RegisterEnabled = boolValue + case "SingleDeviceLoginEnabled": + common.SingleDeviceLoginEnabled = boolValue case "EmailDomainRestrictionEnabled": common.EmailDomainRestrictionEnabled = boolValue case "EmailAliasRestrictionEnabled": diff --git a/model/user.go b/model/user.go index e650931d7f23..03610bc37938 100644 --- a/model/user.go +++ b/model/user.go @@ -29,6 +29,7 @@ type User struct { Role int `json:"role" gorm:"type:int;default:1"` // admin, common Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled Email string `json:"email" gorm:"index" validate:"max=50"` + Phone *string `json:"phone,omitempty" gorm:"type:varchar(20);uniqueIndex"` GitHubId string `json:"github_id" gorm:"column:github_id;index"` DiscordId string `json:"discord_id" gorm:"column:discord_id;index"` OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"` @@ -92,6 +93,25 @@ func RotateUserSessionToken(userId int) (string, error) { return token, nil } +// IssueUserSessionToken returns the token that should be stored in a newly +// created dashboard session. When single-device login is enabled it rotates the +// token so older devices are invalidated; otherwise it reuses the existing token +// so multiple devices can stay signed in. Password changes should still call +// RotateUserSessionToken directly to invalidate all old sessions. +func IssueUserSessionToken(userId int) (string, error) { + if common.SingleDeviceLoginEnabled { + return RotateUserSessionToken(userId) + } + var current string + if err := DB.Model(&User{}).Where("id = ?", userId).Select("session_token").Scan(¤t).Error; err != nil { + return "", err + } + if current != "" { + return current, nil + } + return RotateUserSessionToken(userId) +} + // ValidateUserSessionToken checks whether the session token matches the latest one in DB. func ValidateUserSessionToken(userId int, sessionToken string) (bool, error) { if userId <= 0 || sessionToken == "" {