Skip to content
Open
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
157 changes: 139 additions & 18 deletions controller/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ func Login(c *gin.Context) {

// setup session & cookies and then return user info
func setupLogin(user *model.User, c *gin.Context) {
// 检查是否需要修改初始密码
if user.IsInitialPassword {
c.JSON(http.StatusOK, gin.H{
"message": "请立即修改初始密码",
"success": true,
"data": map[string]interface{}{
"require_change_password": true,
"user_id": user.Id,
"username": user.Username,
},
})
return
}

session := sessions.Default(c)
session.Set("id", user.Id)
session.Set("username", user.Username)
Expand Down Expand Up @@ -206,11 +220,12 @@ func Register(c *gin.Context) {
affCode := user.AffCode // this code is the inviter's code, not the user's own code
inviterId, _ := model.GetUserIdByAffCode(affCode)
cleanUser := model.User{
Username: user.Username,
Password: user.Password,
DisplayName: user.Username,
InviterId: inviterId,
Role: common.RoleCommonUser, // 明确设置角色为普通用户
Username: user.Username,
Password: user.Password,
DisplayName: user.Username,
InviterId: inviterId,
Role: common.RoleCommonUser, // 明确设置角色为普通用户
IsInitialPassword: false, // 用户自行注册不标记为初始密码
}
if common.EmailVerificationEnabled {
cleanUser.Email = user.Email
Expand Down Expand Up @@ -639,6 +654,10 @@ func UpdateUser(c *gin.Context) {
updatedUser.Password = "" // rollback to what it should be
}
updatePassword := updatedUser.Password != ""
// 如果管理员修改了用户密码,标记为初始密码
if updatePassword {
updatedUser.IsInitialPassword = true
}
if err := updatedUser.Edit(updatePassword); err != nil {
common.ApiError(c, err)
return
Expand Down Expand Up @@ -728,22 +747,36 @@ func UpdateSelf(c *gin.Context) {
return
}

cleanUser := model.User{
Id: c.GetInt("id"),
Username: user.Username,
Password: user.Password,
DisplayName: user.DisplayName,
}
userId := c.GetInt("id")

if user.Password == "$I_LOVE_U" {
user.Password = "" // rollback to what it should be
cleanUser.Password = ""
}
updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id)
updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, userId)
if err != nil {
common.ApiError(c, err)
return
}
if err := cleanUser.Update(updatePassword); err != nil {

// 构建更新字段的 map
updates := map[string]interface{}{
"username": user.Username,
"display_name": user.DisplayName,
}

// 如果正在更新密码,同时清除初始密码标记
if updatePassword {
hashedPassword, err := common.Password2Hash(user.Password)
if err != nil {
common.ApiError(c, err)
return
}
updates["password"] = hashedPassword
updates["is_initial_password"] = false // 明确设置为 false
}

// 执行更新
if err := model.DB.Model(&model.User{}).Where("id = ?", userId).Updates(updates).Error; err != nil {
common.ApiError(c, err)
Comment on lines +762 to 780
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Prevent wiping username/display name on partial updates.

Line 762 unconditionally drops username/display_name into the update map. When the client submits a password-only payload (very common), those fields are omitted in JSON, unmarshal defaults them to "", and the DB write will blank the username/display name, breaking login. Only include keys that were actually provided in the request.

-	// 构建更新字段的 map
-	updates := map[string]interface{}{
-		"username":     user.Username,
-		"display_name": user.DisplayName,
-	}
+	// 构建更新字段的 map,仅在请求中包含时才更新
+	updates := map[string]interface{}{}
+	if _, ok := requestData["username"]; ok {
+		updates["username"] = user.Username
+	}
+	if _, ok := requestData["display_name"]; ok {
+		updates["display_name"] = user.DisplayName
+	}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In controller/user.go around lines 762–780, the code unconditionally sets
"username" and "display_name" in the updates map which causes blanking when the
client omits those fields; only include these keys when they were actually
provided in the request — change the code to add username/display_name into
updates only if user.Username != "" and user.DisplayName != "" (or, if you want
to support empty-string updates, change the request DTO to use pointer fields
and check for nil pointers instead), leaving the password update logic as-is.

return
}
Expand Down Expand Up @@ -856,10 +889,11 @@ func CreateUser(c *gin.Context) {
}
// Even for admin users, we cannot fully trust them!
cleanUser := model.User{
Username: user.Username,
Password: user.Password,
DisplayName: user.DisplayName,
Role: user.Role, // 保持管理员设置的角色
Username: user.Username,
Password: user.Password,
DisplayName: user.DisplayName,
Role: user.Role, // 保持管理员设置的角色
IsInitialPassword: true, // 管理员创建的用户标记为初始密码
}
if err := cleanUser.Insert(0); err != nil {
common.ApiError(c, err)
Expand Down Expand Up @@ -1109,6 +1143,93 @@ type UpdateUserSettingRequest struct {
RecordIpLog bool `json:"record_ip_log"`
}

type ChangeInitialPasswordRequest struct {
UserId int `json:"user_id" binding:"required"`
Username string `json:"username" binding:"required"`
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8,max=20"`
}

func ChangeInitialPassword(c *gin.Context) {
var req ChangeInitialPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数: " + err.Error(),
})
return
}

// 获取用户
user, err := model.GetUserById(req.UserId, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户不存在",
})
return
}

// 验证用户名匹配
if user.Username != req.Username {
c.JSON(http.StatusOK, gin.H{
"success": false,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个要用gin自带的bind和validate

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修改为使用binding,并删除.sql

"message": "用户信息不匹配",
})
return
}

// 验证旧密码
if !common.ValidatePasswordAndHash(req.OldPassword, user.Password) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "原密码错误",
})
return
}

