allow to set a default expiration for newly created users

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-11-05 18:01:24 +01:00
parent 33bfd61a0c
commit 048591553a
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
11 changed files with 177 additions and 36 deletions

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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 {

View file

@ -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)

View file

@ -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)

View file

@ -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")

View file

@ -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") != "" {

View file

@ -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
}

View file

@ -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:

View file

@ -182,6 +182,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</select>
</div>
</div>
<div class="form-group row">
<label for="idDefaultUsersExpiration" class="col-sm-2 col-form-label">Default users expiration</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="idDefaultUsersExpiration" name="default_users_expiration"
value="{{.Admin.Filters.Preferences.DefaultUsersExpiration}}" min="0" aria-describedby="defaultUsersExpirationHelpBlock">
<small id="defaultUsersExpirationHelpBlock" class="form-text text-muted">
Default expiration for newly created users as number of days
</small>
</div>
</div>
</div>
</div>

View file

@ -1057,6 +1057,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
showClear: false,
showClose: true,
showToday: false
},
widgetPositioning: {
horizontal: 'auto',
vertical: 'bottom'
}
});