add minimum password length check

This commit is contained in:
Christoph Haas 2025-05-16 09:55:35 +02:00
parent 1394be2341
commit e9005b1b90
No known key found for this signature in database
13 changed files with 129 additions and 13 deletions

View File

@ -14,7 +14,7 @@ Configuration examples are available on the [Examples](./examples.md) page.
```yaml ```yaml
core: core:
admin_user: admin@wgportal.local admin_user: admin@wgportal.local
admin_password: wgportal admin_password: wgportal-default
admin_api_token: "" admin_api_token: ""
editable_keys: true editable_keys: true
create_default_peer: false create_default_peer: false
@ -74,6 +74,7 @@ auth:
ldap: [] ldap: []
webauthn: webauthn:
enabled: true enabled: true
min_password_length: 16
web: web:
listening_address: :8888 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. - **Description:** The administrator user. This user will be created as a default admin if it does not yet exist.
### `admin_password` ### `admin_password`
- **Default:** `wgportal` - **Default:** `wgportal-default`
- **Description:** The administrator password. The default password of `wgportal` should be changed immediately. - **Description:** The administrator password. The default password 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. - **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` ### `admin_api_token`
- **Default:** *(empty)* - **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`). 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. 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 ### OIDC

View File

@ -61,6 +61,26 @@ const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
const wgVersion = ref(WGPORTAL_VERSION); const wgVersion = ref(WGPORTAL_VERSION);
const currentYear = ref(new Date().getFullYear()) 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;
})
</script> </script>
<template> <template>
@ -93,10 +113,10 @@ const currentYear = ref(new Date().getFullYear())
<div class="navbar-nav d-flex justify-content-end"> <div class="navbar-nav d-flex justify-content-end">
<div v-if="auth.IsAuthenticated" class="nav-item dropdown"> <div v-if="auth.IsAuthenticated" class="nav-item dropdown">
<a aria-expanded="false" aria-haspopup="true" class="nav-link dropdown-toggle" data-bs-toggle="dropdown" <a aria-expanded="false" aria-haspopup="true" class="nav-link dropdown-toggle" data-bs-toggle="dropdown"
href="#" role="button">{{ auth.User.Firstname }} {{ auth.User.Lastname }}</a> href="#" role="button">{{ userDisplayName }}</a>
<div class="dropdown-menu"> <div class="dropdown-menu">
<RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink> <RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink>
<RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</RouterLink> <RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly') || settings.Setting('WebAuthnEnabled')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</RouterLink>
<RouterLink :to="{ name: 'audit' }" class="dropdown-item" v-if="auth.IsAdmin"><i class="fas fa-file-shield"></i> {{ $t('menu.audit') }}</RouterLink> <RouterLink :to="{ name: 'audit' }" class="dropdown-item" v-if="auth.IsAdmin"><i class="fas fa-file-shield"></i> {{ $t('menu.audit') }}</RouterLink>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" @click.prevent="auth.Logout"><i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}</a> <a class="dropdown-item" href="#" @click.prevent="auth.Logout"><i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}</a>

View File

