Compare commits

..

4 Commits

Author SHA1 Message Date
千石
eb4c35db75 feat(device/session): per-user max devices & TTL, WebDAV reactivation, admin clean/list APIs (#9315)
* feat(auth): Improved device session management logic

- Replaced the `userID` parameter with the `user` object to support operations with more user attributes.
- Introduced `SessionTTL` and `MaxDevices` properties in the `Handle` and `EnsureActiveOnLogin` functions to support user-defined settings.
- Adjusted the session creation and verification logic in `session.go` to support user-defined device count and session duration.
- Added help documentation in `setting.go` to explain the configuration purposes of `MaxDevices` and `DeviceSessionTTL`.
- Added optional `MaxDevices` and `SessionTTL` properties to the user entity in `user.go` and persisted these settings across user updates.
- Modified the device handling logic in `webdav.go` to adapt to the new user object parameters.

* feat(session): Added session cleanup functionality

- Added the `/clean` route to the route for session cleanup
- Added the `DeleteInactiveSessions` method to support deleting inactive sessions by user ID
- Added the `DeleteSessionByID` method to delete a specific session by session ID
- Defined the `CleanSessionsReq` request structure to support passing a user ID or session ID
- Implemented the `CleanSessions` interface logic to perform corresponding session cleanup operations based on the request parameters

* feat(session): Added session list functionality with usernames

- Added the `SessionWithUser` structure, which includes `Session` and `Username` fields.
- Added the `ListSessionsWithUser` function, which queries and returns a list of sessions with usernames.
- Used a `JOIN` operation to join the session and user tables to retrieve the username associated with each session.
- Changed `ListSessions` to `ListSessionsWithUser` to ensure that the username is retrieved.

* feat(webdav): Enhanced WebDAV authentication logic

- Added logic for generating device keys based on the Client-Id, prioritizing those obtained from the request header.
- If the Client-Id is missing, attempts to obtain it from the cookie. If that still doesn't exist, generates a random suffix for the client IP address as an identifier.
- Stores the generated Client-Id in a cookie to ensure consistency across subsequent requests.
- Use the device.EnsureActiveOnLogin method instead of the original Handle method to reactivate inactive sessions.
2025-09-11 11:27:07 +08:00
千石
fcbc79cb24 feat: Support 123pan safebox (#9311)
* feat(meta): Added a SafePassword field

- Added the SafePassword field to meta.go
- Revised the field format to align with the code style
- The SafePassword field is used to supplement the extended functionality

* feat(driver): Added support for safe unlocking logic

- Added safe file unlocking logic in `driver.go`, returning an error if unlocking fails.
- Introduced the `safeBoxUnlocked` variable of type `sync.Map` to record the IDs of unlocked files.
- Enhanced error handling logic to automatically attempt to unlock safe files and re-retrieve the file list.
- Added the `IsLock` field to file types in `types.go` to identify whether they are safe files.
- Added a constant definition for the `SafeBoxUnlock` interface address in `util.go`.
- Added the `unlockSafeBox` method to unlock a safe with a specified file ID via the API.
- Optimized the file retrieval logic to automatically call the unlock method when the safe is locked.

* Refactor (driver): Optimize lock field type

- Changed the `IsLock` field type from `int` to `bool` for better semantics.
- Updated the check logic to use direct Boolean comparisons to improve code readability and accuracy.
2025-09-05 19:58:27 +08:00
Sakkyoi Cheng
930f9f6096 fix(ssologin): missing role in SSO auto-registration and minor callback issue (#9305)
* fix(ssologin): return after error response

* fix(ssologin): set default role for SSO user creation
2025-09-04 22:15:39 +08:00
千石
23107483a1 Refactor (storage): Comment out the path validation logic (#9308)
- Comment out the error return logic for paths with "/"
- Remove storage path restrictions to allow for flexible handling of root paths
2025-09-04 22:14:33 +08:00
16 changed files with 200 additions and 32 deletions

View File

@@ -6,6 +6,8 @@ import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -29,6 +31,7 @@ type Pan123 struct {
model.Storage
Addition
apiRateLimit sync.Map
safeBoxUnlocked sync.Map
}
func (d *Pan123) Config() driver.Config {
@@ -52,10 +55,27 @@ func (d *Pan123) Drop(ctx context.Context) error {
}
func (d *Pan123) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
if f, ok := dir.(File); ok && f.IsLock {
if err := d.unlockSafeBox(f.FileId); err != nil {
return nil, err
}
}
files, err := d.getFiles(ctx, dir.GetID(), dir.GetName())
if err != nil {
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "safe box") || strings.Contains(err.Error(), "保险箱") {
if id, e := strconv.ParseInt(dir.GetID(), 10, 64); e == nil {
if e = d.unlockSafeBox(id); e == nil {
files, err = d.getFiles(ctx, dir.GetID(), dir.GetName())
} else {
return nil, e
}
}
}
if err != nil {
return nil, err
}
}
return utils.SliceConvert(files, func(src File) (model.Obj, error) {
return src, nil
})

View File

@@ -8,6 +8,7 @@ import (
type Addition struct {
Username string `json:"username" required:"true"`
Password string `json:"password" required:"true"`
SafePassword string `json:"safe_password"`
driver.RootID
//OrderBy string `json:"order_by" type:"select" options:"file_id,file_name,size,update_at" default:"file_name"`
//OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"`

View File

@@ -20,6 +20,7 @@ type File struct {
Etag string `json:"Etag"`
S3KeyFlag string `json:"S3KeyFlag"`
DownloadUrl string `json:"DownloadUrl"`
IsLock bool `json:"IsLock"`
}
func (f File) CreateTime() time.Time {

View File

@@ -43,6 +43,7 @@ const (
S3Auth = MainApi + "/file/s3_upload_object/auth"
UploadCompleteV2 = MainApi + "/file/upload_complete/v2"
S3Complete = MainApi + "/file/s3_complete_multipart_upload"
SafeBoxUnlock = MainApi + "/restful/goapi/v1/file/safe_box/auth/unlockbox"
//AuthKeySalt = "8-8D$sL8gPjom7bk#cY"
)
@@ -238,6 +239,22 @@ do:
return body, nil
}
func (d *Pan123) unlockSafeBox(fileId int64) error {
if _, ok := d.safeBoxUnlocked.Load(fileId); ok {
return nil
}
data := base.Json{"password": d.SafePassword}
url := fmt.Sprintf("%s?fileId=%d", SafeBoxUnlock, fileId)
_, err := d.Request(url, http.MethodPost, func(req *resty.Request) {
req.SetBody(data)
}, nil)
if err != nil {
return err
}
d.safeBoxUnlocked.Store(fileId, true)
return nil
}
func (d *Pan123) getFiles(ctx context.Context, parentId string, name string) ([]File, error) {
page := 1
total := 0
@@ -267,6 +284,15 @@ func (d *Pan123) getFiles(ctx context.Context, parentId string, name string) ([]
req.SetQueryParams(query)
}, &resp)
if err != nil {
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "safe box") || strings.Contains(err.Error(), "保险箱") {
if fid, e := strconv.ParseInt(parentId, 10, 64); e == nil {
if e = d.unlockSafeBox(fid); e == nil {
return d.getFiles(ctx, parentId, name)
}
return nil, e
}
}
return nil, err
}
log.Debug(string(_res))

View File

@@ -165,9 +165,9 @@ func InitialSettings() []model.SettingItem {
{Key: conf.ForwardDirectLinkParams, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL},
{Key: conf.IgnoreDirectLinkParams, Value: "sign,alist_ts", Type: conf.TypeString, Group: model.GLOBAL},
{Key: conf.WebauthnLoginEnabled, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC},
{Key: conf.MaxDevices, Value: "0", Type: conf.TypeNumber, Group: model.GLOBAL},
{Key: conf.MaxDevices, Value: "0", Type: conf.TypeNumber, Group: model.GLOBAL, Help: `max devices per user (0 for unlimited)`},
{Key: conf.DeviceEvictPolicy, Value: "deny", Type: conf.TypeSelect, Options: "deny,evict_oldest", Group: model.GLOBAL},
{Key: conf.DeviceSessionTTL, Value: "86400", Type: conf.TypeNumber, Group: model.GLOBAL},
{Key: conf.DeviceSessionTTL, Value: "86400", Type: conf.TypeNumber, Group: model.GLOBAL, Help: `session ttl in seconds (0 disables)`},
// single settings
{Key: conf.Token, Value: token, Type: conf.TypeString, Group: model.SINGLE, Flag: model.PRIVATE},

View File

@@ -1,11 +1,17 @@
package db
import (
"github.com/alist-org/alist/v3/internal/conf"
"github.com/alist-org/alist/v3/internal/model"
"github.com/pkg/errors"
"gorm.io/gorm/clause"
)
type SessionWithUser struct {
model.Session
Username string
}
func GetSession(userID uint, deviceKey string) (*model.Session, error) {
s := model.Session{UserID: userID, DeviceKey: deviceKey}
if err := db.Select("user_id, device_key, last_active, status, user_agent, ip").Where(&s).First(&s).Error; err != nil {
@@ -64,6 +70,31 @@ func ListSessions() ([]model.Session, error) {
return sessions, errors.WithStack(err)
}
func ListSessionsWithUser() ([]SessionWithUser, error) {
var sessions []SessionWithUser
sessionTable := conf.Conf.Database.TablePrefix + "sessions"
userTable := conf.Conf.Database.TablePrefix + "users"
err := db.Table(sessionTable).
Select(sessionTable+".user_id, "+sessionTable+".device_key, "+sessionTable+".last_active, "+
sessionTable+".status, "+sessionTable+".user_agent, "+sessionTable+".ip, "+userTable+".username").
Joins("JOIN "+userTable+" ON "+sessionTable+".user_id = "+userTable+".id").
Where(sessionTable+".status = ?", model.SessionActive).
Scan(&sessions).Error
return sessions, errors.WithStack(err)
}
func MarkInactive(sessionID string) error {
return errors.WithStack(db.Model(&model.Session{}).Where("device_key = ?", sessionID).Update("status", model.SessionInactive).Error)
}
func DeleteInactiveSessions(userID *uint) error {
query := db.Where("status = ?", model.SessionInactive)
if userID != nil {
query = query.Where("user_id = ?", *userID)
}
return errors.WithStack(query.Delete(&model.Session{}).Error)
}
func DeleteSessionByID(sessionID string) error {
return errors.WithStack(db.Where("device_key = ?", sessionID).Delete(&model.Session{}).Error)
}

View File

@@ -14,8 +14,11 @@ import (
)
// Handle verifies device sessions for a user and upserts current session.
func Handle(userID uint, deviceKey, ua, ip string) error {
func Handle(user *model.User, deviceKey, ua, ip string) error {
ttl := setting.GetInt(conf.DeviceSessionTTL, 86400)
if user.SessionTTL != nil {
ttl = *user.SessionTTL
}
if ttl > 0 {
_ = db.DeleteSessionsBefore(time.Now().Unix() - int64(ttl))
}
@@ -23,7 +26,7 @@ func Handle(userID uint, deviceKey, ua, ip string) error {
ip = utils.MaskIP(ip)
now := time.Now().Unix()
sess, err := db.GetSession(userID, deviceKey)
sess, err := db.GetSession(user.ID, deviceKey)
if err == nil {
if sess.Status == model.SessionInactive {
return errors.WithStack(errs.SessionInactive)
@@ -39,15 +42,18 @@ func Handle(userID uint, deviceKey, ua, ip string) error {
}
max := setting.GetInt(conf.MaxDevices, 0)
if user.MaxDevices != nil {
max = *user.MaxDevices
}
if max > 0 {
count, err := db.CountActiveSessionsByUser(userID)
count, err := db.CountActiveSessionsByUser(user.ID)
if err != nil {
return err
}
if count >= int64(max) {
policy := setting.GetStr(conf.DeviceEvictPolicy, "deny")
if policy == "evict_oldest" {
if oldest, err := db.GetOldestActiveSession(userID); err == nil {
if oldest, err := db.GetOldestActiveSession(user.ID); err == nil {
if err := db.MarkInactive(oldest.DeviceKey); err != nil {
return err
}
@@ -58,30 +64,40 @@ func Handle(userID uint, deviceKey, ua, ip string) error {
}
}
s := &model.Session{UserID: userID, DeviceKey: deviceKey, UserAgent: ua, IP: ip, LastActive: now, Status: model.SessionActive}
s := &model.Session{UserID: user.ID, DeviceKey: deviceKey, UserAgent: ua, IP: ip, LastActive: now, Status: model.SessionActive}
return db.CreateSession(s)
}
// EnsureActiveOnLogin is used only in login flow:
// - If session exists (even Inactive): reactivate and refresh fields.
// - If not exists: apply max-devices policy, then create Active session.
func EnsureActiveOnLogin(userID uint, deviceKey, ua, ip string) error {
func EnsureActiveOnLogin(user *model.User, deviceKey, ua, ip string) error {
ttl := setting.GetInt(conf.DeviceSessionTTL, 86400)
if user.SessionTTL != nil {
ttl = *user.SessionTTL
}
if ttl > 0 {
_ = db.DeleteSessionsBefore(time.Now().Unix() - int64(ttl))
}
ip = utils.MaskIP(ip)
now := time.Now().Unix()
sess, err := db.GetSession(userID, deviceKey)
sess, err := db.GetSession(user.ID, deviceKey)
if err == nil {
if sess.Status == model.SessionInactive {
max := setting.GetInt(conf.MaxDevices, 0)
if user.MaxDevices != nil {
max = *user.MaxDevices
}
if max > 0 {
count, err := db.CountActiveSessionsByUser(userID)
count, err := db.CountActiveSessionsByUser(user.ID)
if err != nil {
return err
}
if count >= int64(max) {
policy := setting.GetStr(conf.DeviceEvictPolicy, "deny")
if policy == "evict_oldest" {
if oldest, gerr := db.GetOldestActiveSession(userID); gerr == nil {
if oldest, gerr := db.GetOldestActiveSession(user.ID); gerr == nil {
if err := db.MarkInactive(oldest.DeviceKey); err != nil {
return err
}
@@ -103,15 +119,18 @@ func EnsureActiveOnLogin(userID uint, deviceKey, ua, ip string) error {
}
max := setting.GetInt(conf.MaxDevices, 0)
if user.MaxDevices != nil {
max = *user.MaxDevices
}
if max > 0 {
count, err := db.CountActiveSessionsByUser(userID)
count, err := db.CountActiveSessionsByUser(user.ID)
if err != nil {
return err
}
if count >= int64(max) {
policy := setting.GetStr(conf.DeviceEvictPolicy, "deny")
if policy == "evict_oldest" {
if oldest, gerr := db.GetOldestActiveSession(userID); gerr == nil {
if oldest, gerr := db.GetOldestActiveSession(user.ID); gerr == nil {
if err := db.MarkInactive(oldest.DeviceKey); err != nil {
return err
}
@@ -123,7 +142,7 @@ func EnsureActiveOnLogin(userID uint, deviceKey, ua, ip string) error {
}
return db.CreateSession(&model.Session{
UserID: userID,
UserID: user.ID,
DeviceKey: deviceKey,
UserAgent: ua,
IP: ip,

View File

@@ -52,6 +52,8 @@ type User struct {
Permission int32 `json:"permission"`
OtpSecret string `json:"-"`
SsoID string `json:"sso_id"` // unique by sso platform
MaxDevices *int `json:"max_devices"`
SessionTTL *int `json:"session_ttl"`
Authn string `gorm:"type:text" json:"-"`
}

View File

@@ -47,9 +47,9 @@ func CreateStorage(ctx context.Context, storage model.Storage) (uint, error) {
storage.Modified = time.Now()
storage.MountPath = utils.FixAndCleanPath(storage.MountPath)
if storage.MountPath == "/" {
return 0, errors.New("Mount path cannot be '/'")
}
//if storage.MountPath == "/" {
// return 0, errors.New("Mount path cannot be '/'")
//}
var err error
// check driver first
@@ -210,9 +210,9 @@ func UpdateStorage(ctx context.Context, storage model.Storage) error {
}
storage.Modified = time.Now()
storage.MountPath = utils.FixAndCleanPath(storage.MountPath)
if storage.MountPath == "/" {
return errors.New("Mount path cannot be '/'")
}
//if storage.MountPath == "/" {
// return errors.New("Mount path cannot be '/'")
//}
err = db.UpdateStorage(&storage)
if err != nil {
return errors.WithMessage(err, "failed update storage in database")

View File

@@ -95,7 +95,7 @@ func loginHash(c *gin.Context, req *LoginReq) {
key := utils.GetMD5EncodeStr(fmt.Sprintf("%d-%s",
user.ID, clientID))
if err := device.EnsureActiveOnLogin(user.ID, key, c.Request.UserAgent(), c.ClientIP()); err != nil {
if err := device.EnsureActiveOnLogin(user, key, c.Request.UserAgent(), c.ClientIP()); err != nil {
if errors.Is(err, errs.TooManyDevices) {
common.ErrorResp(c, err, 403)
} else {

View File

@@ -10,6 +10,7 @@ import (
type SessionResp struct {
SessionID string `json:"session_id"`
UserID uint `json:"user_id,omitempty"`
Username string `json:"username,omitempty"`
LastActive int64 `json:"last_active"`
Status int `json:"status"`
UA string `json:"ua"`
@@ -40,6 +41,11 @@ type EvictSessionReq struct {
SessionID string `json:"session_id"`
}
type CleanSessionsReq struct {
UserID *uint `json:"user_id"`
SessionID string `json:"session_id"`
}
func EvictMySession(c *gin.Context) {
var req EvictSessionReq
if err := c.ShouldBindJSON(&req); err != nil {
@@ -59,7 +65,7 @@ func EvictMySession(c *gin.Context) {
}
func ListSessions(c *gin.Context) {
sessions, err := db.ListSessions()
sessions, err := db.ListSessionsWithUser()
if err != nil {
common.ErrorResp(c, err, 500)
return
@@ -69,6 +75,7 @@ func ListSessions(c *gin.Context) {
resp[i] = SessionResp{
SessionID: s.DeviceKey,
UserID: s.UserID,
Username: s.Username,
LastActive: s.LastActive,
Status: s.Status,
UA: s.UserAgent,
@@ -90,3 +97,32 @@ func EvictSession(c *gin.Context) {
}
common.SuccessResp(c)
}
func CleanSessions(c *gin.Context) {
var req CleanSessionsReq
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
if req.SessionID != "" {
if err := db.DeleteSessionByID(req.SessionID); err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c)
return
}
if req.UserID != nil {
if err := db.DeleteInactiveSessions(req.UserID); err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c)
return
}
if err := db.DeleteInactiveSessions(nil); err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c)
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"github.com/alist-org/alist/v3/internal/op"
"net/http"
"net/url"
"path"
@@ -154,7 +155,7 @@ func autoRegister(username, userID string, err error) (*model.User, error) {
Password: random.String(16),
Permission: int32(setting.GetInt(conf.SSODefaultPermission, 0)),
BasePath: setting.GetStr(conf.SSODefaultDir),
Role: nil,
Role: model.Roles{op.GetDefaultRoleID()},
Disabled: false,
SsoID: userID,
}
@@ -256,6 +257,7 @@ func OIDCLoginCallback(c *gin.Context) {
user, err = autoRegister(userID, userID, err)
if err != nil {
common.ErrorResp(c, err, 400)
return
}
}
token, err := common.GenerateToken(user)

View File

@@ -87,6 +87,12 @@ func UpdateUser(c *gin.Context) {
if req.OtpSecret == "" {
req.OtpSecret = user.OtpSecret
}
if req.MaxDevices == nil {
req.MaxDevices = user.MaxDevices
}
if req.SessionTTL == nil {
req.SessionTTL = user.SessionTTL
}
if req.Disabled && user.IsAdmin() {
count, err := op.CountEnabledAdminsExcluding(user.ID)
if err != nil {

View File

@@ -109,7 +109,7 @@ func HandleSession(c *gin.Context, user *model.User) bool {
clientID = c.Query("client_id")
}
key := utils.GetMD5EncodeStr(fmt.Sprintf("%d-%s", user.ID, clientID))
if err := device.Handle(user.ID, key, c.Request.UserAgent(), c.ClientIP()); err != nil {
if err := device.Handle(user, key, c.Request.UserAgent(), c.ClientIP()); err != nil {
token := c.GetHeader("Authorization")
if errors.Is(err, errs.SessionInactive) {
_ = common.InvalidateToken(token)

View File

@@ -190,6 +190,7 @@ func admin(g *gin.RouterGroup) {
session := g.Group("/session")
session.GET("/list", handles.ListSessions)
session.POST("/evict", handles.EvictSession)
session.POST("/clean", handles.CleanSessions)
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/alist-org/alist/v3/internal/op"
"github.com/alist-org/alist/v3/internal/setting"
"github.com/alist-org/alist/v3/pkg/utils"
"github.com/alist-org/alist/v3/pkg/utils/random"
"github.com/alist-org/alist/v3/server/common"
"github.com/alist-org/alist/v3/server/webdav"
"github.com/gin-gonic/gin"
@@ -55,6 +56,10 @@ func ServeWebDAV(c *gin.Context) {
handler.ServeHTTP(c.Writer, c.Request.WithContext(ctx))
}
// WebDAVAuth authenticates WebDAV requests and reactivates inactive
// sessions using device.EnsureActiveOnLogin. Device keys are based on
// Client-Id headers when available or the client IP with a random
// suffix to avoid conflicts.
func WebDAVAuth(c *gin.Context) {
guest, _ := op.GetGuest()
username, password, ok := c.Request.BasicAuth()
@@ -72,8 +77,17 @@ func WebDAVAuth(c *gin.Context) {
c.Abort()
return
}
key := utils.GetMD5EncodeStr(fmt.Sprintf("%d-%s", admin.ID, c.ClientIP()))
if err := device.Handle(admin.ID, key, c.Request.UserAgent(), c.ClientIP()); err != nil {
clientID := c.GetHeader("Client-Id")
if clientID == "" {
if cookie, err := c.Request.Cookie("Client-Id"); err == nil {
clientID = cookie.Value
} else {
clientID = c.ClientIP() + "-" + random.String(8)
http.SetCookie(c.Writer, &http.Cookie{Name: "Client-Id", Value: clientID, Path: "/"})
}
}
key := utils.GetMD5EncodeStr(fmt.Sprintf("%d-%s", admin.ID, clientID))
if err := device.EnsureActiveOnLogin(admin, key, c.Request.UserAgent(), c.ClientIP()); err != nil {
c.Status(http.StatusForbidden)
c.Abort()
return
@@ -156,8 +170,17 @@ func WebDAVAuth(c *gin.Context) {
c.Abort()
return
}
key := utils.GetMD5EncodeStr(fmt.Sprintf("%d-%s", user.ID, c.ClientIP()))
if err := device.Handle(user.ID, key, c.Request.UserAgent(), c.ClientIP()); err != nil {
clientID := c.GetHeader("Client-Id")
if clientID == "" {
if cookie, err := c.Request.Cookie("Client-Id"); err == nil {
clientID = cookie.Value
} else {
clientID = c.ClientIP() + "-" + random.String(8)
http.SetCookie(c.Writer, &http.Cookie{Name: "Client-Id", Value: clientID, Path: "/"})
}
}
key := utils.GetMD5EncodeStr(fmt.Sprintf("%d-%s", user.ID, clientID))
if err := device.EnsureActiveOnLogin(user, key, c.Request.UserAgent(), c.ClientIP()); err != nil {
c.Status(http.StatusForbidden)
c.Abort()
return