From a60801173f81855a850643ba83a14f2fecb5d719 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Thu, 17 Sep 2026 02:33:51 +0800 Subject: [PATCH] feat: add password change and session invalidation --- admin_users.go | 5 +++++ auth.go | 37 +++++++++++++++++++++++++++++++++++++ main.go | 1 + 3 files changed, 43 insertions(+) diff --git a/admin_users.go b/admin_users.go index 6879012..2122523 100644 --- a/admin_users.go +++ b/admin_users.go @@ -163,6 +163,11 @@ func (a *App) adminUpdateUser(c *gin.Context) { if err := tx.Save(&u).Error; err != nil { return err } + if in.Password != nil { + if err := tx.Where("user_id = ?", u.ID).Delete(&Session{}).Error; err != nil { + return err + } + } if in.Role != nil { if err := tx.Where("user_id = ?", u.ID).Delete(&UserPermission{}).Error; err != nil { return err diff --git a/auth.go b/auth.go index 30d868e..e7a47f4 100644 --- a/auth.go +++ b/auth.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" ) const sessionCookie = "am_session" @@ -144,6 +145,8 @@ func (a *App) login(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "session generation failed"}) return } + // Keep the session table bounded without requiring a background scheduler. + a.db.Where("expires_at <= ?", time.Now()).Delete(&Session{}) session := Session{UserID: user.ID, TokenHash: hashToken(token), ExpiresAt: time.Now().Add(7 * 24 * time.Hour)} if err := a.db.Create(&session).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "session creation failed"}) @@ -162,6 +165,40 @@ func (a *App) logout(c *gin.Context) { c.Status(http.StatusNoContent) } +func (a *App) changePassword(c *gin.Context) { + user := c.MustGet("user").(*User) + var in struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` + } + if c.BindJSON(&in) != nil || in.CurrentPassword == "" || len(in.NewPassword) < 8 { + c.JSON(http.StatusBadRequest, gin.H{"error": "currentPassword and newPassword(8+) required"}) + return + } + var stored User + if err := a.db.First(&stored, user.ID).Error; err != nil || checkPassword(stored.PasswordHash, in.CurrentPassword) != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "current password is incorrect"}) + return + } + hash, err := hashPassword(in.NewPassword) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "password hashing failed"}) + return + } + if err := a.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&User{}).Where("id = ?", user.ID).Update("password_hash", hash).Error; err != nil { + return err + } + return tx.Where("user_id = ?", user.ID).Delete(&Session{}).Error + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "password change failed"}) + return + } + http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + a.audit(user.ID, "auth.password.change", "user", fmt.Sprint(user.ID), "password changed; sessions revoked") + c.Status(http.StatusNoContent) +} + func (a *App) me(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"user": a.currentUser(c)}) } diff --git a/main.go b/main.go index c848182..158952c 100644 --- a/main.go +++ b/main.go @@ -39,6 +39,7 @@ func main() { api := r.Group("/api") api.POST("/auth/login", a.login) api.POST("/auth/logout", a.requireAuth, a.logout) + api.POST("/auth/password", a.requireAuth, a.changePassword) api.GET("/auth/me", a.requireAuth, a.me) auth := api.Group("", a.requireAuth) auth.GET("/search", a.requirePermission(PermDownload), a.search)