@ -5,10 +5,12 @@ import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification"; import { notify } from "@kyvg/vue3-notification";
import {freshUser} from "@/helpers/models"; import {freshUser} from "@/helpers/models";
import {settingsStore} from "@/stores/settings";
const { t } = useI18n() const { t } = useI18n()
const users = userStore() const users = userStore()
const settings = settingsStore()
const props = defineProps({ const props = defineProps({
userId: String, userId: String,
@ -33,6 +35,30 @@ const title = computed(() => {
const formData = ref(freshUser()) const formData = ref(freshUser())
const passwordWeak = computed(() => {
return formData.value.Password && formData.value.Password.length > 0 && formData.value.Password.length < settings.Setting('MinPasswordLength')
})
const formValid = computed(() => {
if (formData.value.Source !== 'db') {
return true // nothing to validate
}
if (props.userId !== '#NEW#' && passwordWeak.value) {
return false
}
if (props.userId === '#NEW#' && (!formData.value.Password || formData.value.Password.length < 1)) {
return false
}
if (props.userId === '#NEW#' && passwordWeak.value) {
return false
}
if (!formData.value.Identifier || formData.value.Identifier.length < 1) {
return false
}
return true
})
// functions // functions
watch(() => props.visible, async (newValue, oldValue) => { watch(() => props.visible, async (newValue, oldValue) => {
@ -109,7 +135,8 @@ async function del() {
</div> </div>
<div v-if="formData.Source==='db'" class="form-group"> <div v-if="formData.Source==='db'" class="form-group">
<label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label> <label class="form-label mt-4">{{ $t('modals.user-edit.password.label') }}</label>
<input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :placeholder="$t('modals.user-edit.password.placeholder')" type="text"> <input v-model="formData.Password" aria-describedby="passwordHelp" class="form-control" :class="{ 'is-invalid': passwordWeak, 'is-valid': formData.Password !== '' && !passwordWeak }" :placeholder="$t('modals.user-edit.password.placeholder')" type="password">
<div class="invalid-feedback">{{ $t('modals.user-edit.password.too-weak') }}</div>
<small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small> <small v-if="props.userId!=='#NEW#'" id="passwordHelp" class="form-text text-muted">{{ $t('modals.user-edit.password.description') }}</small>
</div> </div>
</fieldset> </fieldset>
@ -168,7 +195,7 @@ async function del() {
<div class="flex-fill text-start"> <div class="flex-fill text-start">
<button v-if="props.userId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button> <button v-if="props.userId!=='#NEW#'" class="btn btn-danger me-1" type="button" @click.prevent="del">{{ $t('general.delete') }}</button>
</div> </div>
<button class="btn btn-primary me-1" type="button" @click.prevent="save">{{ $t('general.save') }}</button> <button class="btn btn-primary me-1" type="button" @click.prevent="save" :disabled="!formValid">{{ $t('general.save') }}</button>
<button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button> <button class="btn btn-secondary" type="button" @click.prevent="close">{{ $t('general.close') }}</button>
</template> </template>
</Modal> </Modal>

View File

@ -296,7 +296,8 @@
"password": { "password": {
"label": "Passwort", "label": "Passwort",
"placeholder": "Ein super geheimes 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": { "email": {
"label": "E-Mail", "label": "E-Mail",

View File

@ -296,7 +296,8 @@
"password": { "password": {
"label": "Password", "label": "Password",
"placeholder": "A super secret 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": { "email": {
"label": "Email", "label": "Email",

View File

@ -99,6 +99,8 @@ type Authenticator interface {
LoggedIn(scopes ...Scope) func(next http.Handler) http.Handler 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 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 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 { type Session interface {

View File

@ -47,7 +47,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup := g.Mount("/config") apiGroup := g.Mount("/config")
apiGroup.HandleFunc("GET /frontend.js", e.handleConfigJsGet()) 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. // handleConfigJsGet returns a gorm Handler function.
@ -108,6 +108,7 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed, SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed,
ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly, ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled, WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
MinPasswordLength: e.cfg.Auth.MinPasswordLength,
}) })
} }
} }

View File

@ -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. // 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 { func (h AuthenticationHandler) UserIdMatch(idParameter string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {

View File

@ -11,4 +11,5 @@ type Settings struct {
SelfProvisioning bool `json:"SelfProvisioning"` SelfProvisioning bool `json:"SelfProvisioning"`
ApiAdminOnly bool `json:"ApiAdminOnly"` ApiAdminOnly bool `json:"ApiAdminOnly"`
WebAuthnEnabled bool `json:"WebAuthnEnabled"` WebAuthnEnabled bool `json:"WebAuthnEnabled"`
MinPasswordLength int `json:"MinPasswordLength"`
} }

View File

@ -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) 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 { if currentUser.Id == old.Identifier && old.IsAdmin && !new.IsAdmin {
return fmt.Errorf("cannot remove own admin rights: %w", domain.ErrInvalidData) 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 // database users must have a password
if new.Source == domain.UserSourceDatabase && string(new.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 return nil

View File

@ -18,6 +18,9 @@ type Auth struct {
Ldap []LdapProvider `yaml:"ldap"` Ldap []LdapProvider `yaml:"ldap"`
// Webauthn contains the configuration for the WebAuthn authenticator. // Webauthn contains the configuration for the WebAuthn authenticator.
WebAuthn WebauthnConfig `yaml:"webauthn"` 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. // BaseFields contains the basic fields that are used to map user information from the authentication providers.

View File

@ -101,7 +101,7 @@ func defaultConfig() *Config {
cfg := &Config{} cfg := &Config{}
cfg.Core.AdminUser = "admin@wgportal.local" 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.AdminApiToken = "" // by default, the API access is disabled
cfg.Core.ImportExisting = true cfg.Core.ImportExisting = true
cfg.Core.RestoreState = true cfg.Core.RestoreState = true
@ -165,6 +165,7 @@ func defaultConfig() *Config {
cfg.Webhook.Timeout = 10 * time.Second cfg.Webhook.Timeout = 10 * time.Second
cfg.Auth.WebAuthn.Enabled = true cfg.Auth.WebAuthn.Enabled = true
cfg.Auth.MinPasswordLength = 16
return cfg return cfg
} }

View File

@ -88,6 +88,22 @@ func (u *User) CanChangePassword() error {
return errors.New("password change only allowed for database source") 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 { func (u *User) EditAllowed(new *User) error {
if u.Source == UserSourceDatabase { if u.Source == UserSourceDatabase {
return nil return nil