Pārlūkot izejas kodu

add time-based access restrictions

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 1 gadu atpakaļ
vecāks
revīzija
cc9a0d4dc2

+ 1 - 1
go.mod

@@ -53,7 +53,7 @@ require (
 	github.com/rs/cors v1.10.1
 	github.com/rs/xid v1.5.0
 	github.com/rs/zerolog v1.32.0
-	github.com/sftpgo/sdk v0.1.6-0.20240216180841-c13afec62842
+	github.com/sftpgo/sdk v0.1.6-0.20240317102632-f6eb95ea55c3
 	github.com/shirou/gopsutil/v3 v3.24.2
 	github.com/spf13/afero v1.11.0
 	github.com/spf13/cobra v1.8.0

+ 2 - 2
go.sum

@@ -355,8 +355,8 @@ github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJ
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
-github.com/sftpgo/sdk v0.1.6-0.20240216180841-c13afec62842 h1:Rqh/TYkMX6UmUWvgXrsOBoG7ee2GH1AJXBFlszIzKT0=
-github.com/sftpgo/sdk v0.1.6-0.20240216180841-c13afec62842/go.mod h1:AWoY2YYe/P1ymfTlRER/meERQjCcZZTbgVPGcPQgaqc=
+github.com/sftpgo/sdk v0.1.6-0.20240317102632-f6eb95ea55c3 h1:svxTNm3r2kRlpuVSUKi0WKQlsAq8VI0EzDWPNqeNn/o=
+github.com/sftpgo/sdk v0.1.6-0.20240317102632-f6eb95ea55c3/go.mod h1:AWoY2YYe/P1ymfTlRER/meERQjCcZZTbgVPGcPQgaqc=
 github.com/shirou/gopsutil/v3 v3.24.2 h1:kcR0erMbLg5/3LcInpw0X/rrPSqq4CDPyI6A6ZRC18Y=
 github.com/shirou/gopsutil/v3 v3.24.2/go.mod h1:tSg/594BcA+8UdQU2XcW803GWYgdtauFFPgJCJKZlVk=
 github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=

+ 8 - 1
internal/common/common.go

@@ -449,6 +449,7 @@ type ActiveConnection interface {
 	GetTransfers() []ConnectionTransfer
 	SignalTransferClose(transferID int64, err error)
 	CloseFS() error
+	isAccessAllowed() bool
 }
 
 // StatAttributes defines the attributes for set stat commands
@@ -1081,9 +1082,15 @@ func (conns *ActiveConnections) checkIdles() {
 		if idleTime > Config.idleTimeoutAsDuration || (isUnauthenticatedFTPUser && idleTime > Config.idleLoginTimeout) {
 			defer func(conn ActiveConnection) {
 				err := conn.Disconnect()
-				logger.Debug(conn.GetProtocol(), conn.GetID(), "close idle connection, idle time: %v, username: %q close err: %v",
+				logger.Debug(conn.GetProtocol(), conn.GetID(), "close idle connection, idle time: %s, username: %q close err: %v",
 					time.Since(conn.GetLastActivity()), conn.GetUsername(), err)
 			}(c)
+		} else if !c.isAccessAllowed() {
+			defer func(conn ActiveConnection) {
+				err := conn.Disconnect()
+				logger.Info(conn.GetProtocol(), conn.GetID(), "access conditions not met for user: %q close connection err: %v",
+					conn.GetUsername(), err)
+			}(c)
 		}
 	}
 

+ 23 - 3
internal/common/common_test.go

@@ -748,6 +748,7 @@ func TestIdleConnections(t *testing.T) {
 	user := dataprovider.User{
 		BaseUser: sdk.BaseUser{
 			Username: username,
+			Status:   1,
 		},
 	}
 	c := NewBaseConnection(sshConn1.id+"_1", ProtocolSFTP, "", "", user)
@@ -772,15 +773,34 @@ func TestIdleConnections(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(t, Connections.GetActiveSessions(username), 2)
 
-	cFTP := NewBaseConnection("id2", ProtocolFTP, "", "", dataprovider.User{})
+	cFTP := NewBaseConnection("id2", ProtocolFTP, "", "", dataprovider.User{
+		BaseUser: sdk.BaseUser{
+			Status: 1,
+		},
+	})
 	cFTP.lastActivity.Store(time.Now().UnixNano())
 	fakeConn = &fakeConnection{
 		BaseConnection: cFTP,
 	}
 	err = Connections.Add(fakeConn)
 	assert.NoError(t, err)
-	assert.Equal(t, Connections.GetActiveSessions(username), 2)
-	assert.Len(t, Connections.GetStats(""), 3)
+	// the user is expired, this connection will be removed
+	cDAV := NewBaseConnection("id3", ProtocolWebDAV, "", "", dataprovider.User{
+		BaseUser: sdk.BaseUser{
+			Username:       username + "_2",
+			Status:         1,
+			ExpirationDate: util.GetTimeAsMsSinceEpoch(time.Now().Add(-24 * time.Hour)),
+		},
+	})
+	cDAV.lastActivity.Store(time.Now().UnixNano())
+	fakeConn = &fakeConnection{
+		BaseConnection: cDAV,
+	}
+	err = Connections.Add(fakeConn)
+	assert.NoError(t, err)
+
+	assert.Equal(t, 2, Connections.GetActiveSessions(username))
+	assert.Len(t, Connections.GetStats(""), 4)
 	Connections.RLock()
 	assert.Len(t, Connections.sshConnections, 2)
 	Connections.RUnlock()

+ 8 - 0
internal/common/connection.go

@@ -109,6 +109,14 @@ func (c *BaseConnection) GetMaxSessions() int {
 	return c.User.MaxSessions
 }
 
+// isAccessAllowed returns true if the user's access conditions are met
+func (c *BaseConnection) isAccessAllowed() bool {
+	if err := c.User.CheckLoginConditions(); err != nil {
+		return false
+	}
+	return true
+}
+
 // GetProtocol returns the protocol for the connection
 func (c *BaseConnection) GetProtocol() string {
 	return c.protocol

+ 39 - 0
internal/common/protocol_test.go

@@ -531,6 +531,45 @@ func TestCheckFsAfterUpdate(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestLoginAccessTime(t *testing.T) {
+	u := getTestUser()
+	u.Filters.AccessTime = []sdk.TimePeriod{
+		{
+			DayOfWeek: int(time.Now().Add(-25 * time.Hour).UTC().Weekday()),
+			From:      "00:00",
+			To:        "23:59",
+		},
+	}
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	_, _, err = getSftpClient(user)
+	assert.Error(t, err)
+
+	user.Filters.AccessTime = []sdk.TimePeriod{
+		{
+			DayOfWeek: int(time.Now().UTC().Weekday()),
+			From:      "00:00",
+			To:        "23:59",
+		},
+	}
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+
+	conn, client, err := getSftpClient(user)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+
+		err := checkBasicSFTP(client)
+		assert.NoError(t, err)
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestSetStat(t *testing.T) {
 	u := getTestUser()
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)

+ 59 - 0
internal/dataprovider/dataprovider.go

@@ -2650,6 +2650,14 @@ func copyBaseUserFilters(in sdk.BaseUserFilters) sdk.BaseUserFilters {
 		copy(bwLimit.Sources, limit.Sources)
 		filters.BandwidthLimits = append(filters.BandwidthLimits, bwLimit)
 	}
+	filters.AccessTime = make([]sdk.TimePeriod, 0, len(in.AccessTime))
+	for _, period := range in.AccessTime {
+		filters.AccessTime = append(filters.AccessTime, sdk.TimePeriod{
+			DayOfWeek: period.DayOfWeek,
+			From:      period.From,
+			To:        period.To,
+		})
+	}
 	return filters
 }
 
@@ -3129,9 +3137,60 @@ func validateBaseFilters(filters *sdk.BaseUserFilters) error {
 	}
 	updateFiltersValues(filters)
 
+	if err := validateAccessTimeFilters(filters); err != nil {
+		return err
+	}
+
 	return validateFiltersPatternExtensions(filters)
 }
 
+func isTimeOfDayValid(value string) bool {
+	if len(value) != 5 {
+		return false
+	}
+	parts := strings.Split(value, ":")
+	if len(parts) != 2 {
+		return false
+	}
+	hour, err := strconv.Atoi(parts[0])
+	if err != nil {
+		return false
+	}
+	if hour < 0 || hour > 23 {
+		return false
+	}
+	minute, err := strconv.Atoi(parts[1])
+	if err != nil {
+		return false
+	}
+	if minute < 0 || minute > 59 {
+		return false
+	}
+	return true
+}
+
+func validateAccessTimeFilters(filters *sdk.BaseUserFilters) error {
+	for _, period := range filters.AccessTime {
+		if period.DayOfWeek < int(time.Sunday) || period.DayOfWeek > int(time.Saturday) {
+			return util.NewValidationError(fmt.Sprintf("invalid day of week: %d", period.DayOfWeek))
+		}
+		if !isTimeOfDayValid(period.From) || !isTimeOfDayValid(period.To) {
+			return util.NewI18nError(
+				util.NewValidationError("invalid time of day. Supported format: HH:MM"),
+				util.I18nErrorTimeOfDayInvalid,
+			)
+		}
+		if period.To <= period.From {
+			return util.NewI18nError(
+				util.NewValidationError("invalid time of day. The end time cannot be earlier than the start time"),
+				util.I18nErrorTimeOfDayConflict,
+			)
+		}
+	}
+
+	return nil
+}
+
 func validateCombinedUserFilters(user *User) error {
 	if user.Filters.TOTPConfig.Enabled && util.Contains(user.Filters.WebClient, sdk.WebClientMFADisabled) {
 		return util.NewI18nError(

+ 26 - 2
internal/dataprovider/user.go

@@ -335,7 +335,27 @@ func (u *User) isFsEqual(other *User) bool {
 	return true
 }
 
-// CheckLoginConditions checks if the user is active and not expired
+func (u *User) isTimeBasedAccessAllowed(when time.Time) bool {
+	if len(u.Filters.AccessTime) == 0 {
+		return true
+	}
+	if when.IsZero() {
+		when = time.Now()
+	}
+	when = when.UTC()
+	weekDay := when.Weekday()
+	hhMM := when.Format("15:04")
+	for _, p := range u.Filters.AccessTime {
+		if p.DayOfWeek == int(weekDay) {
+			if hhMM >= p.From && hhMM <= p.To {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+// CheckLoginConditions checks user access restrictions
 func (u *User) CheckLoginConditions() error {
 	if u.Status < 1 {
 		return fmt.Errorf("user %q is disabled", u.Username)
@@ -344,7 +364,10 @@ func (u *User) CheckLoginConditions() error {
 		return fmt.Errorf("user %q is expired, expiration timestamp: %v current timestamp: %v", u.Username,
 			u.ExpirationDate, util.GetTimeAsMsSinceEpoch(time.Now()))
 	}
-	return nil
+	if u.isTimeBasedAccessAllowed(time.Now()) {
+		return nil
+	}
+	return errors.New("access is not allowed at this time")
 }
 
 // hideConfidentialData hides user confidential data
@@ -1834,6 +1857,7 @@ func (u *User) mergeAdditiveProperties(group *Group, groupType int, replacer *st
 	u.Filters.DeniedProtocols = append(u.Filters.DeniedProtocols, group.UserSettings.Filters.DeniedProtocols...)
 	u.Filters.WebClient = append(u.Filters.WebClient, group.UserSettings.Filters.WebClient...)
 	u.Filters.TwoFactorAuthProtocols = append(u.Filters.TwoFactorAuthProtocols, group.UserSettings.Filters.TwoFactorAuthProtocols...)
+	u.Filters.AccessTime = append(u.Filters.AccessTime, group.UserSettings.Filters.AccessTime...)
 }
 
 func (u *User) mergeVirtualFolders(group *Group, groupType int, replacer *strings.Replacer) {

+ 55 - 0
internal/httpd/httpd_test.go

@@ -1269,6 +1269,13 @@ func TestGroupSettingsOverride(t *testing.T) {
 		},
 		VirtualPath: "/vdir4",
 	})
+	g2.UserSettings.Filters.AccessTime = []sdk.TimePeriod{
+		{
+			DayOfWeek: int(time.Now().UTC().Weekday()),
+			From:      "10:00",
+			To:        "18:00",
+		},
+	}
 	f1 := vfs.BaseVirtualFolder{
 		Name:       folderName1,
 		MappedPath: mappedPath1,
@@ -1363,10 +1370,12 @@ func TestGroupSettingsOverride(t *testing.T) {
 	assert.Equal(t, g2.UserSettings.Permissions["/dir3"], user.Permissions["/dir3"])
 	assert.Equal(t, g1.UserSettings.FsConfig.OSConfig.ReadBufferSize, user.FsConfig.OSConfig.ReadBufferSize)
 	assert.Equal(t, g1.UserSettings.FsConfig.OSConfig.WriteBufferSize, user.FsConfig.OSConfig.WriteBufferSize)
+	assert.Len(t, user.Filters.AccessTime, 1)
 
 	user, err = dataprovider.GetUserAfterIDPAuth(defaultUsername, "", common.ProtocolOIDC, nil)
 	assert.NoError(t, err)
 	assert.Len(t, user.VirtualFolders, 4)
+	assert.Len(t, user.Filters.AccessTime, 1)
 
 	user1, user2, err := dataprovider.GetUserVariants(defaultUsername, "")
 	assert.NoError(t, err)
@@ -1374,6 +1383,8 @@ func TestGroupSettingsOverride(t *testing.T) {
 	assert.Len(t, user2.VirtualFolders, 4)
 	assert.Equal(t, int64(0), user1.ExpirationDate)
 	assert.Equal(t, int64(0), user2.ExpirationDate)
+	assert.Len(t, user1.Filters.AccessTime, 0)
+	assert.Len(t, user2.Filters.AccessTime, 1)
 
 	group2.UserSettings.FsConfig = vfs.Filesystem{
 		Provider: sdk.SFTPFilesystemProvider,
@@ -2846,6 +2857,40 @@ func TestUserBandwidthLimits(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestAccessTimeValidation(t *testing.T) {
+	u := getTestUser()
+	u.Filters.AccessTime = []sdk.TimePeriod{
+		{
+			DayOfWeek: 8,
+			From:      "10:00",
+			To:        "18:00",
+		},
+	}
+	_, resp, err := httpdtest.AddUser(u, http.StatusBadRequest)
+	assert.NoError(t, err, string(resp))
+	assert.Contains(t, string(resp), "invalid day of week")
+	u.Filters.AccessTime = []sdk.TimePeriod{
+		{
+			DayOfWeek: 6,
+			From:      "10:00",
+			To:        "18",
+		},
+	}
+	_, resp, err = httpdtest.AddUser(u, http.StatusBadRequest)
+	assert.NoError(t, err, string(resp))
+	assert.Contains(t, string(resp), "invalid time of day")
+	u.Filters.AccessTime = []sdk.TimePeriod{
+		{
+			DayOfWeek: 6,
+			From:      "11:00",
+			To:        "10:58",
+		},
+	}
+	_, resp, err = httpdtest.AddUser(u, http.StatusBadRequest)
+	assert.NoError(t, err, string(resp))
+	assert.Contains(t, string(resp), "The end time cannot be earlier than the start time")
+}
+
 func TestUserTimestamps(t *testing.T) {
 	user, resp, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err, string(resp))
@@ -20648,6 +20693,11 @@ func TestWebUserAddMock(t *testing.T) {
 	form.Set("directory_patterns[4][pattern_path]", "/dir2")
 	form.Set("directory_patterns[4][patterns]", "*.mkv")
 	form.Set("directory_patterns[4][pattern_type]", "denied")
+	form.Set("access_time_restrictions[0][access_time_day_of_week]", "2")
+	form.Set("access_time_restrictions[0][access_time_start]", "10") // invalid and no end, ignored
+	form.Set("access_time_restrictions[1][access_time_day_of_week]", "3")
+	form.Set("access_time_restrictions[1][access_time_start]", "12:00")
+	form.Set("access_time_restrictions[1][access_time_end]", "14:09")
 	form.Set("additional_info", user.AdditionalInfo)
 	form.Set("description", user.Description)
 	form.Add("hooks", "external_auth_disabled")
@@ -20997,6 +21047,11 @@ func TestWebUserAddMock(t *testing.T) {
 			}
 		}
 	}
+	if assert.Len(t, newUser.Filters.AccessTime, 1) {
+		assert.Equal(t, 3, newUser.Filters.AccessTime[0].DayOfWeek)
+		assert.Equal(t, "12:00", newUser.Filters.AccessTime[0].From)
+		assert.Equal(t, "14:09", newUser.Filters.AccessTime[0].To)
+	}
 	assert.Len(t, newUser.Groups, 3)
 	assert.Equal(t, sdk.TLSUsernameNone, newUser.Filters.TLSUsername)
 	req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, newUser.Username), nil)

+ 38 - 0
internal/httpd/webadmin.go

@@ -1284,6 +1284,36 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
 	return permissions
 }
 
+func getAccessTimeRestrictionsFromPostFields(r *http.Request) []sdk.TimePeriod {
+	var result []sdk.TimePeriod
+
+	dayOfWeeks := r.Form["access_time_day_of_week"]
+	starts := r.Form["access_time_start"]
+	ends := r.Form["access_time_end"]
+
+	for idx, dayOfWeek := range dayOfWeeks {
+		dayOfWeek = strings.TrimSpace(dayOfWeek)
+		start := ""
+		if len(starts) > idx {
+			start = strings.TrimSpace(starts[idx])
+		}
+		end := ""
+		if len(ends) > idx {
+			end = strings.TrimSpace(ends[idx])
+		}
+		dayNumber, err := strconv.Atoi(dayOfWeek)
+		if err == nil && start != "" && end != "" {
+			result = append(result, sdk.TimePeriod{
+				DayOfWeek: dayNumber,
+				From:      start,
+				To:        end,
+			})
+		}
+	}
+
+	return result
+}
+
 func getBandwidthLimitsFromPostFields(r *http.Request) ([]sdk.BandwidthLimit, error) {
 	var result []sdk.BandwidthLimit
 	bwSources := r.Form["bandwidth_limit_sources"]
@@ -1463,6 +1493,7 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
 	filters.MaxSharesExpiration = maxSharesExpiration
 	filters.PasswordExpiration = passwordExpiration
 	filters.PasswordStrength = passwordStrength
+	filters.AccessTime = getAccessTimeRestrictionsFromPostFields(r)
 	hooks := r.Form["hooks"]
 	if util.Contains(hooks, "external_auth_disabled") {
 		filters.Hooks.ExternalAuthDisabled = true
@@ -1969,6 +2000,13 @@ func updateRepeaterFormFields(r *http.Request) {
 			r.Form.Add("pattern_policy", strings.TrimSpace(r.Form.Get(base+"[pattern_policy]")))
 			continue
 		}
+		if hasPrefixAndSuffix(k, "access_time_restrictions[", "][access_time_day_of_week]") {
+			base, _ := strings.CutSuffix(k, "[access_time_day_of_week]")
+			r.Form.Add("access_time_day_of_week", strings.TrimSpace(r.Form.Get(k)))
+			r.Form.Add("access_time_start", strings.TrimSpace(r.Form.Get(base+"[access_time_start]")))
+			r.Form.Add("access_time_end", strings.TrimSpace(r.Form.Get(base+"[access_time_end]")))
+			continue
+		}
 		if hasPrefixAndSuffix(k, "src_bandwidth_limits[", "][bandwidth_limit_sources]") {
 			base, _ := strings.CutSuffix(k, "[bandwidth_limit_sources]")
 			r.Form.Add("bandwidth_limit_sources", r.Form.Get(k))

+ 23 - 0
internal/httpdtest/httpdtest.go

@@ -2516,6 +2516,9 @@ func compareUserFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters
 	if err := compareUserBandwidthLimitFilters(expected, actual); err != nil {
 		return err
 	}
+	if err := compareAccessTimeFilters(expected, actual); err != nil {
+		return err
+	}
 	return compareUserFilePatternsFilters(expected, actual)
 }
 
@@ -2531,6 +2534,26 @@ func checkFilterMatch(expected []string, actual []string) bool {
 	return true
 }
 
+func compareAccessTimeFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error {
+	if len(expected.AccessTime) != len(actual.AccessTime) {
+		return errors.New("access time filters mismatch")
+	}
+
+	for idx, p := range expected.AccessTime {
+		if actual.AccessTime[idx].DayOfWeek != p.DayOfWeek {
+			return errors.New("access time day of week mismatch")
+		}
+		if actual.AccessTime[idx].From != p.From {
+			return errors.New("access time from mismatch")
+		}
+		if actual.AccessTime[idx].To != p.To {
+			return errors.New("access time to mismatch")
+		}
+	}
+
+	return nil
+}
+
 func compareUserBandwidthLimitFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error {
 	if len(expected.BandwidthLimits) != len(actual.BandwidthLimits) {
 		return errors.New("bandwidth limits filters mismatch")

+ 2 - 0
internal/util/i18n.go

@@ -191,6 +191,8 @@ const (
 	I18nStorageSFTP                    = "storage.sftp"
 	I18nStorageHTTP                    = "storage.http"
 	I18nErrorInvalidQuotaSize          = "user.invalid_quota_size"
+	I18nErrorTimeOfDayInvalid          = "user.time_of_day_invalid"
+	I18nErrorTimeOfDayConflict         = "user.time_of_day_conflict"
 	I18nErrorInvalidMaxFilesize        = "filters.max_upload_size_invalid"
 	I18nErrorInvalidHomeDir            = "storage.home_dir_invalid"
 	I18nErrorBucketRequired            = "storage.bucket_required"

+ 16 - 3
static/locales/en/translation.json

@@ -263,7 +263,16 @@
         "month": "Month",
         "options": "Options",
         "expired": "Expired",
-        "unsupported": "Feature no longer supported"
+        "unsupported": "Feature no longer supported",
+        "start": "Start (HH:MM)",
+        "end": "End (HH:MM)",
+        "monday": "Monday",
+        "tuesday": "Tuesday",
+        "wednesday": "Wednesday",
+        "thursday": "Thursday",
+        "friday": "Friday",
+        "saturday": "Saturday",
+        "sunday": "Sunday"
     },
     "fs": {
         "view_file": "View file \"{{- path}}\"",
@@ -537,7 +546,9 @@
         "template_password_placeholder": "replaced with the specified password",
         "template_help1": "Placeholders will be replaced in paths and credentials of the configured storage backend.",
         "template_help2": "The generated users can be saved or exported. Exported users can be imported from the \"Maintenance\" section of this SFTPGo instance or another.",
-        "template_no_user": "No valid user defined, unable to complete the requested action"
+        "template_no_user": "No valid user defined, unable to complete the requested action",
+        "time_of_day_invalid": "Invalid time of day. Supported format HH:MM",
+        "time_of_day_conflict": "Invalid time of day. The end time cannot be earlier than the start time"
     },
     "group": {
         "view_manage": "View and manage groups",
@@ -715,7 +726,9 @@
         "disable_fs_checks_help": "Disable checks for existence and automatic creation of home directory and virtual folders",
         "api_key_auth_help": "Allow to impersonate the user, in REST API, with an API key",
         "external_auth_cache_time": "External auth cache time",
-        "external_auth_cache_time_help": "Cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache"
+        "external_auth_cache_time_help": "Cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache",
+        "access_time": "Access time restrictions",
+        "access_time_help": "No restrictions means access is always allowed, the time must be set in the format HH:MM. Use UTC time"
     },
     "admin": {
         "role_permissions": "A role admin cannot have the following permissions: {{val}}",

+ 16 - 3
static/locales/it/translation.json

@@ -263,7 +263,16 @@
         "month": "Mese",
         "options": "Opzioni",
         "expired": "Scaduto",
-        "unsupported": "Funzionalità non più supportata"
+        "unsupported": "Funzionalità non più supportata",
+        "start": "Inizio (HH:MM)",
+        "end": "Fine (HH:MM)",
+        "monday": "Lunedì",
+        "tuesday": "Martedì",
+        "wednesday": "Mercoledì",
+        "thursday": "Giovedì",
+        "friday": "Venerdì",
+        "saturday": "Sabato",
+        "sunday": "Domenica"
     },
     "fs": {
         "view_file": "Visualizza file \"{{- path}}\"",
@@ -537,7 +546,9 @@
         "template_password_placeholder": "sostituito con la password specificata",
         "template_help1": "I segnaposto verranno sostituiti nei percorsi e nelle credenziali del backend di archiviazione configurato.",
         "template_help2": "Gli utenti generati possono essere salvati o esportati. Gli utenti esportati possono essere importati dalla sezione \"Manutenzione\" di questa istanza SFTPGo o di un'altra.",
-        "template_no_user": "Nessun utente valido definito. Impossibile completare l'azione richiesta"
+        "template_no_user": "Nessun utente valido definito. Impossibile completare l'azione richiesta",
+        "time_of_day_invalid": "Ora del giorno non valida. Formato supportato HH:MM",
+        "time_of_day_conflict": "Ora del giorno non valida. L'ora di fine non può essere precedente all'ora di inizio"
     },
     "group": {
         "view_manage": "Visualizza e gestisci gruppi",
@@ -715,7 +726,9 @@
         "disable_fs_checks_help": "Disabilita i controlli sull'esistenza e la creazione automatica della directory home e delle cartelle virtuali",
         "api_key_auth_help": "Permetti di impersonare l'utente nelle API REST utilizzando una chiave API",
         "external_auth_cache_time": "Cache per autenticazione esterna",
-        "external_auth_cache_time_help": "Tempo di memorizzazione nella cache, in secondi, per gli utenti autenticati utilizzando un hook di autenticazione esterno. 0 significa nessuna cache"
+        "external_auth_cache_time_help": "Tempo di memorizzazione nella cache, in secondi, per gli utenti autenticati utilizzando un hook di autenticazione esterno. 0 significa nessuna cache",
+        "access_time": "Limitazioni temporali all'accesso",
+        "access_time_help": "Nessuna restrizione significa che l'accesso è sempre consentito, l'ora deve essere impostata nel formato HH:MM. Utilizzare l'ora UTC"
     },
     "admin": {
         "role_permissions": "Un amministratore di ruolo non può avere le seguenti autorizzazioni: {{val}}",

+ 95 - 0
templates/webadmin/fsconfig.html

@@ -605,6 +605,101 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
 </div>
 {{- end}}
 
+{{- define "user_group_access_time"}}
+<div class="card mt-10">
+    <div class="card-header bg-light">
+        <h3 data-i18n="filters.access_time" class="card-title section-title-inner">Access time restrictions</h3>
+    </div>
+    <div class="card-body">
+        <div id="access_time_restrictions">
+            {{- template "infomsg-no-mb" "filters.access_time_help"}}
+            <div class="form-group">
+                <div data-repeater-list="access_time_restrictions">
+                    {{- range $idx, $period := .AccessTime -}}
+                    <div data-repeater-item>
+                        <div data-repeater-item>
+                            <div class="form-group row">
+                                <div class="col-md-5 mt-3 mt-md-8">
+                                    <select name="access_time_day_of_week" class="form-select select-repetear select-first" data-hide-search="true">
+                                        <option value="0" data-i18n="general.sunday" {{- if eq $period.DayOfWeek 0}} selected{{- end}}>Sunday</option>
+                                        <option value="1" data-i18n="general.monday" {{- if eq $period.DayOfWeek 1}} selected{{- end}}>Monday</option>
+                                        <option value="2" data-i18n="general.tuesday" {{- if eq $period.DayOfWeek 2}} selected{{- end}}>Tuesday</option>
+                                        <option value="3" data-i18n="general.wednesday" {{- if eq $period.DayOfWeek 3}} selected{{- end}}>Wednesday</option>
+                                        <option value="4" data-i18n="general.thursday" {{- if eq $period.DayOfWeek 4}} selected{{- end}}>Thursday</option>
+                                        <option value="5" data-i18n="general.friday" {{- if eq $period.DayOfWeek 5}} selected{{- end}}>Friday</option>
+                                        <option value="6" data-i18n="general.saturday" {{- if eq $period.DayOfWeek 6}} selected{{- end}}>Saturday</option>
+                                    </select>
+                                </div>
+                                <div class="col-md-3 mt-3 mt-md-8">
+                                    <input data-i18n="[placeholder]general.start" type="text" class="form-control" name="access_time_start" value="{{$period.From}}" />
+                                </div>
+                                <div class="col-md-3 mt-3 mt-md-8">
+                                    <input data-i18n="[placeholder]general.end" type="text" class="form-control" name="access_time_end" value="{{$period.To}}" />
+                                </div>
+                                <div class="col-md-1 mt-3 mt-md-8">
+                                    <a href="#" data-repeater-delete
+                                        class="btn btn-light-danger ps-5 pe-4">
+                                        <i class="ki-duotone ki-trash fs-2">
+                                            <span class="path1"></span>
+                                            <span class="path2"></span>
+                                            <span class="path3"></span>
+                                            <span class="path4"></span>
+                                            <span class="path5"></span>
+                                        </i>
+                                    </a>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                    {{- else}}
+                    <div data-repeater-item>
+                        <div class="form-group row">
+                            <div class="col-md-5 mt-3 mt-md-8">
+                                <select name="access_time_day_of_week" class="form-select select-repetear select-first" data-hide-search="true">
+                                    <option value="0" data-i18n="general.sunday">Sunday</option>
+                                    <option value="1" data-i18n="general.monday">Monday</option>
+                                    <option value="2" data-i18n="general.tuesday">Tuesday</option>
+                                    <option value="3" data-i18n="general.wednesday">Wednesday</option>
+                                    <option value="4" data-i18n="general.thursday">Thursday</option>
+                                    <option value="5" data-i18n="general.friday">Friday</option>
+                                    <option value="6" data-i18n="general.saturday">Saturday</option>
+                                </select>
+                            </div>
+                            <div class="col-md-3 mt-3 mt-md-8">
+                                <input data-i18n="[placeholder]general.start" type="text" class="form-control" name="access_time_start" value="" />
+                            </div>
+                            <div class="col-md-3 mt-3 mt-md-8">
+                                <input data-i18n="[placeholder]general.end" type="text" class="form-control" name="access_time_end" value="" />
+                            </div>
+                            <div class="col-md-1 mt-3 mt-md-8">
+                                <a href="#" data-repeater-delete
+                                    class="btn btn-light-danger ps-5 pe-4">
+                                    <i class="ki-duotone ki-trash fs-2">
+                                        <span class="path1"></span>
+                                        <span class="path2"></span>
+                                        <span class="path3"></span>
+                                        <span class="path4"></span>
+                                        <span class="path5"></span>
+                                    </i>
+                                </a>
+                            </div>
+                        </div>
+                    </div>
+                    {{- end}}
+                </div>
+            </div>
+
+            <div class="form-group mt-5">
+                <a href="#" data-repeater-create class="btn btn-light-primary">
+                    <i class="ki-duotone ki-plus fs-3"></i>
+                    <span data-i18n="general.add">Add</span>
+                </a>
+            </div>
+        </div>
+    </div>
+</div>
+{{- end}}
+
 {{- define "user_group_quota"}}
 <div class="form-group row mt-10">
     <label for="idQuotaSize" data-i18n="virtual_folders.quota_size" class="col-md-3 col-form-label">Quota size</label>

+ 3 - 0
templates/webadmin/group.html

@@ -230,6 +230,8 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
 
                             {{- template "user_group_perms" .Group.UserSettings.Filters}}
 
+                            {{- template "user_group_access_time" .Group.UserSettings.Filters}}
+
                             <div class="form-group row mt-10">
                                 <label for="idMaxSessions" data-i18n="filters.max_sessions" class="col-md-3 col-form-label">Max sessions</label>
                                 <div class="col-md-9">
@@ -392,6 +394,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
         initRepeater('#directory_permissions');
         initRepeater('#directory_patterns');
         initRepeater('#src_bandwidth_limits');
+        initRepeater('#access_time_restrictions');
         initRepeaterItems();
         //{{- if .Error}}
         $('#accordionUser .collapse').removeAttr("data-bs-parent").collapse('show');

+ 3 - 0
templates/webadmin/user.html

@@ -516,6 +516,8 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
 
                             {{- template "user_group_perms" .User.Filters}}
 
+                            {{- template "user_group_access_time" .User.Filters}}
+
                             <div class="form-group row mt-10">
                                 <label for="idMaxSessions" data-i18n="filters.max_sessions" class="col-md-3 col-form-label">Max sessions</label>
                                 <div class="col-md-9">
@@ -788,6 +790,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
             initRepeater('#directory_patterns');
             initRepeater('#src_bandwidth_limits');
             initRepeater('#tls_certs');
+            initRepeater('#access_time_restrictions');
             initRepeaterItems();
             //{{- if .Error}}
             //{{- if ne .LoggedUser.Filters.Preferences.VisibleUserPageSections 0}}