diff --git a/go.mod b/go.mod index f5d7c7bf..09345cf6 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ require ( github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5 github.com/rs/xid v1.4.0 github.com/rs/zerolog v1.28.0 - github.com/sftpgo/sdk v0.1.2 + github.com/sftpgo/sdk v0.1.3-0.20221105153737-bae9afc6b356 github.com/shirou/gopsutil/v3 v3.22.10 github.com/spf13/afero v1.9.2 github.com/spf13/cobra v1.6.1 diff --git a/go.sum b/go.sum index 228087c4..dcff8833 100644 --- a/go.sum +++ b/go.sum @@ -1457,8 +1457,8 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo= -github.com/sftpgo/sdk v0.1.2 h1:j4V63RuVcYfJAOWV0zRUofa1PlQvKU2ujly0lB7quVA= -github.com/sftpgo/sdk v0.1.2/go.mod h1:PTp1TfXa+95wHw9yuZu7BA3vmzLqbRkz3gBmMNnwFQg= +github.com/sftpgo/sdk v0.1.3-0.20221105153737-bae9afc6b356 h1:VwFpy5W/pP0X+082xKU2yu4OAwuk8Qqa8j2ofImJ1bM= +github.com/sftpgo/sdk v0.1.3-0.20221105153737-bae9afc6b356/go.mod h1:Giy5vj7Gmju0nGlmBNd28DwPo0G0o1nr9XkE+vu3i+o= github.com/shirou/gopsutil/v3 v3.22.10 h1:4KMHdfBRYXGF9skjDWiL4RA2N+E8dRdodU/bOZpPoVg= github.com/shirou/gopsutil/v3 v3.22.10/go.mod h1:QNza6r4YQoydyCfo6rH0blGfKahgibh4dQmV5xdFkQk= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= diff --git a/internal/dataprovider/admin.go b/internal/dataprovider/admin.go index 5ce58fcb..3ea459c3 100644 --- a/internal/dataprovider/admin.go +++ b/internal/dataprovider/admin.go @@ -123,6 +123,9 @@ type AdminPreferences struct { // // The settings can be combined HideUserPageSections int `json:"hide_user_page_sections,omitempty"` + // Defines the default expiration for newly created users as number of days. + // 0 means no expiration + DefaultUsersExpiration int `json:"default_users_expiration,omitempty"` } // HideGroups returns true if the groups section should be hidden @@ -365,7 +368,7 @@ func (a *Admin) validate() error { return err } if config.NamingRules&1 == 0 && !usernameRegex.MatchString(a.Username) { - return util.NewValidationError(fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username)) + return util.NewValidationError(fmt.Sprintf("username %q is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username)) } if err := a.hashPassword(); err != nil { return err @@ -574,7 +577,8 @@ func (a *Admin) getACopy() Admin { }) } filters.Preferences = AdminPreferences{ - HideUserPageSections: a.Filters.Preferences.HideUserPageSections, + HideUserPageSections: a.Filters.Preferences.HideUserPageSections, + DefaultUsersExpiration: a.Filters.Preferences.DefaultUsersExpiration, } groups := make([]AdminGroupMapping, 0, len(a.Groups)) for _, g := range a.Groups { diff --git a/internal/httpd/api_user.go b/internal/httpd/api_user.go index 74fa9837..a6abcd24 100644 --- a/internal/httpd/api_user.go +++ b/internal/httpd/api_user.go @@ -19,6 +19,7 @@ import ( "fmt" "net/http" "strconv" + "time" "github.com/go-chi/render" "github.com/sftpgo/sdk" @@ -75,7 +76,15 @@ func addUser(w http.ResponseWriter, r *http.Request) { sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) return } + admin, err := dataprovider.AdminExists(claims.Username) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } var user dataprovider.User + if admin.Filters.Preferences.DefaultUsersExpiration > 0 { + user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration))) + } err = render.DecodeJSON(r.Body, &user) if err != nil { sendAPIResponse(w, r, err, "", http.StatusBadRequest) diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 10201f55..d8289be9 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -2722,6 +2722,7 @@ func TestBasicAdminHandling(t *testing.T) { admin.Username = altAdminUsername admin.Filters.Preferences.HideUserPageSections = 1 + 4 + 8 + admin.Filters.Preferences.DefaultUsersExpiration = 30 admin, _, err = httpdtest.AddAdmin(admin, http.StatusCreated) assert.NoError(t, err) @@ -2972,6 +2973,74 @@ func TestAdminPasswordHashing(t *testing.T) { assert.NoError(t, err) } +func TestDefaultUsersExpiration(t *testing.T) { + a := getTestAdmin() + a.Username = altAdminUsername + a.Password = altAdminPassword + a.Filters.Preferences.DefaultUsersExpiration = 30 + admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated) + assert.NoError(t, err) + + token, _, err := httpdtest.GetToken(altAdminUsername, altAdminPassword) + assert.NoError(t, err) + httpdtest.SetJWTToken(token) + + _, _, err = httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.Error(t, err) + + user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK) + assert.NoError(t, err) + assert.Greater(t, user.ExpirationDate, int64(0)) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + + u := getTestUser() + u.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(1 * time.Minute)) + + _, _, err = httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + + user, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, u.ExpirationDate, user.ExpirationDate) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + + httpdtest.SetJWTToken("") + _, _, err = httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + // render the user template page + webToken, err := getJWTWebTokenFromTestServer(altAdminUsername, altAdminPassword) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodGet, webTemplateUser, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, webTemplateUser+fmt.Sprintf("?from=%s", user.Username), nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + + _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) + assert.NoError(t, err) + + httpdtest.SetJWTToken(token) + _, _, err = httpdtest.AddUser(u, http.StatusNotFound) + assert.NoError(t, err) + + httpdtest.SetJWTToken("") +} + func TestAdminInvalidCredentials(t *testing.T) { req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, tokenPath), nil) assert.NoError(t, err) @@ -6139,6 +6208,11 @@ func TestProviderErrors(t *testing.T) { setJWTCookieForReq(req, testServerToken) rr = executeRequest(req) checkResponseCode(t, http.StatusInternalServerError, rr) + req, err = http.NewRequest(http.MethodGet, webTemplateUser, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, testServerToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) req, err = http.NewRequest(http.MethodGet, webGroupsPath+"?qlimit=a", nil) assert.NoError(t, err) setJWTCookieForReq(req, testServerToken) @@ -16420,6 +16494,7 @@ func TestWebAdminBasicMock(t *testing.T) { form.Add("user_page_hidden_sections", "5") form.Add("user_page_hidden_sections", "6") form.Add("user_page_hidden_sections", "7") + form.Set("default_users_expiration", "10") req, _ := http.NewRequest(http.MethodPost, webAdminPath, bytes.NewBuffer([]byte(form.Encode()))) req.RemoteAddr = defaultRemoteAddr req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -16438,6 +16513,16 @@ func TestWebAdminBasicMock(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) form.Set("status", "1") + form.Set("default_users_expiration", "a") + req, _ = http.NewRequest(http.MethodPost, webAdminPath, bytes.NewBuffer([]byte(form.Encode()))) + req.RemoteAddr = defaultRemoteAddr + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid default users expiration") + + form.Set("default_users_expiration", "10") req, _ = http.NewRequest(http.MethodPost, webAdminPath, bytes.NewBuffer([]byte(form.Encode()))) req.RemoteAddr = defaultRemoteAddr req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -16488,6 +16573,7 @@ func TestWebAdminBasicMock(t *testing.T) { secretPayload := admin.Filters.TOTPConfig.Secret.GetPayload() assert.NotEmpty(t, secretPayload) assert.Equal(t, 1+2+4+8+16+32+64, admin.Filters.Preferences.HideUserPageSections) + assert.Equal(t, 10, admin.Filters.Preferences.DefaultUsersExpiration) adminTOTPConfig = dataprovider.AdminTOTPConfig{ Enabled: true, @@ -16610,6 +16696,12 @@ func TestWebAdminBasicMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusNotFound, rr) + req, _ = http.NewRequest(http.MethodGet, webUserPath, nil) + req.RemoteAddr = defaultRemoteAddr + setJWTCookieForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + req, _ = http.NewRequest(http.MethodDelete, path.Join(webAdminPath, altAdminUsername), nil) req.RemoteAddr = defaultRemoteAddr setJWTCookieForReq(req, token) @@ -16617,6 +16709,13 @@ func TestWebAdminBasicMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) + req, _ = http.NewRequest(http.MethodGet, webUserPath, nil) + req.RemoteAddr = defaultRemoteAddr + setJWTCookieForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + assert.Contains(t, rr.Body.String(), "unable to get the admin") + _, err = httpdtest.RemoveAdmin(admin, http.StatusNotFound) assert.NoError(t, err) diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go index 7f2acd85..315cbbbf 100644 --- a/internal/httpd/internal_test.go +++ b/internal/httpd/internal_test.go @@ -758,6 +758,7 @@ func TestUpdateWebAdminInvalidClaims(t *testing.T) { form := make(url.Values) form.Set(csrfFormToken, createCSRFToken("")) form.Set("status", "1") + form.Set("default_users_expiration", "30") req, _ := http.NewRequest(http.MethodPost, path.Join(webAdminPath, "admin"), bytes.NewBuffer([]byte(form.Encode()))) rctx := chi.NewRouteContext() rctx.URLParams.Add("username", "admin") diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index 66207f30..b0f47976 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -792,7 +792,7 @@ func (s *httpdServer) renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Re } func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.User, - mode userPageMode, error string, + mode userPageMode, error string, admin *dataprovider.Admin, ) { folders, err := s.getWebVirtualFolders(w, r, defaultQueryLimit, true) if err != nil { @@ -825,12 +825,7 @@ func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, use } user.FsConfig.RedactedSecret = redactedSecret basePage := s.getBasePageData(title, currentURL, r) - if (mode == userPageModeAdd || mode == userPageModeTemplate) && len(user.Groups) == 0 { - admin, err := dataprovider.AdminExists(basePage.LoggedAdmin.Username) - if err != nil { - s.renderInternalServerErrorPage(w, r, err) - return - } + if (mode == userPageModeAdd || mode == userPageModeTemplate) && len(user.Groups) == 0 && admin != nil { for _, group := range admin.Groups { user.Groups = append(user.Groups, sdk.GroupMapping{ Name: group.Name, @@ -1587,6 +1582,14 @@ func getAdminFromPostFields(r *http.Request) (dataprovider.Admin, error) { admin.AdditionalInfo = r.Form.Get("additional_info") admin.Description = r.Form.Get("description") admin.Filters.Preferences.HideUserPageSections = getAdminHiddenUserPageSections(r) + admin.Filters.Preferences.DefaultUsersExpiration = 0 + if val := r.Form.Get("default_users_expiration"); val != "" { + defaultUsersExpiration, err := strconv.ParseInt(r.Form.Get("default_users_expiration"), 10, 64) + if err != nil { + return admin, fmt.Errorf("invalid default users expiration: %w", err) + } + admin.Filters.Preferences.DefaultUsersExpiration = int(defaultUsersExpiration) + } for k := range r.Form { if strings.HasPrefix(k, "group") { groupName := strings.TrimSpace(r.Form.Get(k)) @@ -2646,6 +2649,12 @@ func (s *httpdServer) handleWebTemplateFolderPost(w http.ResponseWriter, r *http func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + tokenAdmin := getAdminFromToken(r) + admin, err := dataprovider.AdminExists(tokenAdmin.Username) + if err != nil { + s.renderInternalServerErrorPage(w, r, fmt.Errorf("unable to get the admin %q: %w", tokenAdmin.Username, err)) + return + } if r.URL.Query().Get("from") != "" { username := r.URL.Query().Get("from") user, err := dataprovider.UserExists(username) @@ -2654,7 +2663,10 @@ func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Re user.PublicKeys = nil user.Email = "" user.Description = "" - s.renderUserPage(w, r, &user, userPageModeTemplate, "") + if user.ExpirationDate == 0 && admin.Filters.Preferences.DefaultUsersExpiration > 0 { + user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration))) + } + s.renderUserPage(w, r, &user, userPageModeTemplate, "", &admin) } else if _, ok := err.(*util.RecordNotFoundError); ok { s.renderNotFoundPage(w, r, err) } else { @@ -2667,7 +2679,10 @@ func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Re "/": {dataprovider.PermAny}, }, }} - s.renderUserPage(w, r, &user, userPageModeTemplate, "") + if admin.Filters.Preferences.DefaultUsersExpiration > 0 { + user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration))) + } + s.renderUserPage(w, r, &user, userPageModeTemplate, "", &admin) } } @@ -2729,13 +2744,22 @@ func (s *httpdServer) handleWebTemplateUserPost(w http.ResponseWriter, r *http.R func (s *httpdServer) handleWebAddUserGet(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + tokenAdmin := getAdminFromToken(r) + admin, err := dataprovider.AdminExists(tokenAdmin.Username) + if err != nil { + s.renderInternalServerErrorPage(w, r, fmt.Errorf("unable to get the admin %q: %w", tokenAdmin.Username, err)) + return + } user := dataprovider.User{BaseUser: sdk.BaseUser{ Status: 1, Permissions: map[string][]string{ "/": {dataprovider.PermAny}, }}, } - s.renderUserPage(w, r, &user, userPageModeAdd, "") + if admin.Filters.Preferences.DefaultUsersExpiration > 0 { + user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration))) + } + s.renderUserPage(w, r, &user, userPageModeAdd, "", &admin) } func (s *httpdServer) handleWebUpdateUserGet(w http.ResponseWriter, r *http.Request) { @@ -2743,7 +2767,7 @@ func (s *httpdServer) handleWebUpdateUserGet(w http.ResponseWriter, r *http.Requ username := getURLParam(r, "username") user, err := dataprovider.UserExists(username) if err == nil { - s.renderUserPage(w, r, &user, userPageModeUpdate, "") + s.renderUserPage(w, r, &user, userPageModeUpdate, "", nil) } else if _, ok := err.(*util.RecordNotFoundError); ok { s.renderNotFoundPage(w, r, err) } else { @@ -2760,7 +2784,7 @@ func (s *httpdServer) handleWebAddUserPost(w http.ResponseWriter, r *http.Reques } user, err := getUserFromPostFields(r) if err != nil { - s.renderUserPage(w, r, &user, userPageModeAdd, err.Error()) + s.renderUserPage(w, r, &user, userPageModeAdd, err.Error(), nil) return } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) @@ -2775,7 +2799,7 @@ func (s *httpdServer) handleWebAddUserPost(w http.ResponseWriter, r *http.Reques }) err = dataprovider.AddUser(&user, claims.Username, ipAddr) if err != nil { - s.renderUserPage(w, r, &user, userPageModeAdd, err.Error()) + s.renderUserPage(w, r, &user, userPageModeAdd, err.Error(), nil) return } http.Redirect(w, r, webUsersPath, http.StatusSeeOther) @@ -2799,7 +2823,7 @@ func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Req } updatedUser, err := getUserFromPostFields(r) if err != nil { - s.renderUserPage(w, r, &user, userPageModeUpdate, err.Error()) + s.renderUserPage(w, r, &user, userPageModeUpdate, err.Error(), nil) return } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) @@ -2828,7 +2852,7 @@ func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Req err = dataprovider.UpdateUser(&updatedUser, claims.Username, ipAddr) if err != nil { - s.renderUserPage(w, r, &updatedUser, userPageModeUpdate, err.Error()) + s.renderUserPage(w, r, &updatedUser, userPageModeUpdate, err.Error(), nil) return } if r.Form.Get("disconnect") != "" { diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go index b6564db6..90513fef 100644 --- a/internal/httpdtest/httpdtest.go +++ b/internal/httpdtest/httpdtest.go @@ -1645,6 +1645,9 @@ func compareAdminFilters(expected, actual dataprovider.AdminFilters) error { if expected.Preferences.HideUserPageSections != actual.Preferences.HideUserPageSections { return errors.New("hide user page sections mismatch") } + if expected.Preferences.DefaultUsersExpiration != actual.Preferences.DefaultUsersExpiration { + return errors.New("default users expiration mismatch") + } return nil } diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index e2443445..81e7605c 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2545,22 +2545,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Admin' - examples: - example-1: - value: - id: 1 - status: 0 - username: string - description: string - password: pa$$word - email: user@example.com - permissions: - - '*' - filters: - allow_list: - - 192.0.2.0/24 - - '2001:db8::/32' - additional_info: string responses: '201': description: successful operation @@ -5233,6 +5217,9 @@ components: hide_user_page_sections: type: integer description: 'Allow to hide some sections from the user page. These are not security settings and are not enforced server side in any way. They are only intended to simplify the user page in the WebAdmin UI. 1 means hide groups section, 2 means hide filesystem section, "users_base_dir" must be set in the config file otherwise this setting is ignored, 4 means hide virtual folders section, 8 means hide profile section, 16 means hide ACLs section, 32 means hide disk and bandwidth quota limits section, 64 means hide advanced settings section. The settings can be combined' + default_users_expiration: + type: integer + description: 'Defines the default expiration for newly created users as number of days. 0 means no expiration' AdminFilters: type: object properties: diff --git a/templates/webadmin/admin.html b/templates/webadmin/admin.html index fb1626c1..a532584a 100644 --- a/templates/webadmin/admin.html +++ b/templates/webadmin/admin.html @@ -182,6 +182,16 @@ along with this program. If not, see . +
+ +
+ + + Default expiration for newly created users as number of days + +
+
diff --git a/templates/webadmin/user.html b/templates/webadmin/user.html index 340d556e..000d0f1e 100644 --- a/templates/webadmin/user.html +++ b/templates/webadmin/user.html @@ -1057,6 +1057,10 @@ along with this program. If not, see . showClear: false, showClose: true, showToday: false + }, + widgetPositioning: { + horizontal: 'auto', + vertical: 'bottom' } });