Files
alist/server/ftp.go
千石 00120cba27 feat: enhance permission control and label management (#9215)
* 标签管理

* pr检查优化

* feat(role): Implement role management functionality

- Add role management routes in `server/router.go` for listing, getting, creating, updating, and deleting roles
- Introduce `initRoles()` in `internal/bootstrap/data/data.go` for initializing roles during bootstrap
- Create `internal/op/role.go` to handle role operations including caching and singleflight
- Implement role handler functions in `server/handles/role.go` for API responses
- Define database operations for roles in `internal/db/role.go`
- Extend `internal/db/db.go` for role model auto-migration
- Design `internal/model/role.go` to represent role structure with ID, name, description, base path, and permissions
- Initialize default roles (`admin` and `guest`) in `internal/bootstrap/data/role.go` during startup

* refactor(user roles): Support multiple roles for users

- Change the `Role` field type from `int` to `[]int` in `drivers/alist_v3/types.go` and `drivers/quqi/types.go`.
- Update the `Role` field in `internal/model/user.go` to use a new `Roles` type with JSON and database support.
- Modify `IsGuest` and `IsAdmin` methods to check for roles using `Contains` method.
- Update `GetUserByRole` method in `internal/db/user.go` to handle multiple roles.
- Add `roles.go` to define a new `Roles` type with JSON marshalling and scanning capabilities.
- Adjust code in `server/handles/user.go` to compare roles with `utils.SliceEqual`.
- Change role initialization for users in `internal/bootstrap/data/dev.go` and `internal/bootstrap/data/user.go`.
- Update `Role` handling in `server/handles/task.go`, `server/handles/ssologin.go`, and `server/handles/ldap_login.go`.

* feat(user/role): Add path limit check for user and role permissions

- Add new permission bit for checking path limits in `user.go`
- Implement `CheckPathLimit` method in `User` struct to validate path access
- Modify `JoinPath` method in `User` to enforce path limit checks
- Update `role.go` to include path limit logic in `Role` struct
- Document new permission bit in `Role` and `User` comments for clarity

* feat(permission): Add role-based permission handling

- Introduce `role_perm.go` for managing user permissions based on roles.
- Implement `HasPermission` and `MergeRolePermissions` functions.
- Update `webdav.go` to utilize role-based permissions instead of direct user checks.
- Modify `fsup.go` to integrate `CanAccessWithRoles` function.
- Refactor `fsread.go` to use `common.HasPermission` for permission validation.
- Adjust `fsmanage.go` for role-based access control checks.
- Enhance `ftp.go` and `sftp.go` to manage FTP access via roles.
- Update `fsbatch.go` to employ `MergeRolePermissions` for batch operations.
- Replace direct user permission checks with role-based permission handling across various modules.

* refactor(user): Replace integer role values with role IDs

- Change `GetAdmin()` and `GetGuest()` functions to retrieve role by name and use role ID.
- Add patch for version `v3.45.2` to convert legacy integer roles to role IDs.
- Update `dev.go` and `user.go` to use role IDs instead of integer values for roles.
- Remove redundant code in `role.go` related to guest role creation.
- Modify `ssologin.go` and `ldap_login.go` to set user roles to nil instead of using integer roles.
- Introduce `convert_roles.go` to handle conversion of legacy roles and ensure role existence in the database.

* feat(role_perm): implement support for multiple base paths for roles

- Modify role permission checks to support multiple base paths
- Update role creation and update functions to handle multiple base paths
- Add migration script to convert old base_path to base_paths
- Define new Paths type for handling multiple paths in the model
- Adjust role model to replace BasePath with BasePaths
- Update existing patches to handle roles with multiple base paths
- Update bootstrap data to reflect the new base_paths field

* feat(role): Restrict modifications to default roles (admin and guest)

- Add validation to prevent changes to "admin" and "guest" roles in `UpdateRole` and `DeleteRole` functions.
- Introduce `ErrChangeDefaultRole` error in `internal/errs/role.go` to standardize error messaging.
- Update role-related API handlers in `server/handles/role.go` to enforce the new restriction.
- Enhance comments in `internal/bootstrap/data/role.go` to clarify the significance of default roles.
- Ensure consistent error responses for unauthorized role modifications across the application.

* 🔄 **refactor(role): Enhance role permission handling**

- Replaced `BasePaths` with `PermissionPaths` in `Role` struct for better permission granularity.
- Introduced JSON serialization for `PermissionPaths` using `RawPermission` field in `Role` struct.
- Implemented `BeforeSave` and `AfterFind` GORM hooks for handling `PermissionPaths` serialization.
- Refactored permission calculation logic in `role_perm.go` to work with `PermissionPaths`.
- Updated role creation logic to initialize `PermissionPaths` for `admin` and `guest` roles.
- Removed deprecated `CheckPathLimit` method from `Role` struct.

* fix(model/user/role): update permission settings for admin and role

- Change `RawPermission` field in `role.go` to hide JSON representation
- Update `Permission` field in `user.go` to `0xFFFF` for full access
- Modify `PermissionScopes` in `role.go` to `0xFFFF` for enhanced permissions

* 🔒 feat(role-permissions): Enhance role-based access control

- Introduce `canReadPathByRole` function in `role_perm.go` to verify path access based on user roles
- Modify `CanAccessWithRoles` to include role-based path read check
- Add `RoleNames` and `Permissions` to `UserResp` struct in `auth.go` for enhanced user role and permission details
- Implement role details aggregation in `auth.go` to populate `RoleNames` and `Permissions`
- Update `User` struct in `user.go` to include `RolesDetail` for more detailed role information
- Enhance middleware in `auth.go` to load and verify detailed role information for users
- Move `guest` user initialization logic in `user.go` to improve code organization and avoid repetition

* 🔒 fix(permissions): Add permission checks for archive operations

- Add `MergeRolePermissions` and `HasPermission` checks to validate user access for reading archives
- Ensure users have `PermReadArchives` before proceeding with `GetNearestMeta` in specific archive paths
- Implement permission checks for decompress operations, requiring `PermDecompress` for source paths
- Return `PermissionDenied` errors with 403 status if user lacks necessary permissions

* 🔒 fix(server): Add permission check for offline download

- Add permission merging logic for user roles
- Check user has permission for offline download addition
- Return error response with "permission denied" if check fails

*  feat(role-permission): Implement path-based role permission checks

- Add `CheckPathLimitWithRoles` function to validate access based on `PermPathLimit` permission.
- Integrate `CheckPathLimitWithRoles` in `offline_download` to enforce path-based access control.
- Apply `CheckPathLimitWithRoles` across file system management operations (e.g., creation, movement, deletion).
- Ensure `CheckPathLimitWithRoles` is invoked for batch operations and archive-related actions.
- Update error handling to return `PermissionDenied` if the path validation fails.
- Import `errs` package in `offline_download` for consistent error responses.

*  feat(role-permission): Implement path-based role permission checks

- Add `CheckPathLimitWithRoles` function to validate access based on `PermPathLimit` permission.
- Integrate `CheckPathLimitWithRoles` in `offline_download` to enforce path-based access control.
- Apply `CheckPathLimitWithRoles` across file system management operations (e.g., creation, movement, deletion).
- Ensure `CheckPathLimitWithRoles` is invoked for batch operations and archive-related actions.
- Update error handling to return `PermissionDenied` if the path validation fails.
- Import `errs` package in `offline_download` for consistent error responses.

* ♻️ refactor(access-control): Update access control logic to use role-based checks

- Remove deprecated logic from `CanAccess` function in `check.go`, replacing it with `CanAccessWithRoles` for improved role-based access control.
- Modify calls in `search.go` to use `CanAccessWithRoles` for more precise handling of permissions.
- Update `fsread.go` to utilize `CanAccessWithRoles`, ensuring accurate access validation based on user roles.
- Simplify import statements in `check.go` by removing unused packages to clean up the codebase.

*  feat(fs): Improve visibility logic for hidden files

- Import `server/common` package to handle permissions more robustly
- Update `whetherHide` function to use `MergeRolePermissions` for user-specific path permissions
- Replace direct user checks with `HasPermission` for `PermSeeHides`
- Enhance logic to ensure `nil` user cases are handled explicitly

* 标签管理

* feat(db/auth/user): Enhance role handling and clean permission paths

- Comment out role modification checks in `server/handles/user.go` to allow flexible role changes.
- Improve permission path handling in `server/handles/auth.go` by normalizing and deduplicating paths.
- Introduce `addedPaths` map in `CurrentUser` to prevent duplicate permissions.

* feat(storage/db): Implement role permissions path prefix update

- Add `UpdateRolePermissionsPathPrefix` function in `role.go` to update role permissions paths.
- Modify `storage.go` to call the new function when the mount path is renamed.
- Introduce path cleaning and prefix matching logic for accurate path updates.
- Ensure roles are updated only if their permission scopes are modified.
- Handle potential errors with informative messages during database operations.

* feat(role-migration): Implement role conversion and introduce NEWGENERAL role

- Add `NEWGENERAL` to the roles enumeration in `user.go`
- Create new file `convert_role.go` for migrating legacy roles to new model
- Implement `ConvertLegacyRoles` function to handle role conversion with permission scopes
- Add `convert_role.go` patch to `all.go` under version `v3.46.0`

* feat(role/auth): Add role retrieval by user ID and update path prefixes

- Add `GetRolesByUserID` function for efficient role retrieval by user ID
- Implement `UpdateUserBasePathPrefix` to update user base paths
- Modify `UpdateRolePermissionsPathPrefix` to return modified role IDs
- Update `auth.go` middleware to use the new role retrieval function
- Refresh role and user caches upon path prefix updates to maintain consistency

---------

Co-authored-by: Leslie-Xy <540049476@qq.com>
2025-07-26 09:51:59 +08:00

291 lines
8.1 KiB
Go

package server
import (
"context"
"crypto/tls"
"errors"
"fmt"
ftpserver "github.com/KirCute/ftpserverlib-pasvportmap"
"github.com/alist-org/alist/v3/internal/conf"
"github.com/alist-org/alist/v3/internal/model"
"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/server/common"
"github.com/alist-org/alist/v3/server/ftp"
"math/rand"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
)
type FtpMainDriver struct {
settings *ftpserver.Settings
proxyHeader *http.Header
clients map[uint32]ftpserver.ClientContext
shutdownLock sync.RWMutex
isShutdown bool
tlsConfig *tls.Config
}
func NewMainDriver() (*FtpMainDriver, error) {
header := &http.Header{}
header.Add("User-Agent", setting.GetStr(conf.FTPProxyUserAgent))
transferType := ftpserver.TransferTypeASCII
if conf.Conf.FTP.DefaultTransferBinary {
transferType = ftpserver.TransferTypeBinary
}
activeConnCheck := ftpserver.IPMatchDisabled
if conf.Conf.FTP.EnableActiveConnIPCheck {
activeConnCheck = ftpserver.IPMatchRequired
}
pasvConnCheck := ftpserver.IPMatchDisabled
if conf.Conf.FTP.EnablePasvConnIPCheck {
pasvConnCheck = ftpserver.IPMatchRequired
}
tlsRequired := ftpserver.ClearOrEncrypted
if setting.GetBool(conf.FTPImplicitTLS) {
tlsRequired = ftpserver.ImplicitEncryption
} else if setting.GetBool(conf.FTPMandatoryTLS) {
tlsRequired = ftpserver.MandatoryEncryption
}
tlsConf, err := getTlsConf(setting.GetStr(conf.FTPTLSPrivateKeyPath), setting.GetStr(conf.FTPTLSPublicCertPath))
if err != nil && tlsRequired != ftpserver.ClearOrEncrypted {
return nil, fmt.Errorf("FTP mandatory TLS has been enabled, but the certificate failed to load: %w", err)
}
return &FtpMainDriver{
settings: &ftpserver.Settings{
ListenAddr: conf.Conf.FTP.Listen,
PublicHost: lookupIP(setting.GetStr(conf.FTPPublicHost)),
PassiveTransferPortGetter: newPortMapper(setting.GetStr(conf.FTPPasvPortMap)),
FindPasvPortAttempts: conf.Conf.FTP.FindPasvPortAttempts,
ActiveTransferPortNon20: conf.Conf.FTP.ActiveTransferPortNon20,
IdleTimeout: conf.Conf.FTP.IdleTimeout,
ConnectionTimeout: conf.Conf.FTP.ConnectionTimeout,
DisableMLSD: false,
DisableMLST: false,
DisableMFMT: true,
Banner: setting.GetStr(conf.Announcement),
TLSRequired: tlsRequired,
DisableLISTArgs: false,
DisableSite: false,
DisableActiveMode: conf.Conf.FTP.DisableActiveMode,
EnableHASH: false,
DisableSTAT: false,
DisableSYST: false,
EnableCOMB: false,
DefaultTransferType: transferType,
ActiveConnectionsCheck: activeConnCheck,
PasvConnectionsCheck: pasvConnCheck,
SiteHandlers: map[string]ftpserver.SiteHandler{
"SIZE": ftp.HandleSIZE,
},
},
proxyHeader: header,
clients: make(map[uint32]ftpserver.ClientContext),
shutdownLock: sync.RWMutex{},
isShutdown: false,
tlsConfig: tlsConf,
}, nil
}
func (d *FtpMainDriver) GetSettings() (*ftpserver.Settings, error) {
return d.settings, nil
}
func (d *FtpMainDriver) ClientConnected(cc ftpserver.ClientContext) (string, error) {
if d.isShutdown || !d.shutdownLock.TryRLock() {
return "", errors.New("server has shutdown")
}
defer d.shutdownLock.RUnlock()
d.clients[cc.ID()] = cc
return "AList FTP Endpoint", nil
}
func (d *FtpMainDriver) ClientDisconnected(cc ftpserver.ClientContext) {
err := cc.Close()
if err != nil {
utils.Log.Errorf("failed to close client: %v", err)
}
delete(d.clients, cc.ID())
}
func (d *FtpMainDriver) AuthUser(cc ftpserver.ClientContext, user, pass string) (ftpserver.ClientDriver, error) {
var userObj *model.User
var err error
if user == "anonymous" || user == "guest" {
userObj, err = op.GetGuest()
if err != nil {
return nil, err
}
} else {
userObj, err = op.GetUserByName(user)
if err != nil {
return nil, err
}
passHash := model.StaticHash(pass)
if err = userObj.ValidatePwdStaticHash(passHash); err != nil {
return nil, err
}
}
perm := common.MergeRolePermissions(userObj, userObj.BasePath)
if userObj.Disabled || !common.HasPermission(perm, common.PermFTPAccess) {
return nil, errors.New("user is not allowed to access via FTP")
}
ctx := context.Background()
ctx = context.WithValue(ctx, "user", userObj)
if user == "anonymous" || user == "guest" {
ctx = context.WithValue(ctx, "meta_pass", pass)
} else {
ctx = context.WithValue(ctx, "meta_pass", "")
}
ctx = context.WithValue(ctx, "client_ip", cc.RemoteAddr().String())
ctx = context.WithValue(ctx, "proxy_header", d.proxyHeader)
return ftp.NewAferoAdapter(ctx), nil
}
func (d *FtpMainDriver) GetTLSConfig() (*tls.Config, error) {
if d.tlsConfig == nil {
return nil, errors.New("TLS config not provided")
}
return d.tlsConfig, nil
}
func (d *FtpMainDriver) Stop() {
d.isShutdown = true
d.shutdownLock.Lock()
defer d.shutdownLock.Unlock()
for _, value := range d.clients {
_ = value.Close()
}
}
func lookupIP(host string) string {
if host == "" || net.ParseIP(host) != nil {
return host
}
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
utils.Log.Fatalf("given FTP public host is invalid, and the default value will be used: %v", err)
return ""
}
for _, ip := range ips {
if ip.To4() != nil {
return ip.String()
}
}
v6 := ips[0].String()
utils.Log.Warnf("no IPv4 record looked up, %s will be used as public host, and it might do not work.", v6)
return v6
}
func newPortMapper(str string) ftpserver.PasvPortGetter {
if str == "" {
return nil
}
pasvPortMappers := strings.Split(strings.Replace(str, "\n", ",", -1), ",")
type group struct {
ExposedStart int
ListenedStart int
Length int
}
groups := make([]group, len(pasvPortMappers))
totalLength := 0
convertToPorts := func(str string) (int, int, error) {
start, end, multi := strings.Cut(str, "-")
if multi {
si, err := strconv.Atoi(start)
if err != nil {
return 0, 0, err
}
ei, err := strconv.Atoi(end)
if err != nil {
return 0, 0, err
}
if ei < si || ei < 1024 || si < 1024 || ei > 65535 || si > 65535 {
return 0, 0, errors.New("invalid port")
}
return si, ei - si + 1, nil
} else {
ret, err := strconv.Atoi(str)
if err != nil {
return 0, 0, err
} else {
return ret, 1, nil
}
}
}
for i, mapper := range pasvPortMappers {
var err error
exposed, listened, mapped := strings.Cut(mapper, ":")
for {
if mapped {
var es, ls, el, ll int
es, el, err = convertToPorts(exposed)
if err != nil {
break
}
ls, ll, err = convertToPorts(listened)
if err != nil {
break
}
if el != ll {
err = errors.New("the number of exposed ports and listened ports does not match")
break
}
groups[i].ExposedStart = es
groups[i].ListenedStart = ls
groups[i].Length = el
totalLength += el
} else {
var start, length int
start, length, err = convertToPorts(mapper)
groups[i].ExposedStart = start
groups[i].ListenedStart = start
groups[i].Length = length
totalLength += length
}
break
}
if err != nil {
utils.Log.Fatalf("failed to convert FTP PASV port mapper %s: %v, the port mapper will be ignored.", mapper, err)
return nil
}
}
return func() (int, int, bool) {
idxPort := rand.Intn(totalLength)
for _, g := range groups {
if idxPort >= g.Length {
idxPort -= g.Length
} else {
return g.ExposedStart + idxPort, g.ListenedStart + idxPort, true
}
}
// unreachable
return 0, 0, false
}
}
func getTlsConf(keyPath, certPath string) (*tls.Config, error) {
if keyPath == "" || certPath == "" {
return nil, errors.New("private key or certificate is not provided")
}
cert, err := os.ReadFile(certPath)
if err != nil {
return nil, err
}
key, err := os.ReadFile(keyPath)
if err != nil {
return nil, err
}
tlsCert, err := tls.X509KeyPair(cert, key)
if err != nil {
return nil, err
}
return &tls.Config{Certificates: []tls.Certificate{tlsCert}}, nil
}