// 验证是否确实需要修改初始密码
if !user.IsInitialPassword {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户不需要修改初始密码",
})
return
}

// 更新密码并清除初始密码标记
hashedPassword, err := common.Password2Hash(req.NewPassword)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "密码加密失败",
})
return
}

// 使用 map 更新以确保 IsInitialPassword = false 能被正确写入
updates := map[string]interface{}{
"password": hashedPassword,
"is_initial_password": false,
}

if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Updates(updates).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "修改密码失败: " + err.Error(),
})
return
}

// 记录日志
model.RecordLog(user.Id, model.LogTypeSystem, "修改初始密码")

c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "密码修改成功,请重新登录",
})
}

func UpdateUserSetting(c *gin.Context) {
var req UpdateUserSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
Expand Down
58 changes: 30 additions & 28 deletions model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,35 @@ import (
// User if you add sensitive fields, don't forget to clean them in setupLogin function.
// Otherwise, the sensitive information will be saved on local storage in plain text!
type User struct {
Id int `json:"id"`
Username string `json:"username" gorm:"unique;index" validate:"max=20"`
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"`
OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database!
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
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"`
GitHubId string `json:"github_id" gorm:"column:github_id;index"`
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"`
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
AccessToken *string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
Quota int `json:"quota" gorm:"type:int;default:0"`
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota
RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
Group string `json:"group" gorm:"type:varchar(64);default:'default'"`
AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"`
AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"`
AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度
AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度
InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"`
DeletedAt gorm.DeletedAt `gorm:"index"`
LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"`
Setting string `json:"setting" gorm:"type:text;column:setting"`
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
Id int `json:"id"`
Username string `json:"username" gorm:"unique;index" validate:"max=20"`
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"`
OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database!
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
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"`
GitHubId string `json:"github_id" gorm:"column:github_id;index"`
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"`
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
AccessToken *string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
Quota int `json:"quota" gorm:"type:int;default:0"`
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota
RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
Group string `json:"group" gorm:"type:varchar(64);default:'default'"`
AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"`
AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"`
AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度
AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度
InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"`
DeletedAt gorm.DeletedAt `gorm:"index"`
LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"`
Setting string `json:"setting" gorm:"type:text;column:setting"`
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
IsInitialPassword bool `json:"is_initial_password" gorm:"type:boolean;default:false;column:is_initial_password"` // 是否是初始密码
}

func (user *User) ToBaseUser() *UserBase {
Expand Down Expand Up @@ -464,6 +465,7 @@ func (user *User) Edit(updatePassword bool) error {
}
if updatePassword {
updates["password"] = newUser.Password
updates["is_initial_password"] = newUser.IsInitialPassword
}

DB.First(&user, user.Id)
Expand Down
1 change: 1 addition & 0 deletions router/api-router.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func SetApiRouter(router *gin.Engine) {
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login)
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin)
userRoute.POST("/change_initial_password", middleware.CriticalRateLimit(), controller.ChangeInitialPassword)
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add Turnstile check to protect password-change endpoint.

Parity with login/register; mitigates brute-force attempts.

- userRoute.POST("/change_initial_password", middleware.CriticalRateLimit(), controller.ChangeInitialPassword)
+ userRoute.POST("/change_initial_password",
+   middleware.CriticalRateLimit(),
+   middleware.TurnstileCheck(),
+   controller.ChangeInitialPassword)

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In router/api-router.go around line 55, the password-change endpoint needs
Turnstile protection for parity with login/register; update the route definition
to include the Turnstile middleware (e.g. add middleware.Turnstile() in the
middleware chain before the controller handler) so requests hit the Turnstile
check prior to the password-change controller; preserve existing rate-limit or
critical middleware ordering when inserting the Turnstile middleware.

userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish)
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
Expand Down
Loading