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

View File

@ -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;
})
</script>
<template>
@ -93,10 +113,10 @@ const currentYear = ref(new Date().getFullYear())
<div class="navbar-nav d-flex justify-content-end">
<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"
href="#" role="button">{{ auth.User.Firstname }} {{ auth.User.Lastname }}</a>
href="#" role="button">{{ userDisplayName }}</a>
<div class="dropdown-menu">
<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>
<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>

View File

@ -5,10 +5,12 @@ import {computed, ref, watch} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import {freshUser} from "@/helpers/models";
import {settingsStore} from "@/stores/settings";
const { t } = useI18n()
const users = userStore()
const settings = settingsStore()
const props = defineProps({
userId: String,
@ -33,6 +35,30 @@ const title = computed(() => {
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
watch(() => props.visible, async (newValue, oldValue) => {
@ -109,7 +135,8 @@ async function del() {
</div>
<div v-if="formData.Source==='db'" class="form-group">
<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>
</div>
</fieldset>
@ -168,7 +195,7 @@ async function del() {
<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>
</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>
</template>
</Modal>

View File

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

View File

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

View File

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

View File

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

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.
func (h AuthenticationHandler) UserIdMatch(idParameter string) 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"`
ApiAdminOnly bool `json:"ApiAdminOnly"`
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)
}
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

View File

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

View File

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

View File

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