sftpgo-mirror/internal/dataprovider/admin.go
Nicola Murino 3dd412f6e3
WebAdmin and REST API: remove too granular permissions
Our permissions system for admin users is too granular and some
permissions overlap. For example, you can define an administrator
with the "manage_system" permission and not with the "manage_admins"
or "manage_user" permission, but the "manage_system" permission
allows you to restore a backup and then create users and
administrators. The following permissions will be removed:
"manage_admins", "manage_apikeys", "manage_system", "retention_checks",
"manage_event_rules", "manage_roles", "manage_ip_lists". Now you
need to add the "*" permission to replace the removed granular
permissions because the removed permissions allow actions that
should only be allowed to super administrators.
There is no point in having separate, overlapping permissions.

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
2024-11-10 10:46:28 +01:00

637 lines
20 KiB
Go

// Copyright (C) 2019 Nicola Murino
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package dataprovider
import (
"encoding/json"
"errors"
"fmt"
"net"
"os"
"slices"
"strconv"
"strings"
"github.com/alexedwards/argon2id"
"github.com/sftpgo/sdk"
passwordvalidator "github.com/wagslane/go-password-validator"
"golang.org/x/crypto/bcrypt"
"github.com/drakkan/sftpgo/v2/internal/kms"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/mfa"
"github.com/drakkan/sftpgo/v2/internal/util"
)
// Available permissions for SFTPGo admins
const (
PermAdminAny = "*"
PermAdminAddUsers = "add_users"
PermAdminChangeUsers = "edit_users"
PermAdminDeleteUsers = "del_users"
PermAdminViewUsers = "view_users"
PermAdminViewConnections = "view_conns"
PermAdminCloseConnections = "close_conns"
PermAdminViewServerStatus = "view_status"
PermAdminManageGroups = "manage_groups"
PermAdminManageFolders = "manage_folders"
PermAdminQuotaScans = "quota_scans"
PermAdminManageDefender = "manage_defender"
PermAdminViewDefender = "view_defender"
PermAdminViewEvents = "view_events"
PermAdminDisableMFA = "disable_mfa"
)
const (
// GroupAddToUsersAsMembership defines that the admin's group will be added as membership group for new users
GroupAddToUsersAsMembership = iota
// GroupAddToUsersAsPrimary defines that the admin's group will be added as primary group for new users
GroupAddToUsersAsPrimary
// GroupAddToUsersAsSecondary defines that the admin's group will be added as secondary group for new users
GroupAddToUsersAsSecondary
)
var (
validAdminPerms = []string{PermAdminAny, PermAdminAddUsers, PermAdminChangeUsers, PermAdminDeleteUsers,
PermAdminViewUsers, PermAdminManageFolders, PermAdminManageGroups, PermAdminViewConnections,
PermAdminCloseConnections, PermAdminViewServerStatus, PermAdminQuotaScans,
PermAdminManageDefender, PermAdminViewDefender, PermAdminViewEvents, PermAdminDisableMFA}
forbiddenPermsForRoleAdmins = []string{PermAdminAny}
)
// AdminTOTPConfig defines the time-based one time password configuration
type AdminTOTPConfig struct {
Enabled bool `json:"enabled,omitempty"`
ConfigName string `json:"config_name,omitempty"`
Secret *kms.Secret `json:"secret,omitempty"`
}
func (c *AdminTOTPConfig) validate(username string) error {
if !c.Enabled {
c.ConfigName = ""
c.Secret = kms.NewEmptySecret()
return nil
}
if c.ConfigName == "" {
return util.NewValidationError("totp: config name is mandatory")
}
if !slices.Contains(mfa.GetAvailableTOTPConfigNames(), c.ConfigName) {
return util.NewValidationError(fmt.Sprintf("totp: config name %q not found", c.ConfigName))
}
if c.Secret.IsEmpty() {
return util.NewValidationError("totp: secret is mandatory")
}
if c.Secret.IsPlain() {
c.Secret.SetAdditionalData(username)
if err := c.Secret.Encrypt(); err != nil {
return util.NewValidationError(fmt.Sprintf("totp: unable to encrypt secret: %v", err))
}
}
return nil
}
// AdminPreferences defines the admin preferences
type AdminPreferences struct {
// 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
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
func (p *AdminPreferences) HideGroups() bool {
return p.HideUserPageSections&1 != 0
}
// HideFilesystem returns true if the filesystem section should be hidden
func (p *AdminPreferences) HideFilesystem() bool {
return config.UsersBaseDir != "" && p.HideUserPageSections&2 != 0
}
// HideVirtualFolders returns true if the virtual folder section should be hidden
func (p *AdminPreferences) HideVirtualFolders() bool {
return p.HideUserPageSections&4 != 0
}
// HideProfile returns true if the profile section should be hidden
func (p *AdminPreferences) HideProfile() bool {
return p.HideUserPageSections&8 != 0
}
// HideACLs returns true if the ACLs section should be hidden
func (p *AdminPreferences) HideACLs() bool {
return p.HideUserPageSections&16 != 0
}
// HideDiskQuotaAndBandwidthLimits returns true if the disk quota and bandwidth limits
// section should be hidden
func (p *AdminPreferences) HideDiskQuotaAndBandwidthLimits() bool {
return p.HideUserPageSections&32 != 0
}
// HideAdvancedSettings returns true if the advanced settings section should be hidden
func (p *AdminPreferences) HideAdvancedSettings() bool {
return p.HideUserPageSections&64 != 0
}
// VisibleUserPageSections returns the number of visible sections
// in the user page
func (p *AdminPreferences) VisibleUserPageSections() int {
var result int
if !p.HideProfile() {
result++
}
if !p.HideACLs() {
result++
}
if !p.HideDiskQuotaAndBandwidthLimits() {
result++
}
if !p.HideAdvancedSettings() {
result++
}
return result
}
// AdminFilters defines additional restrictions for SFTPGo admins
// TODO: rename to AdminOptions in v3
type AdminFilters struct {
// only clients connecting from these IP/Mask are allowed.
// IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291
// for example "192.0.2.0/24" or "2001:db8::/32"
AllowList []string `json:"allow_list,omitempty"`
// API key auth allows to impersonate this administrator with an API key
AllowAPIKeyAuth bool `json:"allow_api_key_auth,omitempty"`
// A password change is required at the next login
RequirePasswordChange bool `json:"require_password_change,omitempty"`
// Require two factor authentication
RequireTwoFactor bool `json:"require_two_factor"`
// Time-based one time passwords configuration
TOTPConfig AdminTOTPConfig `json:"totp_config,omitempty"`
// Recovery codes to use if the user loses access to their second factor auth device.
// Each code can only be used once, you should use these codes to login and disable or
// reset 2FA for your account
RecoveryCodes []RecoveryCode `json:"recovery_codes,omitempty"`
Preferences AdminPreferences `json:"preferences"`
}
// AdminGroupMappingOptions defines the options for admin/group mapping
type AdminGroupMappingOptions struct {
AddToUsersAs int `json:"add_to_users_as,omitempty"`
}
func (o *AdminGroupMappingOptions) validate() error {
if o.AddToUsersAs < GroupAddToUsersAsMembership || o.AddToUsersAs > GroupAddToUsersAsSecondary {
return util.NewValidationError(fmt.Sprintf("Invalid mode to add groups to new users: %d", o.AddToUsersAs))
}
return nil
}
// GetUserGroupType returns the type for the matching user group
func (o *AdminGroupMappingOptions) GetUserGroupType() int {
switch o.AddToUsersAs {
case GroupAddToUsersAsPrimary:
return sdk.GroupTypePrimary
case GroupAddToUsersAsSecondary:
return sdk.GroupTypeSecondary
default:
return sdk.GroupTypeMembership
}
}
// AdminGroupMapping defines the mapping between an SFTPGo admin and a group
type AdminGroupMapping struct {
Name string `json:"name"`
Options AdminGroupMappingOptions `json:"options"`
}
// Admin defines a SFTPGo admin
type Admin struct {
// Database unique identifier
ID int64 `json:"id"`
// 1 enabled, 0 disabled (login is not allowed)
Status int `json:"status"`
// Username
Username string `json:"username"`
Password string `json:"password,omitempty"`
Email string `json:"email,omitempty"`
Permissions []string `json:"permissions"`
Filters AdminFilters `json:"filters,omitempty"`
Description string `json:"description,omitempty"`
AdditionalInfo string `json:"additional_info,omitempty"`
// Groups membership
Groups []AdminGroupMapping `json:"groups,omitempty"`
// Creation time as unix timestamp in milliseconds. It will be 0 for admins created before v2.2.0
CreatedAt int64 `json:"created_at"`
// last update time as unix timestamp in milliseconds
UpdatedAt int64 `json:"updated_at"`
// Last login as unix timestamp in milliseconds
LastLogin int64 `json:"last_login"`
// Role name. If set the admin can only administer users with the same role.
// Role admins cannot be super administrators
Role string `json:"role,omitempty"`
}
// CountUnusedRecoveryCodes returns the number of unused recovery codes
func (a *Admin) CountUnusedRecoveryCodes() int {
unused := 0
for _, code := range a.Filters.RecoveryCodes {
if !code.Used {
unused++
}
}
return unused
}
func (a *Admin) hashPassword() error {
if a.Password != "" && !util.IsStringPrefixInSlice(a.Password, internalHashPwdPrefixes) {
if config.PasswordValidation.Admins.MinEntropy > 0 {
if err := passwordvalidator.Validate(a.Password, config.PasswordValidation.Admins.MinEntropy); err != nil {
return util.NewI18nError(util.NewValidationError(err.Error()), util.I18nErrorPasswordComplexity)
}
}
if config.PasswordHashing.Algo == HashingAlgoBcrypt {
pwd, err := bcrypt.GenerateFromPassword([]byte(a.Password), config.PasswordHashing.BcryptOptions.Cost)
if err != nil {
return err
}
a.Password = util.BytesToString(pwd)
} else {
pwd, err := argon2id.CreateHash(a.Password, argon2Params)
if err != nil {
return err
}
a.Password = pwd
}
}
return nil
}
func (a *Admin) hasRedactedSecret() bool {
return a.Filters.TOTPConfig.Secret.IsRedacted()
}
func (a *Admin) validateRecoveryCodes() error {
for i := 0; i < len(a.Filters.RecoveryCodes); i++ {
code := &a.Filters.RecoveryCodes[i]
if code.Secret.IsEmpty() {
return util.NewValidationError("mfa: recovery code cannot be empty")
}
if code.Secret.IsPlain() {
code.Secret.SetAdditionalData(a.Username)
if err := code.Secret.Encrypt(); err != nil {
return util.NewValidationError(fmt.Sprintf("mfa: unable to encrypt recovery code: %v", err))
}
}
}
return nil
}
func (a *Admin) validatePermissions() error {
a.Permissions = util.RemoveDuplicates(a.Permissions, false)
if len(a.Permissions) == 0 {
return util.NewI18nError(
util.NewValidationError("please grant some permissions to this admin"),
util.I18nErrorPermissionsRequired,
)
}
if slices.Contains(a.Permissions, PermAdminAny) {
a.Permissions = []string{PermAdminAny}
}
for _, perm := range a.Permissions {
if !slices.Contains(validAdminPerms, perm) {
return util.NewValidationError(fmt.Sprintf("invalid permission: %q", perm))
}
if a.Role != "" {
if slices.Contains(forbiddenPermsForRoleAdmins, perm) {
return util.NewI18nError(
util.NewValidationError("a role admin cannot be a super admin"),
util.I18nErrorRoleAdminPerms,
)
}
}
}
return nil
}
func (a *Admin) validateGroups() error {
hasPrimary := false
for _, g := range a.Groups {
if g.Name == "" {
return util.NewValidationError("group name is mandatory")
}
if err := g.Options.validate(); err != nil {
return err
}
if g.Options.AddToUsersAs == GroupAddToUsersAsPrimary {
if hasPrimary {
return util.NewI18nError(
util.NewValidationError("only one primary group is allowed"),
util.I18nErrorPrimaryGroup,
)
}
hasPrimary = true
}
}
return nil
}
func (a *Admin) validate() error {
a.SetEmptySecretsIfNil()
if a.Username == "" {
return util.NewI18nError(util.NewValidationError("username is mandatory"), util.I18nErrorUsernameRequired)
}
if err := checkReservedUsernames(a.Username); err != nil {
return util.NewI18nError(err, util.I18nErrorReservedUsername)
}
if a.Password == "" {
return util.NewI18nError(util.NewValidationError("please set a password"), util.I18nErrorPasswordRequired)
}
if a.hasRedactedSecret() {
return util.NewValidationError("cannot save an admin with a redacted secret")
}
if err := a.Filters.TOTPConfig.validate(a.Username); err != nil {
return util.NewI18nError(err, util.I18nError2FAInvalid)
}
if err := a.validateRecoveryCodes(); err != nil {
return util.NewI18nError(err, util.I18nErrorRecoveryCodesInvalid)
}
if config.NamingRules&1 == 0 && !usernameRegex.MatchString(a.Username) {
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("username %q is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username)),
util.I18nErrorInvalidUser,
)
}
if err := a.hashPassword(); err != nil {
return err
}
if err := a.validatePermissions(); err != nil {
return err
}
if a.Email != "" && !util.IsEmailValid(a.Email) {
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("email %q is not valid", a.Email)),
util.I18nErrorInvalidEmail,
)
}
a.Filters.AllowList = util.RemoveDuplicates(a.Filters.AllowList, false)
for _, IPMask := range a.Filters.AllowList {
_, _, err := net.ParseCIDR(IPMask)
if err != nil {
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("could not parse allow list entry %q : %v", IPMask, err)),
util.I18nErrorInvalidIPMask,
)
}
}
return a.validateGroups()
}
// CheckPassword verifies the admin password
func (a *Admin) CheckPassword(password string) (bool, error) {
if config.PasswordCaching {
found, match := cachedAdminPasswords.Check(a.Username, password, a.Password)
if found {
if !match {
return false, ErrInvalidCredentials
}
return match, nil
}
}
if strings.HasPrefix(a.Password, bcryptPwdPrefix) {
if err := bcrypt.CompareHashAndPassword([]byte(a.Password), []byte(password)); err != nil {
return false, ErrInvalidCredentials
}
cachedAdminPasswords.Add(a.Username, password, a.Password)
return true, nil
}
match, err := argon2id.ComparePasswordAndHash(password, a.Password)
if !match || err != nil {
return false, ErrInvalidCredentials
}
if match {
cachedAdminPasswords.Add(a.Username, password, a.Password)
}
return match, err
}
// CanLoginFromIP returns true if login from the given IP is allowed
func (a *Admin) CanLoginFromIP(ip string) bool {
if len(a.Filters.AllowList) == 0 {
return true
}
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return len(a.Filters.AllowList) == 0
}
for _, ipMask := range a.Filters.AllowList {
_, network, err := net.ParseCIDR(ipMask)
if err != nil {
continue
}
if network.Contains(parsedIP) {
return true
}
}
return false
}
// CanLogin returns an error if the login is not allowed
func (a *Admin) CanLogin(ip string) error {
if a.Status != 1 {
return fmt.Errorf("admin %q is disabled", a.Username)
}
if !a.CanLoginFromIP(ip) {
return fmt.Errorf("login from IP %v not allowed", ip)
}
return nil
}
func (a *Admin) checkUserAndPass(password, ip string) error {
if err := a.CanLogin(ip); err != nil {
return err
}
if a.Password == "" || password == "" {
return errors.New("credentials cannot be null or empty")
}
match, err := a.CheckPassword(password)
if err != nil {
return err
}
if !match {
return ErrInvalidCredentials
}
return nil
}
// RenderAsJSON implements the renderer interface used within plugins
func (a *Admin) RenderAsJSON(reload bool) ([]byte, error) {
if reload {
admin, err := provider.adminExists(a.Username)
if err != nil {
providerLog(logger.LevelError, "unable to reload admin before rendering as json: %v", err)
return nil, err
}
admin.HideConfidentialData()
return json.Marshal(admin)
}
a.HideConfidentialData()
return json.Marshal(a)
}
// HideConfidentialData hides admin confidential data
func (a *Admin) HideConfidentialData() {
a.Password = ""
if a.Filters.TOTPConfig.Secret != nil {
a.Filters.TOTPConfig.Secret.Hide()
}
for _, code := range a.Filters.RecoveryCodes {
if code.Secret != nil {
code.Secret.Hide()
}
}
a.SetNilSecretsIfEmpty()
}
// SetEmptySecretsIfNil sets the secrets to empty if nil
func (a *Admin) SetEmptySecretsIfNil() {
if a.Filters.TOTPConfig.Secret == nil {
a.Filters.TOTPConfig.Secret = kms.NewEmptySecret()
}
}
// SetNilSecretsIfEmpty set the secrets to nil if empty.
// This is useful before rendering as JSON so the empty fields
// will not be serialized.
func (a *Admin) SetNilSecretsIfEmpty() {
if a.Filters.TOTPConfig.Secret != nil && a.Filters.TOTPConfig.Secret.IsEmpty() {
a.Filters.TOTPConfig.Secret = nil
}
}
// HasPermission returns true if the admin has the specified permission
func (a *Admin) HasPermission(perm string) bool {
if slices.Contains(a.Permissions, PermAdminAny) {
return true
}
return slices.Contains(a.Permissions, perm)
}
// GetAllowedIPAsString returns the allowed IP as comma separated string
func (a *Admin) GetAllowedIPAsString() string {
return strings.Join(a.Filters.AllowList, ",")
}
// GetValidPerms returns the allowed admin permissions
func (a *Admin) GetValidPerms() []string {
return validAdminPerms
}
// CanManageMFA returns true if the admin can add a multi-factor authentication configuration
func (a *Admin) CanManageMFA() bool {
return len(mfa.GetAvailableTOTPConfigs()) > 0
}
// GetSignature returns a signature for this admin.
// It will change after an update
func (a *Admin) GetSignature() string {
return strconv.FormatInt(a.UpdatedAt, 10)
}
func (a *Admin) getACopy() Admin {
a.SetEmptySecretsIfNil()
permissions := make([]string, len(a.Permissions))
copy(permissions, a.Permissions)
filters := AdminFilters{}
filters.AllowList = make([]string, len(a.Filters.AllowList))
filters.AllowAPIKeyAuth = a.Filters.AllowAPIKeyAuth
filters.RequirePasswordChange = a.Filters.RequirePasswordChange
filters.RequireTwoFactor = a.Filters.RequireTwoFactor
filters.TOTPConfig.Enabled = a.Filters.TOTPConfig.Enabled
filters.TOTPConfig.ConfigName = a.Filters.TOTPConfig.ConfigName
filters.TOTPConfig.Secret = a.Filters.TOTPConfig.Secret.Clone()
copy(filters.AllowList, a.Filters.AllowList)
filters.RecoveryCodes = make([]RecoveryCode, 0)
for _, code := range a.Filters.RecoveryCodes {
if code.Secret == nil {
code.Secret = kms.NewEmptySecret()
}
filters.RecoveryCodes = append(filters.RecoveryCodes, RecoveryCode{
Secret: code.Secret.Clone(),
Used: code.Used,
})
}
filters.Preferences = AdminPreferences{
HideUserPageSections: a.Filters.Preferences.HideUserPageSections,
DefaultUsersExpiration: a.Filters.Preferences.DefaultUsersExpiration,
}
groups := make([]AdminGroupMapping, 0, len(a.Groups))
for _, g := range a.Groups {
groups = append(groups, AdminGroupMapping{
Name: g.Name,
Options: AdminGroupMappingOptions{
AddToUsersAs: g.Options.AddToUsersAs,
},
})
}
return Admin{
ID: a.ID,
Status: a.Status,
Username: a.Username,
Password: a.Password,
Email: a.Email,
Permissions: permissions,
Groups: groups,
Filters: filters,
AdditionalInfo: a.AdditionalInfo,
Description: a.Description,
LastLogin: a.LastLogin,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
Role: a.Role,
}
}
func (a *Admin) setFromEnv() error {
envUsername := strings.TrimSpace(os.Getenv("SFTPGO_DEFAULT_ADMIN_USERNAME"))
envPassword := strings.TrimSpace(os.Getenv("SFTPGO_DEFAULT_ADMIN_PASSWORD"))
if envUsername == "" || envPassword == "" {
return errors.New(`to create the default admin you need to set the env vars "SFTPGO_DEFAULT_ADMIN_USERNAME" and "SFTPGO_DEFAULT_ADMIN_PASSWORD"`)
}
a.Username = envUsername
a.Password = envPassword
a.Status = 1
a.Permissions = []string{PermAdminAny}
return nil
}