diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 3cbdb59..158d060 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -14,7 +14,7 @@ Configuration examples are available on the [Examples](./examples.md) page. ```yaml core: admin_user: admin@wgportal.local - admin_password: wgportal + admin_password: wgportal-default admin_api_token: "" editable_keys: true create_default_peer: false @@ -74,6 +74,7 @@ auth: ldap: [] webauthn: enabled: true + min_password_length: 16 web: listening_address: :8888 @@ -120,9 +121,9 @@ More advanced options are found in the subsequent `Advanced` section. - **Description:** The administrator user. This user will be created as a default admin if it does not yet exist. ### `admin_password` -- **Default:** `wgportal` -- **Description:** The administrator password. The default password of `wgportal` should be changed immediately. -- **Important:** The password should be strong and secure. It is recommended to use a password with at least 16 characters, including uppercase and lowercase letters, numbers, and special characters. +- **Default:** `wgportal-default` +- **Description:** The administrator password. The default password should be changed immediately! +- **Important:** The password should be strong and secure. The minimum password length is specified in [auth.min_password_length](#min_password_length). By default, it is 16 characters. ### `admin_api_token` - **Default:** *(empty)* @@ -340,6 +341,14 @@ Options for configuring email notifications or sending peer configurations via e WireGuard Portal supports multiple authentication strategies, including **OpenID Connect** (`oidc`), **OAuth** (`oauth`), **Passkeys** (`webauthn`) and **LDAP** (`ldap`). Each can have multiple providers configured. Below are the relevant keys. +Some core authentication options are shared across all providers, while others are specific to each provider type. + +### `min_password_length` +- **Default:** `16` +- **Description:** Minimum password length for local authentication. This is not enforced for LDAP authentication. + The default admin password strength is also enforced by this setting. +- **Important:** The password should be strong and secure. It is recommended to use a password with at least 16 characters, including uppercase and lowercase letters, numbers, and special characters. + --- ### OIDC diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cffbb8a..e6e20d2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -61,6 +61,26 @@ const companyName = ref(WGPORTAL_SITE_COMPANY_NAME); const wgVersion = ref(WGPORTAL_VERSION); const currentYear = ref(new Date().getFullYear()) +const userDisplayName = computed(() => { + let displayName = "Unknown"; + if (auth.IsAuthenticated) { + if (auth.User.Firstname === "" && auth.User.Lastname === "") { + displayName = auth.User.Identifier; + } else if (auth.User.Firstname === "" && auth.User.Lastname !== "") { + displayName = auth.User.Lastname; + } else if (auth.User.Firstname !== "" && auth.User.Lastname === "") { + displayName = auth.User.Firstname; + } else if (auth.User.Firstname !== "" && auth.User.Lastname !== "") { + displayName = auth.User.Firstname + " " + auth.User.Lastname; + } + } + + // pad string to 20 characters so that the menu is always the same size on desktop + if (displayName.length < 20 && window.innerWidth > 992) { + displayName = displayName.padStart(20, "\u00A0"); + } + return displayName; +}) diff --git a/frontend/src/lang/translations/de.json b/frontend/src/lang/translations/de.json index 3a172d6..ff8af74 100644 --- a/frontend/src/lang/translations/de.json +++ b/frontend/src/lang/translations/de.json @@ -296,7 +296,8 @@ "password": { "label": "Passwort", "placeholder": "Ein super geheimes Passwort", - "description": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten." + "description": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten.", + "too-weak": "Das Passwort entspricht nicht den Sicherheitsanforderungen." }, "email": { "label": "E-Mail", diff --git a/frontend/src/lang/translations/en.json b/frontend/src/lang/translations/en.json index 4ee8199..06e95e0 100644 --- a/frontend/src/lang/translations/en.json +++ b/frontend/src/lang/translations/en.json @@ -296,7 +296,8 @@ "password": { "label": "Password", "placeholder": "A super secret password", - "description": "Leave this field blank to keep current password." + "description": "Leave this field blank to keep current password.", + "too-weak": "The password is too weak. Please use a stronger password." }, "email": { "label": "Email", diff --git a/internal/app/api/v0/handlers/base.go b/internal/app/api/v0/handlers/base.go index 38ac024..bee1d05 100644 --- a/internal/app/api/v0/handlers/base.go +++ b/internal/app/api/v0/handlers/base.go @@ -99,6 +99,8 @@ type Authenticator interface { LoggedIn(scopes ...Scope) func(next http.Handler) http.Handler // UserIdMatch checks if the user id in the session matches the user id in the request. If not, the request is aborted. UserIdMatch(idParameter string) func(next http.Handler) http.Handler + // InfoOnly only add user info to the request context. No login check is performed. + InfoOnly() func(next http.Handler) http.Handler } type Session interface { diff --git a/internal/app/api/v0/handlers/endpoint_config.go b/internal/app/api/v0/handlers/endpoint_config.go index d791f88..a99effe 100644 --- a/internal/app/api/v0/handlers/endpoint_config.go +++ b/internal/app/api/v0/handlers/endpoint_config.go @@ -47,7 +47,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) { apiGroup := g.Mount("/config") apiGroup.HandleFunc("GET /frontend.js", e.handleConfigJsGet()) - apiGroup.HandleFunc("GET /settings", e.handleSettingsGet()) + apiGroup.With(e.authenticator.InfoOnly()).HandleFunc("GET /settings", e.handleSettingsGet()) } // handleConfigJsGet returns a gorm Handler function. @@ -108,6 +108,7 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc { SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed, ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly, WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, + MinPasswordLength: e.cfg.Auth.MinPasswordLength, }) } } diff --git a/internal/app/api/v0/handlers/web_authentication.go b/internal/app/api/v0/handlers/web_authentication.go index 1b6a570..f214739 100644 --- a/internal/app/api/v0/handlers/web_authentication.go +++ b/internal/app/api/v0/handlers/web_authentication.go @@ -72,6 +72,32 @@ func (h AuthenticationHandler) LoggedIn(scopes ...Scope) func(next http.Handler) } } +// InfoOnly only checks if the user is logged in and adds the user id to the context. +// If the user is not logged in, the context user id is set to domain.CtxUnknownUserId. +func (h AuthenticationHandler) InfoOnly() func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := h.session.GetData(r.Context()) + + var newContext context.Context + + if !session.LoggedIn { + newContext = domain.SetUserInfo(r.Context(), domain.DefaultContextUserInfo()) + } else { + newContext = domain.SetUserInfo(r.Context(), &domain.ContextUserInfo{ + Id: domain.UserIdentifier(session.UserIdentifier), + IsAdmin: session.IsAdmin, + }) + } + + r = r.WithContext(newContext) + + // Continue down the chain to Handler etc + next.ServeHTTP(w, r) + }) + } +} + // UserIdMatch checks if the user id in the session matches the user id in the request. If not, the request is aborted. func (h AuthenticationHandler) UserIdMatch(idParameter string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { diff --git a/internal/app/api/v0/model/models.go b/internal/app/api/v0/model/models.go index a6bbc75..847b139 100644 --- a/internal/app/api/v0/model/models.go +++ b/internal/app/api/v0/model/models.go @@ -11,4 +11,5 @@ type Settings struct { SelfProvisioning bool `json:"SelfProvisioning"` ApiAdminOnly bool `json:"ApiAdminOnly"` WebAuthnEnabled bool `json:"WebAuthnEnabled"` + MinPasswordLength int `json:"MinPasswordLength"` } diff --git a/internal/app/users/user_manager.go b/internal/app/users/user_manager.go index cd8fb5b..11e8ac8 100644 --- a/internal/app/users/user_manager.go +++ b/internal/app/users/user_manager.go @@ -364,6 +364,10 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use return errors.Join(fmt.Errorf("no access: %w", err), domain.ErrInvalidData) } + if err := new.HasWeakPassword(m.cfg.Auth.MinPasswordLength); err != nil { + return errors.Join(fmt.Errorf("password too weak: %w", err), domain.ErrInvalidData) + } + if currentUser.Id == old.Identifier && old.IsAdmin && !new.IsAdmin { return fmt.Errorf("cannot remove own admin rights: %w", domain.ErrInvalidData) } @@ -418,7 +422,11 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error { // database users must have a password if new.Source == domain.UserSourceDatabase && string(new.Password) == "" { - return fmt.Errorf("invalid password: %w", domain.ErrInvalidData) + return fmt.Errorf("missing password: %w", domain.ErrInvalidData) + } + + if err := new.HasWeakPassword(m.cfg.Auth.MinPasswordLength); err != nil { + return errors.Join(fmt.Errorf("password too weak: %w", err), domain.ErrInvalidData) } return nil diff --git a/internal/config/auth.go b/internal/config/auth.go index ba68b12..004fc5b 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -18,6 +18,9 @@ type Auth struct { Ldap []LdapProvider `yaml:"ldap"` // Webauthn contains the configuration for the WebAuthn authenticator. WebAuthn WebauthnConfig `yaml:"webauthn"` + // MinPasswordLength is the minimum password length for user accounts. This also applies to the admin user. + // It is encouraged to set this value to at least 16 characters. + MinPasswordLength int `yaml:"min_password_length"` } // BaseFields contains the basic fields that are used to map user information from the authentication providers. diff --git a/internal/config/config.go b/internal/config/config.go index 2096a3f..cb4d995 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -101,7 +101,7 @@ func defaultConfig() *Config { cfg := &Config{} cfg.Core.AdminUser = "admin@wgportal.local" - cfg.Core.AdminPassword = "wgportal" + cfg.Core.AdminPassword = "wgportal-default" cfg.Core.AdminApiToken = "" // by default, the API access is disabled cfg.Core.ImportExisting = true cfg.Core.RestoreState = true @@ -165,6 +165,7 @@ func defaultConfig() *Config { cfg.Webhook.Timeout = 10 * time.Second cfg.Auth.WebAuthn.Enabled = true + cfg.Auth.MinPasswordLength = 16 return cfg } diff --git a/internal/domain/user.go b/internal/domain/user.go index 43a8b47..84b5345 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -88,6 +88,22 @@ func (u *User) CanChangePassword() error { return errors.New("password change only allowed for database source") } +func (u *User) HasWeakPassword(minLength int) error { + if u.Source != UserSourceDatabase { + return nil // password is not required for non-database users, so no check needed + } + + if u.Password == "" { + return nil // password is not set, so no check needed + } + + if len(u.Password) < minLength { + return fmt.Errorf("password is too short, minimum length is %d", minLength) + } + + return nil // password is strong enough +} + func (u *User) EditAllowed(new *User) error { if u.Source == UserSourceDatabase { return nil