Skip to content
Merged

Stc #16

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 // 是否启用邮箱别名限制
Expand Down
46 changes: 46 additions & 0 deletions common/verification.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package common

import (
"context"
"errors"
"strings"
"sync"
"time"

"github.com/go-redis/redis/v8"
"github.com/google/uuid"
)

Expand All @@ -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)
Expand All @@ -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{
Expand All @@ -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]
Expand All @@ -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)
Expand All @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion controller/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions model/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"] = ""
Expand Down Expand Up @@ -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":
Expand Down
20 changes: 20 additions & 0 deletions model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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(&current).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 == "" {
Expand Down