feat: add password change and session invalidation

This commit is contained in:
OpenClaw Agent
2026-09-17 02:33:51 +08:00
parent dea1473424
commit a60801173f
3 changed files with 43 additions and 0 deletions
+5
View File
@@ -163,6 +163,11 @@ func (a *App) adminUpdateUser(c *gin.Context) {
if err := tx.Save(&u).Error; err != nil { if err := tx.Save(&u).Error; err != nil {
return err 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 in.Role != nil {
if err := tx.Where("user_id = ?", u.ID).Delete(&UserPermission{}).Error; err != nil { if err := tx.Where("user_id = ?", u.ID).Delete(&UserPermission{}).Error; err != nil {
return err return err
+37
View File
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
) )
const sessionCookie = "am_session" 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "session generation failed"})
return 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)} 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 { if err := a.db.Create(&session).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session creation failed"}) 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) 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) { func (a *App) me(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user": a.currentUser(c)}) c.JSON(http.StatusOK, gin.H{"user": a.currentUser(c)})
} }
+1
View File
@@ -39,6 +39,7 @@ func main() {
api := r.Group("/api") api := r.Group("/api")
api.POST("/auth/login", a.login) api.POST("/auth/login", a.login)
api.POST("/auth/logout", a.requireAuth, a.logout) api.POST("/auth/logout", a.requireAuth, a.logout)
api.POST("/auth/password", a.requireAuth, a.changePassword)
api.GET("/auth/me", a.requireAuth, a.me) api.GET("/auth/me", a.requireAuth, a.me)
auth := api.Group("", a.requireAuth) auth := api.Group("", a.requireAuth)
auth.GET("/search", a.requirePermission(PermDownload), a.search) auth.GET("/search", a.requirePermission(PermDownload), a.search)