diff --git a/.gitignore b/.gitignore index d843f34f..d97cf896 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ dist/ key.gpg mokey-*.tar.gz *.swp +# Local full template override directories (brand-specific; not published upstream) +*-templates/ +*-templates-*/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a75c965d..9f4365c5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,44 +1,36 @@ -# Mokey goreleaser configs -# See here: https://goreleaser.com +version: 2 +project_name: mokey + before: hooks: - go mod tidy + builds: - - env: + - id: mokey + env: - CGO_ENABLED=1 goarch: - amd64 goos: - linux ldflags: - - -s -w -X github.com/ubccr/mokey/server.Version={{.Version}} + - -s -w -X github.com/tubby1981/mokey/server.Version={{.Version}} - -extldflags=-static tags: - sqlite_omit_load_extension - osusergo - - netgo -archives: - - replacements: - linux: linux - amd64: x86_64 - wrap_in_directory: true - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" - files: - - LICENSE - - NOTICE - - README.md - - ChangeLog.md - - mokey.toml.sample + nfpms: - - vendor: University at Buffalo - homepage: https://github.com/ubccr/mokey - maintainer: Andrew E. Bruno - license: MIT - description: |- - FreeIPA self-service account management tool + - id: mokey formats: - deb - rpm + maintainer: Andrew E. Bruno + vendor: University at Buffalo + homepage: https://github.com/tubby1981/mokey + license: MIT + description: | + FreeIPA self-service account management tool overrides: deb: file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}" @@ -48,25 +40,30 @@ nfpms: file_name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Arch }}" scripts: postinstall: ./scripts/nfpm/postinstall.sh - rpm: - signature: - key_file: key.gpg - deb: - signature: - key_file: key.gpg + #rpm: + # signature: + # key_file: key.gpg + #deb: + # signature: + # key_file: key.gpg contents: - src: ./scripts/nfpm/mokey.toml.default dst: /etc/mokey/mokey.toml - type: "config|noreplace" + type: config|noreplace - src: ./scripts/nfpm/mokey.env dst: /etc/default/mokey - type: "config|noreplace" + - src: ./scripts/nfpm/translations/* + dst: /etc/mokey/translations/ + file_info: + owner: mokey + group: mokey - src: ./scripts/nfpm/mokey.service dst: /usr/lib/systemd/system/mokey.service + checksum: name_template: 'checksums.txt' snapshot: - name_template: "{{ incpatch .Version }}-SNAPSHOT-{{.ShortCommit}}" + version_template: "{{ incpatch .Version }}-SNAPSHOT-{{.ShortCommit}}" changelog: sort: desc groups: @@ -81,4 +78,4 @@ changelog: filters: exclude: - '^docs:' - - 'typo' + - 'typo' diff --git a/ChangeLog.md b/ChangeLog.md index f988a06c..b3465795 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,74 @@ # Mokey ChangeLog +## [Unreleased] + +## [v0.6.14] - 2026-06-30 + +### Login / password managers +- Fix login loop when browsers or password managers autofill credentials (HTMX now reads input values from the DOM) +- Use standard `autocomplete` attributes and input types on login and forgot-password forms +- Use readonly instead of disabled for the username field on the password step so password managers recognize the form +- Accept one-step login when password managers submit username and password together on `/auth/login` +- Route login forms through HTMX even when password managers call `form.submit()` directly +- Strip credentials from URL query strings on auth routes (prevents passwords in access logs) +- Accept CSRF token from hidden form field (not only `X-CSRF-Token` header) +- Add optional `server.auth_debug` logging for login flow troubleshooting +- Pass reset form path to password-reset template for HTMX form posts +- Show forgot-password link on the main login page + +## [v0.6.13] - 2026-06-08 + +### Password reset +- Fix password reset from email links so users can log in immediately after choosing a new password +- Prefer `ResetPassword` + self-service `change_password` over admin `passwd` (admin-set passwords are marked expired by FreeIPA) +- Fall back to admin `passwd` when self-service reset fails; refresh `krbPasswordExpiration` from the user's effective password policy +- Use `pwpolicy_show` / `pwpolicy_find` for max lifetime, with `accounts.password_max_life_days` as config fallback +- Return accurate error messages on password reset (no longer always "Invalid OTP" when 2FA is disabled) +- Show the OTP field on password reset when the user has MFA enabled (`UserHasOTP`), not only for OTP-only accounts +- Add `password_reset.failed` translation for generic reset failures + +### Password policy validation +- Enforce per-user FreeIPA password policy (min length, character classes) on password change, reset, and signup +- `min_passwd_len` and `min_passwd_classes` in `mokey.toml` are fallbacks when the service account cannot read IPA policies + +### FreeIPA / goipa +- Vendor goipa locally with `AdminSetPassword`, `ResetUserPassword`, `PasswordPolicyForUser`, and omit empty OTP from `SetPassword` requests + +### Other +- Add explicit `font-src 'self'` to Content-Security-Policy headers +- Update setup documentation: `Password Policy Readers` privilege, keytab verification, and config fallbacks + +## [v0.6.12] - 2026-06-04 + +- Remove organization-specific strings from default translations and email logo defaults + +## [v0.6.11] - 2026-06-04 + +- Redirect expired passwords to forgot-password flow (`/auth/forgotpw?expired=1`) instead of a dedicated expired-password page; remove `login-password-expired.html` +- Add HTTP 408 request timeout error pages (`408.html`, `408-partial.html`) +- Improve error handling: shared `renderErrorPage` with 500 fallback, fix error logger username context key +- Reduce noisy 502/error logs for benign client disconnects (`errors.go`) +- Make favicon middleware optional when `site.favicon` or bundled `images/favicon.ico` is missing +- Extract `userHasOTP` template helper to `userauth.go` +- Add `email.logo_path` to `mokey.toml.sample` +- Add translations for forgot-password expired redirect message +- Use generic default email branding paths and translation strings (no organization-specific names in upstream) + +## [v0.6.10] - 2026-06-03 + +- Add account portal fields: Department (read-only), Job Title, Telephone Number (read-only), and Mobile Telephone Number (editable) +- Use tubby1981/goipa v0.0.8 for Org Unit and Job Title support in FreeIPA +- Ship translation files as dutch.toml and english.toml in packages +- Remove unused create_templates.py script + +## [v0.6.7] - 2025-04-17 +- Added `timeago` function with support for English, Portuguese, Spanish, Dutch, French, German, and Turkish. Based on [xeonx/timeago](https://github.com/xeonx/timeago). +- Some translations in the email templates were not working properly. This has been fixed. + +## [v0.6.6] - 2025-04-16 +- Add config option to hide registratrion link: enable_user_signup true/false +- Add support for multiple languages with configurable translations. See README + ## [v0.6.5] - 2024-10-28 - Update fiber, htmx (v2.0.3), hyperscript (v0.9.13) diff --git a/README.md b/README.md index 83b2b007..cff763f4 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Note: mokey needs to be installed on a machine already enrolled in FreeIPA. It's also recommended to have the ipa-admintools package installed. Enrolling a host in FreeIPA is outside the scope of this document. -To install mokey download a copy of the pre-compiled binary [here](https://github.com/ubccr/mokey/releases). +To install mokey download a copy of the pre-compiled binary [here](https://github.com/tubby1981/mokey/releases). tar.gz archive: @@ -61,17 +61,40 @@ $ sudo rpm -ivh mokey-VERSION-amd64.rpm ## Setup and configuration -Create a user account and role in FreeIPA with the "Modify users and Reset -passwords" privilege. This user account will be used by the mokey application -to reset users passwords. The "Modify Users" permission also needs to have the -"ipauserauthtype" enabled. Run the following commands (requires ipa-admintools -to be installed): +Create a dedicated service account in FreeIPA for mokey. All FreeIPA API calls +run as this account using a keytab (`site.ktuser` / `site.keytab` in +`mokey.toml`). + +### Required FreeIPA privileges + +The service account needs privileges to manage users and passwords. The +`User Administrators` privilege covers most day-to-day operations (user lookup, +password reset, MFA, SSH keys, and so on). + +For password reset via email, mokey also needs to read password policies so it +can set `krbPasswordExpiration` correctly after an admin password change. Add +the **Password Policy Readers** privilege. Without it, `ipa pwpolicy-show` +fails for the service account and mokey falls back to +`accounts.password_max_life_days` in `mokey.toml`. + +| Privilege | Purpose | +|-----------|---------| +| User Administrators | User management, password reset, MFA, SSH keys | +| Password Policy Readers | Read effective password policy (`maxlife`) per user | + +The **System: Modify Users** permission must include the `ipauserauthtype` +attribute (required for MFA enable/disable). + +### FreeIPA setup + +Run the following on an IPA server (requires `ipa-admintools`): ``` $ mkdir /etc/mokey/private $ kinit adminuser -$ ipa role-add 'Mokey User Manager' --desc='Mokey User management' -$ ipa role-add-privilege 'Mokey User Manager' --privilege='User Administrators' +$ ipa role-add 'Mokey User Manager' --desc='Mokey user management' +$ ipa role-add-privilege 'Mokey User Manager' --privileges='User Administrators' +$ ipa role-add-privilege 'Mokey User Manager' --privileges='Password Policy Readers' $ ipa user-add mokeyapp --first Mokey --last App $ ipa role-add-member 'Mokey User Manager' --users=mokeyapp $ ipa permission-mod 'System: Modify Users' --includedattrs=ipauserauthtype @@ -80,22 +103,50 @@ $ chmod 640 /etc/mokey/private/mokeyapp.keytab $ chgrp mokey /etc/mokey/private/mokeyapp.keytab ``` -Edit mokey configuration file and set path to keytab file. The values for -`token_secret` and `csrf_secret` will be automatically generated for you if -left blank. Set these secret values if you'd like sessions to persist after a restart. -For other site specific config options [see here](https://github.com/ubccr/mokey/blob/main/mokey.toml.sample): +Verify the service account can read password policies: + +``` +$ kinit -kt /etc/mokey/private/mokeyapp.keytab mokeyapp +$ ipa pwpolicy-show --user=someuser +$ kdestroy +``` + +If `pwpolicy-show` returns *password policy not found*, the role is missing +**Password Policy Readers** or the user is not a member of the role. As a +fallback, set `password_max_life_days` in `mokey.toml` to match your IPA policy +(for example `183`). + +### mokey configuration + +Edit `/etc/mokey/mokey.toml`. Set the keytab path and service account name. The +values for `token_secret` and `csrf_secret` are generated automatically if left +blank. Set them explicitly if you want sessions and tokens to persist across +restarts. For all options see +[mokey.toml.sample](https://github.com/tubby1981/mokey/blob/main/mokey.toml.sample). ``` $ vim /etc/mokey/mokey.toml -# Path to keytab file + +[site] +# User account for the mokey service (must match keytab principal) +ktuser = "mokeyapp" keytab = "/etc/mokey/private/mokeyapp.keytab" # Secret key for branca tokens. Must be 32 bytes. To generate run: -# openssl rand -hex 32 +# openssl rand -hex 32 token_secret = "" # CSRF token secret key. Should be a random string csrf_secret = "" + +[accounts] +# Fallback when pwpolicy_show is unavailable to the service account. +# Per-user IPA policy is used automatically when mokeyapp can read it. +password_max_life_days = 183 + +# Fallback password validation limits when IPA policy cannot be read. +# min_passwd_len and min_passwd_classes in mokey.toml are used only as fallback; +# effective Min length and Character classes come from each user's IPA policy. ``` It's highly recommended to run mokey using HTTPS. You'll need an SSL @@ -139,12 +190,39 @@ Any OAuth clients configured in Hydra will be authenticated via mokey using FreeIPA as the identity provider. For an example OAuth 2.0/OIDC client application see [here](examples/mokey-oidc/main.go). +## Translations + +mokey supports multiple languages for its interface and email templates. Default are English and Dutch supported. + +### Configuring Translations + +1. **Place translation files** + Translation files should be placed in `/etc/mokey/translations/`. + For example: + - `english.toml` for English translations + - `dutch.toml` for Dutch translations + +2. **Update the configuration file** + Add the following options to `/etc/mokey/mokey.toml`: + ```toml + # Default language for the site + # Languages supported: English (english), Dutch (dutch) + # Default is english + default_language = "english" + + # Directory where translations can be placed + translations_dir = "/etc/mokey/translations" + ``` + +3. **Create custom translations** + Users can translate mokey into their own language by creating a new .toml file in the translations_dir and referencing it in the default_language configuration. This allows for complete customization of the interface and email templates in the preferred language. + ## Building from source First, you will need Go v1.21 or greater. Clone the repository: ``` -$ git clone https://github.com/ubccr/mokey +$ git clone https://github.com/tubby1981/mokey $ cd mokey $ go build . ``` diff --git a/a.out b/a.out new file mode 100755 index 00000000..e7d15643 Binary files /dev/null and b/a.out differ diff --git a/cmd/root.go b/cmd/root.go index 64a4bba0..bc082ee2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,7 +10,7 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/ubccr/mokey/server" + "github.com/tubby1981/mokey/server" ) var ( diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go index 9cd459b0..b4caa6f1 100644 --- a/cmd/serve/serve.go +++ b/cmd/serve/serve.go @@ -9,8 +9,8 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/ubccr/mokey/cmd" - "github.com/ubccr/mokey/server" + "github.com/tubby1981/mokey/cmd" + "github.com/tubby1981/mokey/server" ) var ( diff --git a/go.mod b/go.mod index a154944b..75d34093 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ -module github.com/ubccr/mokey +// module github.com/ubccr/mokey +// replace github.com/ubccr/mokey => /opt/tromp/mokey +module github.com/tubby1981/mokey require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/coreos/go-oidc v2.2.1+incompatible github.com/dchest/captcha v1.0.0 - github.com/dustin/go-humanize v1.0.1 github.com/essentialkaos/branca/v2 v2.0.5 github.com/gofiber/fiber/v2 v2.52.5 github.com/gofiber/storage/memory/v2 v2.0.1 @@ -20,13 +21,15 @@ require ( github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 - github.com/ubccr/goipa v0.0.7 + github.com/ubccr/goipa v0.0.8 github.com/urfave/negroni v1.0.0 github.com/valyala/fasthttp v1.56.0 golang.org/x/net v0.29.0 golang.org/x/oauth2 v0.18.0 ) +require github.com/dustin/go-humanize v1.0.1 + require ( github.com/andybalholm/brotli v1.1.1 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -111,4 +114,6 @@ require ( go 1.22.0 +replace github.com/ubccr/goipa => ./third_party/goipa + toolchain go1.23.2 diff --git a/go.sum b/go.sum index eace0691..8461a161 100644 --- a/go.sum +++ b/go.sum @@ -208,8 +208,6 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tinylib/msgp v1.1.9 h1:SHf3yoO2sGA0veCJeCBYLHuttAVFHGm2RHgNodW7wQU= github.com/tinylib/msgp v1.1.9/go.mod h1:BCXGB54lDD8qUEPmiG0cQQUANC4IUQyB2ItS2UDlO/k= -github.com/ubccr/goipa v0.0.7 h1:hw61EMYMUMwmX/gahcIffV5n8mam54nQu7W2tETjjsw= -github.com/ubccr/goipa v0.0.7/go.mod h1:BWj8rFYwKbb9aWCQ9Y0UK6CLJJWyw0UA9C7hHLXd4Nc= github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/main.go b/main.go index 99f10641..cc9c61a6 100644 --- a/main.go +++ b/main.go @@ -5,8 +5,8 @@ package main import ( - "github.com/ubccr/mokey/cmd" - _ "github.com/ubccr/mokey/cmd/serve" + "github.com/tubby1981/mokey/cmd" + _ "github.com/tubby1981/mokey/cmd/serve" ) func main() { diff --git a/mokey.toml.sample b/mokey.toml.sample index d1003475..72b9d24f 100644 --- a/mokey.toml.sample +++ b/mokey.toml.sample @@ -47,6 +47,14 @@ keytab = "/etc/mokey/private/mokeyapp.keytab" # Path to logo # logo = "/etc/mokey/assets/my-logo.png" +# Default language for the site +# Languages supported: English (english), Dutch (dutch) +# Default is english +default_language = "english" + +# Directory where translations can be placed +translations_dir = "/etc/mokey/translations" + #------------------------------------------------------------------------------ # User account settings #------------------------------------------------------------------------------ @@ -57,15 +65,21 @@ default_homedir = "/home" # Default login shell default_shell = "/bin/bash" -# Minimum password length. Used for validating new passwords. Should match your -# password policy set in FreeIPA +# Minimum password length fallback. When mokeyapp can read the user's FreeIPA +# password policy (pwpolicy_show), the per-user Min length value is used instead. min_passwd_len = 8 -# Minimum password classes. Classes are lowercase, uppercase, numbers, and -# special characters. Used for validating new passwords. Should match your -# password policy set in FreeIPA +# Minimum password classes fallback (lowercase, uppercase, numbers, special). +# When mokeyapp can read the user's FreeIPA password policy, the per-user +# Character classes value is used instead. min_passwd_classes = 2 +# Fallback max password lifetime in days for krbPasswordExpiration after admin +# password reset, when FreeIPA pwpolicy_show is unavailable to the service +# account. Set this to match your IPA password policy (e.g. 183). Optional if +# mokeyapp has the "System: Read Password Policies" privilege. +# password_max_life_days = 183 + # Hash algorithm for generating OTP tokens: sha1, sha256, or sha512 otp_hash_algorithm = "sha1" @@ -101,6 +115,9 @@ require_admin_verify = false # you could hide this error message by setting this to true. hide_invalid_username_error = false +# Enable or disable user signup link on the login page +enable_user_signup = true + #------------------------------------------------------------------------------ # Email #------------------------------------------------------------------------------ @@ -110,6 +127,10 @@ hide_invalid_username_error = false # to hard code the base_url. # base_url = "https://localhost" +# PNG logo embedded in outgoing HTML emails (optional). +# Default: {site.templates_dir}/static/images/logo.png +# logo_path = "/etc/mokey/assets/images/logo.png" + # Max lifetime of branca tokens used for password resets and account verify token_max_age = 3600 @@ -146,6 +167,10 @@ listen = "0.0.0.0:8866" # Times out the session after inactivity (in seconds) session_idle_timeout = 900 +# Log login flow steps (session cookie id, redirects, form fields) for troubleshooting. +# Never logs password values. Also warns when credentials appear in URL query strings. +# auth_debug = true + # Path to ssl certificate # ssl_cert = "" @@ -159,8 +184,9 @@ secure_cookies = true csrf_secret = "" # Timeouts -read_timeout = 5 -write_timeout = 5 +# Seconds; increase if FreeIPA or Redis session storage is slow (low values cause 502 errors) +read_timeout = 30 +write_timeout = 30 idle_timeout = 120 # Rate limiter diff --git a/scripts/nfpm/mokey.toml.default b/scripts/nfpm/mokey.toml.default index 41967a05..6cd94b4a 100644 --- a/scripts/nfpm/mokey.toml.default +++ b/scripts/nfpm/mokey.toml.default @@ -27,6 +27,14 @@ ktuser = "mokeyapp" # Path to keytab file keytab = "/etc/mokey/private/mokeyapp.keytab" +# Default language for the site +# Languages supported: English (english), Dutch (dutch) +# Default is english +default_language = "english" + +# Directory where translations can be placed +translations_dir = "/etc/mokey/translations" + #------------------------------------------------------------------------------ # User account settings #------------------------------------------------------------------------------ @@ -39,13 +47,16 @@ default_shell = "/bin/bash" # Minimum password length. Used for validating new passwords. Should match your # password policy set in FreeIPA +# Minimum password length fallback when IPA password policy cannot be read. min_passwd_len = 8 -# Minimum password classes. Classes are lowercase, uppercase, numbers, and -# special characters. Used for validating new passwords. Should match your -# password policy set in FreeIPA +# Minimum password classes fallback when IPA password policy cannot be read. min_passwd_classes = 2 +# Fallback max password lifetime in days for krbPasswordExpiration after admin +# password reset, when FreeIPA pwpolicy_show is unavailable to the service account. +# password_max_life_days = 183 + # Hash algorithm for generating OTP tokens: sha1, sha256, or sha512 otp_hash_algorithm = "sha1" @@ -55,6 +66,9 @@ otp_issuer = "MYORG" # Block list of user accounts from logging in # block_users = ["username1", "username2", "username3"] +# Enable or disable user signup link on the login page +enable_user_signup = true + #------------------------------------------------------------------------------ # Email #------------------------------------------------------------------------------ diff --git a/scripts/nfpm/postinstall.sh b/scripts/nfpm/postinstall.sh index bae8c470..905efb70 100755 --- a/scripts/nfpm/postinstall.sh +++ b/scripts/nfpm/postinstall.sh @@ -1,6 +1,9 @@ #!/bin/sh +#!/bin/sh + cleanInstall() { + # Create mokey system user and group if they don't exist if ! getent passwd mokey > /dev/null; then printf "\033[32m Creating mokey system user & group\033[0m\n" groupadd -r mokey @@ -8,15 +11,29 @@ cleanInstall() { -c 'Mokey server' mokey fi + # Set up data directory permissions mkdir -p /var/lib/mokey chown mokey:mokey /var/lib/mokey chmod 755 /var/lib/mokey + # Set up configuration file permissions if it exists if [ -f "/etc/mokey/mokey.toml" ]; then chmod 640 /etc/mokey/mokey.toml chown mokey:mokey /etc/mokey/mokey.toml fi + # Grant the binary permission to bind to privileged ports (like 443) + # This allows running the service as the non-root 'mokey' user. + if [ -f "/usr/bin/mokey" ]; then + if command -v setcap > /dev/null; then + printf "\033[32m Setting network capabilities for mokey binary\033[0m\n" + setcap 'cap_net_bind_service=+ep' /usr/bin/mokey + else + printf "\033[31m Warning: setcap not found. Mokey may fail to bind to port 443.\033[0m\n" + fi + fi + + # Handle systemd integration for Debian-based or standard systemd systems if [ -x "/usr/bin/deb-systemd-helper" ]; then deb-systemd-helper purge mokey.service >/dev/null deb-systemd-helper unmask mokey.service >/dev/null @@ -28,6 +45,45 @@ cleanInstall() { fi } +upgrade() { + printf "\033[32m Upgrading mokey\033[0m\n" + + # Re-apply capabilities during upgrade as the binary is usually replaced + if [ -f "/usr/bin/mokey" ] && command -v setcap > /dev/null; then + setcap 'cap_net_bind_service=+ep' /usr/bin/mokey + fi + + if [ -x "/usr/bin/systemctl" ]; then + systemctl restart mokey.service ||: + fi +} + +# Check if this is a clean install or an upgrade based on arguments +action="$1" +if [ "$1" = "configure" ] && [ -z "$2" ]; then + # Alpine linux does not pass args, and deb passes $1=configure + action="install" +elif [ "$1" = "configure" ] && [ -n "$2" ]; then + # deb passes $1=configure $2= + action="upgrade" +fi + +case "$action" in + "1" | "install") + cleanInstall + ;; + "2" | "upgrade") + upgrade + ;; + *) + # Default case, often used for Alpine or direct script execution + printf "\033[32m Executing default installation\033[0m\n" + cleanInstall + ;; +esac + +exit 0 + upgrade() { printf "\033[32m Upgrading mokey\033[0m\n" if [ -x "/usr/bin/systemctl" ]; then diff --git a/scripts/nfpm/translations/dutch.toml b/scripts/nfpm/translations/dutch.toml new file mode 100644 index 00000000..c8c45aca --- /dev/null +++ b/scripts/nfpm/translations/dutch.toml @@ -0,0 +1,280 @@ +[common] +add = "Toevoegen" +cancel = "Annuleren" +captcha_image = "Captcha-afbeelding" +captcha_instruction = "Voer de cijfers in die u in de afbeelding hieronder ziet:" +check_email = "Controleer uw e-mail voor verdere instructies." +close = "Sluiten" +confirm_password = "Bevestig wachtwoord" +current_password = "Huidig wachtwoord" +delete = "Verwijderen" +disable = "Uitschakelen" +disabled = "Uitgeschakeld" +email = "E-mail" +enable = "Inschakelen" +enabled = "Ingeschakeld" +first_name = "Voornaam" +last_name = "Achternaam" +login = "Inloggen" +must_enable_2fa = "Je moet Two-Factor authenticatie inschakelen voor je account." +new_password = "Nieuw wachtwoord" +otp_code = "OTP Code" +password = "Wachtwoord" +reload = "Herlaad" +submit = "Verzenden" +update = "Bijwerken" +username = "Gebruikersnaam" +verify = "Verifiëren" +verify_account = "Verifieer Account" +too_many_requests = "Te veel verzoeken. Probeer het later opnieuw." + +[account] +title = "Accountinstellingen" +groups = "Groepen" +home_dir = "Home Dir" +last_password_change = "Laatste wachtwoordwijziging" +never = "Nooit" +password_expires = "Wachtwoord verloopt" +org_unit = "Afdeling" +job_title = "Functietitel" +telephone_number = "Telefoonnummer" +mobile_number = "Mobiel telefoonnummer" +phone_number = "Telefoonnummer" +settings = "Accountinstellingen" +settings_updated = "Accountinstellingen succesvol bijgewerkt" +verify_email_sent = "Een verificatie-e-mail is verzonden." +fatal_system_error = "Fatale systeemfout" +invalid_credentials = "Ongeldige inloggegevens" +invalid_username = "Ongeldige gebruikersnaam" +please_provide_password = "Voer een wachtwoord in" +please_provide_username = "Voer een gebruikersnaam in" +user_account_is_locked = "Gebruikersaccount is geblokkeerd" +you_must_enable_two_factor_authentication_first = "Schakel eerst tweefactorauthenticatie in!" +failed_to_verify_account = "Accountverificatie mislukt. Neem contact op met de beheerder." +system_error = "Systeemfout, neem contact op met de beheerder" +weak_password = "Je wachtwoord is te zwak. Zorg ervoor dat je wachtwoord een cijfer en zowel kleine als hoofdletters bevat" + +[email_template] +account_updated_preheader = "Uw account is bijgewerkt." +account_updated_greeting = "Beste " +account_updated_body_part1 = "Je hebt recentelijk je " +account_updated_body_part2 = " account bijgewerkt. Ter referentie, hier is wat er is veranderd:" +account_updated_security_notice_part1 = "Om veiligheidsredenen is dit verzoek ontvangen vanaf een " +account_updated_security_notice_part2 = " apparaat met " +account_updated_security_notice_part3 = ". Als je geen account hebt aangemaakt, negeer dan deze e-mail en " +account_updated_security_notice_part4 = "neem contact op met support " +account_updated_security_notice_part5 = " of bekijk onze " +account_updated_security_notice_help = "documentatie" +account_updated_security_notice_part6 = " als je vragen hebt." +account_updated_security_notice_contact = "neem contact op met de support " +account_updated_signature = "Bedankt," +account_verify_preheader = "Gebruik deze link om uw account te verifiëren. De link is alleen geldig voor" +account_verify_greeting = "Beste" +account_verify_body_part1 = "Je hebt recentelijk een account aangemaakt bij " +account_verify_body_part2 = " en je MOET je e-mail verifiëren voordat je je account kunt gebruiken. " +account_verify_body_part3 = "Gebruik de onderstaande knop om je e-mailadres te verifiëren. " +account_verify_body_part4 = "Deze verificatie is geldig voor de komende " +account_verify_button = "Verifieer uw account" +account_verify_login_info_intro = "Ter referentie, hier zijn uw inloggegevens:" +account_verify_login_page = "Inlogpagina" +account_verify_username = "Gebruikersnaam" +account_verify_signature_part1 = "Bedankt, Het " +account_verify_signature_part2 = " team" +account_verify_trouble_link = "Als u problemen ondervindt met de bovenstaande knop, kopieer en plak de URL dan in uw webbrowser." +password_reset_preheader = "Gebruik deze link om je wachtwoord opnieuw in te stellen. De link is alleen geldig voor" +password_reset_greeting = "Hallo" +password_reset_intro_part1 = "Je hebt onlangs verzocht om je wachtwoord voor je " +password_reset_intro_part2 = " account opnieuw in te stellen. Gebruik de knop hieronder om het opnieuw in te stellen. " +password_reset_intro_part3 = "Deze link is alleen geldig voor de komende " +password_reset_button = "Stel je wachtwoord opnieuw in" +password_reset_trouble = "Als je problemen hebt met de knop hierboven, kopieer en plak dan de onderstaande URL in je webbrowser." +closing_part1 = "Bedankt,\nHet " +closing_part2 = " team" +welcome_preheader_part1 = "Bedankt voor het aanmaken van een account bij " +welcome_preheader_part2 = ". We hebben wat informatie en bronnen verzameld om je op weg te helpen." +welcome_greeting = "Welkom," +welcome_intro_part1 = "Bedankt voor het aanmaken van een account bij " +welcome_intro_part2 = ". We zijn blij dat je er bent." +welcome_button = "Aan de slag" +welcome_note_part1 = "Ter referentie, hier zijn je inloggegevens:\nLoginpagina: " +welcome_note_part2 = "\nGebruikersnaam: " +password_reset_subject = "Stel uw wachtwoord opnieuw in" +account_verify_subject = "Verifieer uw e-mailadres" +account_updated_subject = "Uw wachtwoord is gewijzigd" +welcome_subject = "Welkom bij " +two_factor_auth_event = "Twee-factor-authenticatie " +ssh_key_event = "SSH-sleutel " +otp_token_event = "OTP-token " +password_changed_event = "Wachtwoord gewijzigd" +ssh_key_added = "toegevoegd" +ssh_key_removed = "verwijderd" +otp_token_added = "toegevoegd" +otp_token_removed = "verwijderd" +two_factor_auth_enabled = "Ingeschakeld" +two_factor_auth_disabled = "Uitgeschakeld" +footer_questions = "Heb je vragen?" +footer_contact = "Neem dan contact op met de sitebeheerder:" +footer_sent_to = "Deze e-mail is automatisch verstuurd naar" +password_reset_title = "Wachtwoord opnieuw instellen" +account_updated_title = "Account bijgewerkt" +account_verify_title = "Account verifiëren" +welcome_title = "Welkom" +account_verify_intro_part1 = "Je hebt recentelijk een account aangemaakt bij " +account_verify_intro_part2 = " en je MOET je e-mail verifiëren voordat je je account kunt gebruiken. Gebruik de onderstaande knop om je e-mailadres te verifiëren. Deze verificatie is geldig voor de komende " + +[error] +title = "Foutmeldingen" +access_denied = "Toegang geweigerd" +bad_gateway = "De server heeft een foutieve gatewayfout ondervonden. Probeer het later opnieuw of neem contact op met de sitebeheerder." +log_in_again = "inloggen opnieuw" +no_access_to_resource = "Je hebt geen toegang tot deze bron" +session_timed_out = "Je sessie is verlopen. Probeer het opnieuw" +request_timeout = "Het verzoek duurde te lang. Probeer het opnieuw." +something_bad_happened = "Er is iets mis gegaan" +sorry_page_not_found = "Sorry, de pagina die je hebt aangevraagd is niet gevonden" +system_trouble = "We hebben momenteel wat systeemproblemen. Als dit probleem aanhoudt, neem dan contact op met de sitebeheerder." + +[hydra] +consent_without_challenge = "Toestemming zonder uitdaging" +failed_to_validate_consent = "Mislukt bij het valideren van toestemming" +access_denied = "Toegang geweigerd" +failed_to_accept_consent = "Mislukt bij het accepteren van toestemming" +login_without_challenge = "Inloggen zonder uitdaging" +failed_to_validate_login = "Mislukt bij het valideren van inloggen" +failed_to_accept_login = "Mislukt bij het accepteren van inloggen" +oauth2_error = "OAuth2 Fout" + +[login] +title = "Inloggen" +submit_button = "Nu inloggen" +create_account = "Account aanmaken" +forgot_password = "Wachtwoord vergeten?" +new_user = "Nieuwe gebruiker?" +next_button = "Volgende" +not_you = "Niet jij?" +otp_authentication = "Twee-Factor Authenticatie" +switch_account = "Account wisselen" +login_failed = "Inloggen mislukt" + +[otptoken] +title = "OTP-tokens" +6_digit_code_label = "6-cijferige code" +add_new_title = "Nieuw TOTP-token toevoegen" +add_token_prompt = "Voeg een OTP-token toe via uw authenticator-app om Twee-Factor-authenticatie op uw account in te schakelen." +added_on = "Toegevoegd op" +adding_token = "Token toevoegen..." +click_to_disable = "Klik om uit te schakelen" +click_to_enable = "Klik om in te schakelen" +delete_prompt_title = "Token verwijderen?" +delete_warning = "Deze actie kan NIET ongedaan worden gemaakt. Dit zal het token permanent verwijderen en u kunt het in de toekomst niet meer gebruiken." +disable_prompt_title = "Token uitschakelen?" +disabled = "Uitgeschakeld" +enable_prompt_title = "Token inschakelen?" +enter_6_digit_code_help = "Voer de 6-cijferige code in uit uw mobiele app" +invalid_6_digit_code = "Ongeldige 6-cijferige code. Probeer het opnieuw." +failed_to_verify_token = "Verificatie van token mislukt." +header = "OTP Tokens" +no_tokens_found = "Geen OTP-tokens gevonden" +new_token_button = "Nieuw Token" +qr_code_alt = "Scan QR-code" +scan_qr_title = "Scan QR-code met authenticator-app" +show_uri = "Toon URI" +token_description_help = "Voer de beschrijving van het token in (bijvoorbeeld het apparaat waarvoor het wordt gebruikt). Klik vervolgens op de knop Toevoegen hieronder om het nieuwe TOTP-token te verifiëren. De QR-code verschijnt op het volgende scherm. Zorg ervoor dat u deze scant met uw authenticator-app en voer de 6-cijferige code in om te verifiëren." +token_description_label = "Tokenbeschrijving" +token_description_placeholder = "Mijn telefoon" +invalid_otp = "Ongeldige OTP-code." + +[password] +enter_new = "Voer een nieuw wachtwoord in" +confirm_new = "Bevestig je nieuwe wachtwoord" +mismatch = "Wachtwoorden komen niet overeen. Bevestig je wachtwoord." +enter_current = "Voer je huidige wachtwoord in" +same_as_new = "Het huidige wachtwoord is hetzelfde als het nieuwe wachtwoord. Kies een ander wachtwoord." +min_length = "Wachtwoord voldoet niet aan het beleid. Minimumlengte: %d" +policy_not_met = "Wachtwoord voldoet niet aan het beleid. Probeer zowel hoofd‑/kleine letters, cijfers en speciale tekens te gebruiken." + +[password_change] +title = "Wachtwoord Wijzigen" +confirm_new_password = "Bevestig nieuw wachtwoord" +otp_help = "Voer de zes-cijferige code in uit je mobiele app" +success = "Wachtwoord succesvol bijgewerkt" + +[password_expired] +title = "Wachtwoord Verlopen" +change_password_button = "Wachtwoord Wijzigen" +help = "Je bent ingelogd met je verlopen wachtwoord. Kies hieronder een nieuw wachtwoord (minimaal 16 tekens volgens het beleid)." +otp_help = "Je bent al ingelogd met je verlopen wachtwoord. Kies een nieuw wachtwoord en voer een nieuwe OTP-code in van je authenticator-app." + +[password_forgot] +title = "Wachtwoord vergeten" +expired_redirect = "Je wachtwoord is verlopen. Vul hieronder je gebruikersnaam in en bevestig met de captcha om een e-mail te ontvangen waarmee je een nieuw wachtwoord kunt instellen." +email_sent = "Er is een e-mail voor het opnieuw instellen van uw wachtwoord verstuurd." + +[password_reset] +title = "Wachtwoord Herstellen" +reset_button = "Wachtwoord Herstellen" +success_message = "Je wachtwoord is succesvol hersteld" +failed = "Het wachtwoord kon niet worden gewijzigd. Controleer of het voldoet aan het wachtwoordbeleid en probeer het opnieuw." + +[profile] +title = "Profielinstellingen" +account = "Account" +avatar_alt = "Gebruikersavatar" +logout = "Uitloggen" +otp_tokens = "OTP Tokens" +security = "Beveiliging" +ssh_keys = "SSH-sleutels" + +[security] +title = "Beveiligingsinstellingen" +authentication_methods = "Authenticatiemethoden" +confirm_disable = "Uitschakelen" +confirm_enable = "Inschakelen" +disable = "Klik om uit te schakelen" +disable_message = "Dit zal de Two-Factor authenticatie uitschakelen. Weet je zeker dat je dit wilt doen?" +disable_title = "Two-Factor authenticatie uitschakelen?" +enable = "Klik om in te schakelen" +enable_message = "Dit zal de Two-Factor authenticatie inschakelen. Weet je zeker dat je dit wilt doen?" +enable_title = "Two-Factor authenticatie inschakelen?" +two_factor_authentication = "Two-Factor authenticatie" + +[signup] +title = "Aanmelden" +already_have_account = "Heb je al een account?" +allowed_domains = "Toegestane domeinen" +create_account = "Account maken" +tos_link = "Algemene voorwaarden" +tos_message = "Door op 'Account maken' te klikken ga je akkoord met onze" + +[signup_success] +title = "Bevestig je account" +account_created = "Account succesvol aangemaakt" +admin_verification = "Een beheerder moet je account activeren voordat je het kunt gebruiken." +email_verification = "Je moet je e-mailadres verifiëren om je account te activeren." + +[sshkey_list] +title = "SSH Sleutels" +delete_key = "Sleutel Verwijderen?" +delete_warning = "Deze actie KAN NIET worden teruggedraaid. Dit zal de sleutel permanent verwijderen en je kunt deze in de toekomst niet meer gebruiken" +enable_mfa = "Je moet Two-Factor authenticatie inschakelen voordat je SSH-sleutels kunt toevoegen!" +new_key = "Nieuwe SSH Sleutel" +no_keys = "Geen SSH sleutels geüpload" +type = "Type" +please_provide_sshkey = "Voer een SSH-sleutel in" +invalid_sshkey = "Ongeldige SSH-sleutel" + +[sshkey_new] +title = "SSH Sleutels Toevoegen" +add_title = "Nieuwe SSH Sleutel Toevoegen" +adding = "SSH-sleutel toevoegen..." +key_help = "Plak hierboven de inhoud van de SSH publieke sleutel. Moet beginnen met 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', of 'sk-ssh-ed25519@openssh.com'" +public_key = "Publieke Sleutel" + +[verify_account] +title = "Account Verifiëren" +instruction = "Klik hieronder op verifiëren om je account in te stellen." +message = "Verifieer je e-mail" + +[verify_success] +title = "Account Verifiëren" +message = "Je account is succesvol geverifieerd. Dank je wel" diff --git a/scripts/nfpm/translations/english.toml b/scripts/nfpm/translations/english.toml new file mode 100644 index 00000000..d5ab1e8c --- /dev/null +++ b/scripts/nfpm/translations/english.toml @@ -0,0 +1,280 @@ +[common] +add = "Add" +cancel = "Cancel" +captcha_image = "Captcha Image" +captcha_instruction = "Enter the numbers you see in the image below:" +check_email = "Check your email for further instructions." +close = "Close" +confirm_password = "Confirm Password" +current_password = "Current Password" +delete = "Delete" +disable = "Disable" +disabled = "Disabled" +email = "Email" +enable = "Enable" +enabled = "Enabled" +first_name = "First Name" +last_name = "Last Name" +login = "Login" +must_enable_2fa = "You must enable Two-Factor authentication for your account." +new_password = "New Password" +otp_code = "OTP Code" +password = "Password" +reload = "Reload" +submit = "Submit" +update = "Update" +username = "Username" +verify = "Verify" +verify_account = "Verify Account" +too_many_requests = "Too many requests. Please try again later." + +[account] +title = "Account Settings" +groups = "Groups" +home_dir = "Home Directory" +last_password_change = "Last Password Change" +never = "Never" +password_expires = "Password Expires" +org_unit = "Department" +job_title = "Job Title" +telephone_number = "Telephone Number" +mobile_number = "Mobile Telephone Number" +phone_number = "Phone Number" +settings = "Account Settings" +settings_updated = "Account settings successfully updated" +verify_email_sent = "A verification email has been sent." +fatal_system_error = "Fatal system error" +invalid_credentials = "Invalid credentials" +invalid_username = "Invalid username" +please_provide_password = "Please provide a password" +please_provide_username = "Please provide a username" +user_account_is_locked = "User account is locked" +you_must_enable_two_factor_authentication_first = "You must enable Two-Factor Authentication first!" +failed_to_verify_account = "Failed to verify account. Please contact the administrator." +system_error = "System error please contact administrator" +weak_password = "Your password is too weak. Please ensure your password includes a number and lower/upper case character" + +[email_template] +account_updated_preheader = "Your account has been updated." +account_updated_greeting = "Hi" +account_updated_body_part1 = "You recently updated your " +account_updated_body_part2 = " account. For reference, here's what changed:" +account_updated_security_notice_part1 = "For security, this request was received from a " +account_updated_security_notice_part2 = " device using " +account_updated_security_notice_part3 = ". If you did not create an account, please ignore this email and " +account_updated_security_notice_part4 = "contact support" +account_updated_security_notice_part5 = " or check out our " +account_updated_security_notice_help = "documentation" +account_updated_security_notice_part6 = " if you have questions." +account_updated_security_notice_contact = "contact support" +account_updated_signature = "Thanks," +account_verify_preheader = "Use this link to verify your account. The link is only valid for" +account_verify_greeting = "Hi" +account_verify_body_part1 = "You recently created an account at " +account_verify_body_part2 = " and you MUST verify your email before using your account. " +account_verify_body_part3 = "Use the button below to verify your email address. " +account_verify_body_part4 = "This verification is valid for the next " +account_verify_button = "Verify your account" +account_verify_login_info_intro = "For reference, here's your login information:" +account_verify_login_page = "Login Page" +account_verify_username = "Username" +account_verify_signature_part1 = "Thanks, The " +account_verify_signature_part2 = " team" +account_verify_trouble_link = "If you're having trouble with the button above, copy and paste the URL into your web browser." +password_reset_preheader = "Use this link to reset your password. The link is only valid for" +password_reset_greeting = "Hi" +password_reset_intro_part1 = "You recently requested to reset your password for your " +password_reset_intro_part2 = " account. Use the button below to reset it. " +password_reset_intro_part3 = "This link is only valid for the next " +password_reset_button = "Reset your password" +password_reset_trouble = "If you’re having trouble with the button above, copy and paste the URL below into your web browser." +closing_part1 = "Thanks,\nThe " +closing_part2 = " team" +welcome_preheader_part1 = "Thanks for creating an account at " +welcome_preheader_part2 = ". We've pulled together some information and resources to help you get started." +welcome_greeting = "Welcome," +welcome_intro_part1 = "Thanks for creating an account at " +welcome_intro_part2 = ". We're glad you're here." +welcome_button = "Getting Started" +welcome_note_part1 = "For reference, here's your login information:\nLogin Page: " +welcome_note_part2 = "\nUsername: " +password_reset_subject = "Reset your password" +account_verify_subject = "Verify your email address" +account_updated_subject = "Your password has been changed" +welcome_subject = "Welcome to " +two_factor_auth_event = "Two-Factor Authentication " +ssh_key_event = "SSH key " +otp_token_event = "OTP token " +password_changed_event = "Password changed" +ssh_key_added = "added" +ssh_key_removed = "removed" +otp_token_added = "added" +otp_token_removed = "removed" +two_factor_auth_enabled = "Enabled" +two_factor_auth_disabled = "Disabled" +footer_questions = "Do you have questions?" +footer_contact = "Please contact your site administrator:" +footer_sent_to = "This email was automatically sent to" +password_reset_title = "Reset your password" +account_updated_title = "Account updated" +account_verify_title = "Verify your account" +welcome_title = "Welcome" +account_verify_intro_part1 = "You recently created an account at " +account_verify_intro_part2 = " and you MUST verify your email before using your account. Use the button below to verify your email address. This verification is valid for the next " + +[error] +title = "Error Messages" +access_denied = "Access Denied" +bad_gateway = "The server encountered a bad gateway error. Please try again later or contact the site administrator." +log_in_again = "log in again" +no_access_to_resource = "You do not have access to this resource" +session_timed_out = "Your session has timed out. Please try again" +request_timeout = "The request took too long. Please try again." +something_bad_happened = "Something bad happened" +sorry_page_not_found = "Sorry, the page you requested was not found" +system_trouble = "We are currently experiencing some system trouble. If this problem persists, please contact the site administrator." + +[hydra] +consent_without_challenge = "Consent without challenge" +failed_to_validate_consent = "Failed to validate consent" +access_denied = "Access denied" +failed_to_accept_consent = "Failed to accept consent" +login_without_challenge = "Login without challenge" +failed_to_validate_login = "Failed to validate login" +failed_to_accept_login = "Failed to accept login" +oauth2_error = "OAuth2 Error" + +[login] +title = "Login" +submit_button = "Log in now" +create_account = "Create Account" +forgot_password = "Forgot Password?" +new_user = "New User?" +next_button = "Next" +not_you = "Not You?" +otp_authentication = "Two-Factor Authentication" +switch_account = "Switch Account" +login_failed = "Login failed" + +[otptoken] +title = "OTP Tokens" +6_digit_code_label = "6-digit Code" +add_new_title = "Add New TOTP Token" +add_token_prompt = "Add an OTP token via your authenticator app to enable Two-Factor authentication on your account." +added_on = "Added On" +adding_token = "Adding Token..." +click_to_disable = "Click to Disable" +click_to_enable = "Click to Enable" +delete_prompt_title = "Delete Token?" +delete_warning = "This action CANNOT be undone. This will permanently delete the token and you will not be able to use it in the future." +disable_prompt_title = "Disable Token?" +disabled = "Disabled" +enable_prompt_title = "Enable Token?" +enter_6_digit_code_help = "Enter the 6-digit code from your mobile app" +invalid_6_digit_code = "Invalid 6-digit code. Please try again." +failed_to_verify_token = "Failed to verify token." +header = "OTP Tokens" +new_token_button = "New Token" +no_tokens_found = "No OTP Tokens Found" +qr_code_alt = "Scan QR Code" +scan_qr_title = "Scan QR Code with Authenticator App" +show_uri = "Show URI" +token_description_help = "Enter the token description (e.g., the device it's used for). Then click Add below to verify the new TOTP token. The QR code will appear on the next screen. Be sure to scan it with your authenticator app and enter the 6-digit code to verify." +token_description_label = "Token Description" +token_description_placeholder = "My Phone" +invalid_otp = "Invalid OTP code." + +[password] +enter_new = "Please enter a new password" +confirm_new = "Please confirm your new password" +mismatch = "Passwords do not match. Please confirm your password." +enter_current = "Please enter your current password" +same_as_new = "Current password is the same as new password. Please set a different password." +min_length = "Password does not conform to policy. Min length: %d" +policy_not_met = "Password does not conform to policy. Try including both upper/lower case, numbers, and other characters" + +[password_change] +title = "Change Password" +confirm_new_password = "Confirm New Password" +otp_help = "Enter the 6-digit code from your mobile app" +success = "Password successfully updated" + +[password_expired] +title = "Password Expired" +change_password_button = "Change Password" +help = "You signed in with your expired password. Choose a new password below (at least 16 characters per policy)." +otp_help = "You have already signed in with your expired password. Choose a new password and enter a fresh OTP code from your authenticator app." + +[password_forgot] +title = "Forgot Password" +expired_redirect = "Your password has expired. Enter your username below and complete the captcha to receive an email to set a new password." +email_sent = "An email to reset your password has been sent." + +[password_reset] +title = "Reset Password" +reset_button = "Reset Password" +success_message = "Your password has been successfully reset" +failed = "Your password could not be changed. Check that it meets the password policy and try again." + +[profile] +title = "Profile Settings" +account = "Account" +avatar_alt = "User Avatar" +logout = "Logout" +otp_tokens = "OTP Tokens" +security = "Security" +ssh_keys = "SSH Keys" + +[security] +title = "Security Settings" +authentication_methods = "Authentication Methods" +confirm_disable = "Disable" +confirm_enable = "Enable" +disable = "Click to Disable" +disable_message = "This will disable Two-Factor authentication. Are you sure you want to do this?" +disable_title = "Disable Two-Factor Authentication?" +enable = "Click to Enable" +enable_message = "This will enable Two-Factor authentication. Are you sure you want to do this?" +enable_title = "Enable Two-Factor Authentication?" +two_factor_authentication = "Two-Factor Authentication" + +[signup] +title = "Sign Up" +already_have_account = "Already have an account?" +allowed_domains = "Allowed Domains" +create_account = "Create Account" +tos_link = "Terms of Service" +tos_message = "By clicking 'Create Account,' you agree to our" + +[signup_success] +title = "Confirm Your Account" +account_created = "Account successfully created" +admin_verification = "An administrator must activate your account before you can use it." +email_verification = "You need to verify your email address to activate your account." + +[sshkey_list] +title = "SSH Keys" +delete_key = "Delete Key?" +delete_warning = "This action CANNOT be undone. This will permanently delete the key and you will not be able to use it in the future." +enable_mfa = "You must enable Two-Factor authentication before you can add SSH keys!" +new_key = "New SSH Key" +no_keys = "No SSH keys uploaded" +type = "Type" +please_provide_sshkey = "Please provide an ssh key" +invalid_sshkey = "Invalid ssh key" + +[sshkey_new] +title = "Add SSH Keys" +add_title = "Add New SSH Key" +adding = "Adding SSH Key..." +key_help = "Paste the contents of the SSH public key above. Must start with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'" +public_key = "Public Key" + +[verify_account] +title = "Verify Account" +instruction = "Click Verify below to set up your account." +message = "Verify Your Email" + +[verify_success] +title = "Verify Account" +message = "Your account has been successfully verified. Thank you" diff --git a/server/account.go b/server/account.go index 80e73b65..ba9c8e63 100644 --- a/server/account.go +++ b/server/account.go @@ -27,7 +27,8 @@ func (r *Router) AccountSettings(c *fiber.Ctx) error { user.First = strings.TrimSpace(c.FormValue("first")) user.Last = strings.TrimSpace(c.FormValue("last")) - user.Mobile = strings.TrimSpace(c.FormValue("phone")) + user.Title = strings.TrimSpace(c.FormValue("title")) + user.Mobile = strings.TrimSpace(c.FormValue("mobile")) if user.First == "" || user.Last == "" { vars["message"] = "Please provide a first and last name" @@ -134,7 +135,7 @@ func (r *Router) accountCreate(user *ipa.User, password, passwordConfirm, captch return errors.New("Last name is too long. Maximum of 150 chars allowed") } - if err := validatePassword(password, passwordConfirm); err != nil { + if err := r.validatePasswordForUser(user.Username, password, passwordConfirm); err != nil { return err } @@ -212,7 +213,7 @@ func (r *Router) AccountVerify(c *fiber.Ctx) error { "email": claims.Email, "err": err, }).Error("Verifying account failed while fetching user from FreeIPA") - return c.Status(fiber.StatusInternalServerError).SendString("Failed to verify account please contact administrator") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "account.failed_to_verify_account")) } if user.Locked && !viper.GetBool("accounts.require_admin_verify") { @@ -223,7 +224,7 @@ func (r *Router) AccountVerify(c *fiber.Ctx) error { "email": claims.Email, "error": err, }).Error("Verify account failed to enable user in FreeIPA") - return c.Status(fiber.StatusInternalServerError).SendString("Failed to verify account please contact administrator") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "account.failed_to_verify_account")) } } @@ -238,7 +239,7 @@ func (r *Router) AccountVerify(c *fiber.Ctx) error { "email": claims.Email, "error": err, }).Error("Verify account failed to modify user category in FreeIPA") - return c.Status(fiber.StatusInternalServerError).SendString("Failed to verify account please contact administrator") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "account.failed_to_verify_account")) } } diff --git a/server/auth.go b/server/auth.go index e86ef627..f7e3348d 100644 --- a/server/auth.go +++ b/server/auth.go @@ -3,6 +3,8 @@ package server import ( "errors" "fmt" + "net/url" + "strings" "time" "github.com/gofiber/fiber/v2" @@ -66,7 +68,12 @@ func (r *Router) isLoggedIn(c *fiber.Ctx) (bool, error) { } func (r *Router) Login(c *fiber.Ctx) error { + r.logAuthStep(c, "login_get", nil) + vars := fiber.Map{} + if challenge := strings.TrimSpace(c.Query("login_challenge")); challenge != "" { + vars["challenge"] = challenge + } return c.Render("login.html", vars) } @@ -111,6 +118,11 @@ func (r *Router) logout(c *fiber.Ctx) { } func (r *Router) redirectLogin(c *fiber.Ctx) error { + r.logAuthStep(c, "redirect_login", log.Fields{ + "redirect_reason": "logout_or_session_invalid", + "redirect_to": "/auth/login", + }) + r.logout(c) if c.Get("HX-Request", "false") == "true" { @@ -123,6 +135,11 @@ func (r *Router) redirectLogin(c *fiber.Ctx) error { func (r *Router) RequireNoLogin(c *fiber.Ctx) error { if ok, _ := r.isLoggedIn(c); ok { + r.logAuthStep(c, "redirect_already_authenticated", log.Fields{ + "redirect_reason": "session_already_authenticated", + "redirect_to": "/", + }) + if c.Get("HX-Request", "false") == "true" { c.Set("HX-Redirect", "/") return c.Status(fiber.StatusNoContent).SendString("") @@ -154,17 +171,31 @@ func (r *Router) RequireMFA(c *fiber.Ctx) error { user := r.user(c) if !user.OTPOnly() { - return c.Status(fiber.StatusUnauthorized).SendString("You must enable Two-Factor Authentication first!") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.you_must_enable_two_factor_authentication_first")) } return c.Next() } func (r *Router) CheckUser(c *fiber.Ctx) error { + r.logAuthStep(c, "check_user_start", nil) + + if authQueryHasCredentials(c) { + return r.redirectCleanLogin(c, "credentials_in_query_string_on_post") + } + + if password := strings.TrimSpace(c.FormValue("password")); password != "" { + r.logAuthStep(c, "check_user_password_present", log.Fields{ + "action": "delegate_to_authenticate", + }) + return r.Authenticate(c) + } + username := c.FormValue("username") if username == "" { - return c.Status(fiber.StatusBadRequest).SendString("Please provide a username") + r.logAuthStep(c, "check_user_missing_username", nil) + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "account.please_provide_username")) } if isBlocked(username) { @@ -172,7 +203,7 @@ func (r *Router) CheckUser(c *fiber.Ctx) error { "username": username, }).Warn("AUDIT User account is blocked from logging in") r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Invalid username") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.invalid_username")) } userRec, err := r.adminClient.UserShow(username) @@ -185,7 +216,7 @@ func (r *Router) CheckUser(c *fiber.Ctx) error { if !viper.GetBool("accounts.hide_invalid_username_error") { r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Invalid username") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.invalid_username")) } userRec = new(ipa.User) userRec.Username = username @@ -195,7 +226,7 @@ func (r *Router) CheckUser(c *fiber.Ctx) error { "username": username, }).Error("Failed to fetch user info from FreeIPA") r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusInternalServerError).SendString("Fatal system error") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "account.fatal_system_error")) } } @@ -204,7 +235,7 @@ func (r *Router) CheckUser(c *fiber.Ctx) error { "username": username, }).Warn("AUDIT User account is locked in FreeIPA") r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("User account is locked") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.user_account_is_locked")) } log.WithFields(log.Fields{ @@ -212,6 +243,10 @@ func (r *Router) CheckUser(c *fiber.Ctx) error { "ip": RemoteIP(c), }).Info("Login user attempt") + r.logAuthStep(c, "check_user_show_password_step", log.Fields{ + "username": username, + }) + vars := fiber.Map{ "user": userRec, "challenge": c.FormValue("challenge"), @@ -221,17 +256,23 @@ func (r *Router) CheckUser(c *fiber.Ctx) error { } func (r *Router) Authenticate(c *fiber.Ctx) error { + r.logAuthStep(c, "authenticate_start", nil) + + if authQueryHasCredentials(c) { + return r.redirectCleanLogin(c, "credentials_in_query_string_on_post") + } + username := c.FormValue("username") password := c.FormValue("password") challenge := c.FormValue("challenge") otp := c.FormValue("otp") if username == "" { - return c.Status(fiber.StatusBadRequest).SendString("Please provide a username") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "account.please_provide_username")) } if password == "" { - return c.Status(fiber.StatusBadRequest).SendString("Please provide a password") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "account.please_provide_password")) } if isBlocked(username) { @@ -239,7 +280,7 @@ func (r *Router) Authenticate(c *fiber.Ctx) error { "username": username, }).Warn("AUDIT User account is blocked from logging in") r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Invalid credentials") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.invalid_credentials")) } client := ipa.NewDefaultClient() @@ -250,35 +291,21 @@ func (r *Router) Authenticate(c *fiber.Ctx) error { log.WithFields(log.Fields{ "username": username, "err": err, - }).Info("Password expired, forcing change") - - sess, err := r.session(c) - if err != nil { - return c.Status(fiber.StatusInternalServerError).SendString("") + }).Info("Password expired, redirecting to forgot password") + + target := fmt.Sprintf("/auth/forgotpw?expired=1&username=%s", url.QueryEscape(username)) + r.logAuthStep(c, "redirect_password_expired", log.Fields{ + "redirect_reason": "password_expired", + "redirect_to": target, + "username": username, + }) + + if c.Get("HX-Request", "false") == "true" { + c.Set("HX-Redirect", target) + return c.Status(fiber.StatusNoContent).SendString("") } - err = sess.Regenerate() - if err != nil { - return err - } - - sess.Set(SessionKeyAuthenticated, false) - sess.Set(SessionKeyUsername, username) - - if err := r.sessionSave(c, sess); err != nil { - return c.Status(fiber.StatusInternalServerError).SendString("") - } - - userRec, err := r.adminClient.UserShow(username) - if err != nil { - return c.Status(fiber.StatusInternalServerError).SendString("") - } - - vars := fiber.Map{ - "username": username, - "user": userRec, - } - return c.Render("login-password-expired.html", vars) + return c.Redirect(target) default: log.WithFields(log.Fields{ "username": username, @@ -286,7 +313,7 @@ func (r *Router) Authenticate(c *fiber.Ctx) error { "err": err, }).Error("AUDIT Failed login attempt") r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Invalid credentials") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.invalid_credentials")) } } @@ -297,11 +324,14 @@ func (r *Router) Authenticate(c *fiber.Ctx) error { "err": err, }).Error("Failed to ping FreeIPA") r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Invalid credentials") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.invalid_credentials")) } sess, err := r.session(c) if err != nil { + r.logAuthStep(c, "authenticate_session_error", log.Fields{ + "error": err.Error(), + }) return err } @@ -318,6 +348,12 @@ func (r *Router) Authenticate(c *fiber.Ctx) error { return err } + r.logAuthStep(c, "authenticate_session_saved", log.Fields{ + "username": username, + "session_cookie": c.Cookies("session"), + "ipa_session_id": client.SessionID(), + }) + if viper.IsSet("hydra.admin_url") && challenge != "" { return r.LoginOAuthPost(username, challenge, c) } @@ -328,6 +364,15 @@ func (r *Router) Authenticate(c *fiber.Ctx) error { }).Info("AUDIT User logged in successfully") r.metrics.totalLogins.Inc() - c.Set("HX-Redirect", "/") - return c.Status(fiber.StatusNoContent).SendString("") + r.logAuthStep(c, "redirect_login_success", log.Fields{ + "redirect_reason": "authenticated", + "redirect_to": "/", + }) + + if c.Get("HX-Request", "false") == "true" { + c.Set("HX-Redirect", "/") + return c.Status(fiber.StatusNoContent).SendString("") + } + + return c.Redirect("/") } diff --git a/server/auth_debug.go b/server/auth_debug.go new file mode 100644 index 00000000..03461054 --- /dev/null +++ b/server/auth_debug.go @@ -0,0 +1,110 @@ +package server + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + log "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +var authCredentialQueryKeys = []string{ + "password", + "passwd", + "pass", + "pwd", + "otp", + "otpcode", +} + +func authQueryHasCredentials(c *fiber.Ctx) bool { + for _, key := range authCredentialQueryKeys { + if c.Query(key) != "" { + return true + } + } + return false +} + +func authFormFieldNames(c *fiber.Ctx) []string { + names := make([]string, 0, 8) + c.Request().PostArgs().VisitAll(func(key, _ []byte) { + names = append(names, string(key)) + }) + return names +} + +func (r *Router) authDebugFields(c *fiber.Ctx, step string) log.Fields { + fields := log.Fields{ + "auth_step": step, + "path": c.Path(), + "method": c.Method(), + "ip": RemoteIP(c), + "is_htmx": c.Get("HX-Request") == "true", + "session_cookie": c.Cookies("session"), + "has_csrf_header": c.Get("X-CSRF-Token") != "", + "has_csrf_form": c.FormValue("_csrf") != "" || c.FormValue("csrf") != "", + } + + if c.Method() == fiber.MethodPost { + fields["form_fields"] = authFormFieldNames(c) + fields["has_username"] = c.FormValue("username") != "" + fields["has_password"] = c.FormValue("password") != "" + fields["has_otp"] = c.FormValue("otp") != "" || c.FormValue("otpcode") != "" + fields["has_challenge"] = c.FormValue("challenge") != "" + } + + for _, key := range authCredentialQueryKeys { + if c.Query(key) != "" { + fields["query_has_"+key] = true + } + } + + return fields +} + +func (r *Router) logAuthStep(c *fiber.Ctx, step string, extra log.Fields) { + fields := r.authDebugFields(c, step) + for k, v := range extra { + fields[k] = v + } + + switch step { + case "reject_query_credentials", "strip_query_credentials": + log.WithFields(fields).Warn("AUTH security: credentials must not appear in URL") + default: + if viper.GetBool("server.auth_debug") { + log.WithFields(fields).Info("AUTH flow") + } + } +} + +func (r *Router) redirectCleanLogin(c *fiber.Ctx, reason string) error { + r.logAuthStep(c, "strip_query_credentials", log.Fields{ + "redirect_reason": reason, + "redirect_to": "/auth/login", + }) + + if c.Get("HX-Request") == "true" { + c.Set("HX-Redirect", "/auth/login") + return c.Status(fiber.StatusNoContent).SendString("") + } + + return c.Redirect("/auth/login") +} + +func (r *Router) sanitizeAuthQuery(c *fiber.Ctx) error { + if c.Method() != fiber.MethodGet { + return c.Next() + } + + if !strings.HasPrefix(c.Path(), "/auth/") && c.Path() != "/oauth/login" { + return c.Next() + } + + if !authQueryHasCredentials(c) { + return c.Next() + } + + return r.redirectCleanLogin(c, "credentials_in_query_string") +} diff --git a/server/csrf.go b/server/csrf.go index a3aba864..2e946619 100644 --- a/server/csrf.go +++ b/server/csrf.go @@ -10,7 +10,16 @@ import ( func (r *Router) CSRF(c *fiber.Ctx) error { sess, err := r.session(c) if err != nil { - return err + log.WithFields(log.Fields{ + "path": c.Path(), + "ip": RemoteIP(c), + "err": err, + }).Warn("Session storage unavailable") + if c.Get("HX-Request", "false") == "true" { + c.Set("HX-Redirect", "/auth/login") + return c.Status(fiber.StatusNoContent).SendString("") + } + return c.Redirect("/auth/login") } var token string @@ -27,10 +36,22 @@ func (r *Router) CSRF(c *fiber.Ctx) error { sess.Save() } default: - if token == "" || token != c.Get("X-CSRF-Token") { + requestToken := c.Get("X-CSRF-Token") + if requestToken == "" { + requestToken = c.FormValue("_csrf") + } + if requestToken == "" { + requestToken = c.FormValue("csrf") + } + + if token == "" || requestToken == "" || token != requestToken { log.WithFields(log.Fields{ - "path": c.Path(), - "ip": RemoteIP(c), + "path": c.Path(), + "ip": RemoteIP(c), + "is_htmx": c.Get("HX-Request") == "true", + "has_csrf_header": c.Get("X-CSRF-Token") != "", + "has_csrf_form": c.FormValue("_csrf") != "" || c.FormValue("csrf") != "", + "session_cookie": c.Cookies("session"), }).Error("Invalid CSRF token in POST request") return fiber.ErrForbidden } diff --git a/server/email.go b/server/email.go index 287df0d4..16fb9e13 100644 --- a/server/email.go +++ b/server/email.go @@ -7,12 +7,14 @@ package server import ( "bytes" "crypto/tls" + "encoding/base64" "fmt" - "mime/multipart" + "io/ioutil" "mime/quotedprintable" "net" "net/smtp" "net/textproto" + "os" "path/filepath" "strings" "text/template" @@ -23,7 +25,7 @@ import ( "github.com/mileusna/useragent" log "github.com/sirupsen/logrus" "github.com/spf13/viper" - "github.com/ubccr/goipa" + ipa "github.com/ubccr/goipa" ) const crlf = "\r\n" @@ -80,7 +82,7 @@ func (e *Emailer) SendPasswordResetEmail(user *ipa.User, ctx *fiber.Ctx) error { "link_expires": strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(time.Duration(viper.GetInt("email.token_max_age"))*time.Second), "", "")), } - return e.sendEmail(user, ctx, "Please reset your password", "password-reset", vars) + return e.sendEmail(user, ctx, Translate("", "email_template.password_reset_subject"), "password-reset", vars) } func (e *Emailer) SendAccountVerifyEmail(user *ipa.User, ctx *fiber.Ctx) error { @@ -94,7 +96,7 @@ func (e *Emailer) SendAccountVerifyEmail(user *ipa.User, ctx *fiber.Ctx) error { "link_expires": strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(time.Duration(viper.GetInt("email.token_max_age"))*time.Second), "", "")), } - return e.sendEmail(user, ctx, "Verify your email", "account-verify", vars) + return e.sendEmail(user, ctx, Translate("", "email_template.account_verify_subject"), "account-verify", vars) } func (e *Emailer) SendWelcomeEmail(user *ipa.User, ctx *fiber.Ctx) error { @@ -102,17 +104,22 @@ func (e *Emailer) SendWelcomeEmail(user *ipa.User, ctx *fiber.Ctx) error { "getting_started_url": viper.GetString("site.getting_started_url"), } - subject := "Welcome to " + viper.GetString("site.name") + subject := Translate("", "email_template.welcome_subject") + " " + viper.GetString("site.name") return e.sendEmail(user, ctx, subject, "welcome", vars) + } func (e *Emailer) SendMFAChangedEmail(enabled bool, user *ipa.User, ctx *fiber.Ctx) error { - verb := "Disabled" + var verbKey string if enabled { - verb = "Enabled" + verbKey = "email_template.two_factor_auth_enabled" + } else { + verbKey = "email_template.two_factor_auth_disabled" } - event := "Two-Factor Authentication " + verb + + verb := Translate("", verbKey) + event := Translate("", "email_template.two_factor_auth_event") + verb vars := map[string]interface{}{ "event": event, @@ -121,26 +128,33 @@ func (e *Emailer) SendMFAChangedEmail(enabled bool, user *ipa.User, ctx *fiber.C return e.sendEmail(user, ctx, event, "account-updated", vars) } -func (e *Emailer) SendSSHKeyUpdatedEmail(added bool, user *ipa.User, ctx *fiber.Ctx) error { - verb := "removed" +func (e Emailer) SendSSHKeyUpdatedEmail(added bool, user *ipa.User, ctx *fiber.Ctx) error { + var verbKey string if added { - verb = "added" + verbKey = "email_template.ssh_key_added" + } else { + verbKey = "email_template.ssh_key_removed" } - event := "SSH key " + verb + + verb := Translate("", verbKey) + event := Translate("", "email_template.ssh_key_event") + verb vars := map[string]interface{}{ "event": event, } - return e.sendEmail(user, ctx, event, "account-updated", vars) } func (e *Emailer) SendOTPTokenUpdatedEmail(added bool, user *ipa.User, ctx *fiber.Ctx) error { - verb := "removed" + var verbKey string if added { - verb = "added" + verbKey = "email_template.otp_token_added" + } else { + verbKey = "email_template.otp_token_removed" } - event := "OTP token " + verb + + verb := Translate("", verbKey) + event := Translate("", "email_template.otp_token_event") + verb vars := map[string]interface{}{ "event": event, @@ -150,11 +164,13 @@ func (e *Emailer) SendOTPTokenUpdatedEmail(added bool, user *ipa.User, ctx *fibe } func (e *Emailer) SendPasswordChangedEmail(user *ipa.User, ctx *fiber.Ctx) error { + event := Translate("", "email_template.password_changed_event") + vars := map[string]interface{}{ - "event": "Password changed", + "event": event, } - return e.sendEmail(user, ctx, "Your password has been changed", "account-updated", vars) + return e.sendEmail(user, ctx, Translate("", "email_template.account_updated_subject"), "account-updated", vars) } func (e *Emailer) quotedBody(body []byte) ([]byte, error) { @@ -196,6 +212,19 @@ func (e *Emailer) sendEmail(user *ipa.User, ctx *fiber.Ctx, subject, tmpl string data["homepage"] = viper.GetString("site.homepage") data["base_url"] = BaseURL(ctx) + // Ensure the "lang" key exists in the data map + defaultLang := "en" + if viper.IsSet("site.default_language") { + defaultLang = viper.GetString("site.default_language") + } + + if lang, exists := data["lang"]; !exists || lang == "" { + log.Printf("DEBUG: 'lang' key not found or empty, using default language '%s'", defaultLang) + data["lang"] = defaultLang + } else { + log.Debugf("DEBUG: Using provided 'lang' key with value: %v", lang) + } + var text bytes.Buffer err := e.templates.ExecuteTemplate(&text, tmpl+".txt", data) if err != nil { @@ -218,6 +247,26 @@ func (e *Emailer) sendEmail(user *ipa.User, ctx *fiber.Ctx, subject, tmpl string return err } + // Determine logo path + logoPath := viper.GetString("email.logo_path") + if logoPath == "" { + logoPath = filepath.Join(viper.GetString("site.templates_dir"), "static/images/logo.png") + } + if _, err := os.Stat(logoPath); os.IsNotExist(err) { + logoPath = "/etc/mokey/templates/static/images/logo.png" + } + + // Load logo file + var logoData []byte + var logoContentType string + if logoData, err = ioutil.ReadFile(logoPath); err == nil { + logoContentType = "image/png" + log.Debugf("Loaded logo from %s (%d bytes)", logoPath, len(logoData)) + } else { + log.Warnf("Failed to load logo from %s: %v", logoPath, err) + logoData = nil + } + header := make(textproto.MIMEHeader) header.Set("Mime-Version", "1.0") header.Set("Date", time.Now().Format(time.RFC1123Z)) @@ -225,42 +274,57 @@ func (e *Emailer) sendEmail(user *ipa.User, ctx *fiber.Ctx, subject, tmpl string header.Set("Subject", fmt.Sprintf("[%s] %s", viper.GetString("site.name"), subject)) header.Set("From", viper.GetString("email.from")) + // Manually build multipart/related structure with nested multipart/alternative var multipartBody bytes.Buffer - mp := multipart.NewWriter(&multipartBody) - header.Set("Content-Type", fmt.Sprintf("multipart/alternative;%s boundary=%s", crlf, mp.Boundary())) - - txtPart, err := mp.CreatePart(textproto.MIMEHeader( - map[string][]string{ - "Content-Type": []string{"text/plain; charset=utf-8"}, - "Content-Transfer-Encoding": []string{"quoted-printable"}, - })) - if err != nil { - return err - } - - _, err = txtPart.Write(txtBody) - if err != nil { - return err - } - - htmlPart, err := mp.CreatePart(textproto.MIMEHeader( - map[string][]string{ - "Content-Type": []string{"text/html; charset=utf-8"}, - "Content-Transfer-Encoding": []string{"quoted-printable"}, - })) - if err != nil { - return err - } - - _, err = htmlPart.Write(htmlBody) - if err != nil { - return err - } - - err = mp.Close() - if err != nil { - return err - } + outerBoundary := "related-" + fmt.Sprintf("%x", time.Now().UnixNano()) + innerBoundary := "alternative-" + fmt.Sprintf("%x", time.Now().UnixNano()) + + header.Set("Content-Type", fmt.Sprintf("multipart/related; boundary=%s", outerBoundary)) + + // Start outer multipart + multipartBody.WriteString("--" + outerBoundary + "\r\n") + + // Start inner multipart/alternative + multipartBody.WriteString("Content-Type: multipart/alternative; boundary=" + innerBoundary + "\r\n") + multipartBody.WriteString("\r\n") + + // Text/plain part + multipartBody.WriteString("--" + innerBoundary + "\r\n") + multipartBody.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + multipartBody.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + multipartBody.WriteString("\r\n") + multipartBody.Write(txtBody) + multipartBody.WriteString("\r\n") + + // Text/html part + multipartBody.WriteString("--" + innerBoundary + "\r\n") + multipartBody.WriteString("Content-Type: text/html; charset=utf-8\r\n") + multipartBody.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + multipartBody.WriteString("\r\n") + multipartBody.Write(htmlBody) + multipartBody.WriteString("\r\n") + + // Close inner multipart/alternative + multipartBody.WriteString("--" + innerBoundary + "--\r\n") + multipartBody.WriteString("\r\n") + + // Add logo as inline image attachment + if logoData != nil { + multipartBody.WriteString("--" + outerBoundary + "\r\n") + multipartBody.WriteString("Content-Type: " + logoContentType + "\r\n") + multipartBody.WriteString("Content-Transfer-Encoding: base64\r\n") + multipartBody.WriteString("Content-ID: \r\n") + multipartBody.WriteString("Content-Disposition: inline; filename=\"logo.png\"\r\n") + multipartBody.WriteString("\r\n") + + encodedLogo := make([]byte, base64.StdEncoding.EncodedLen(len(logoData))) + base64.StdEncoding.Encode(encodedLogo, logoData) + multipartBody.Write(encodedLogo) + multipartBody.WriteString("\r\n") + } + + // Close outer multipart + multipartBody.WriteString("--" + outerBoundary + "--\r\n") smtpHostPort := fmt.Sprintf("%s:%d", viper.GetString("email.smtp_host"), viper.GetInt("email.smtp_port")) var conn net.Conn @@ -305,6 +369,7 @@ func (e *Emailer) sendEmail(user *ipa.User, ctx *fiber.Ctx, subject, tmpl string return err } } + if err = c.Mail(viper.GetString("email.from")); err != nil { log.Error(err) return err diff --git a/server/errors.go b/server/errors.go new file mode 100644 index 00000000..50b8cd12 --- /dev/null +++ b/server/errors.go @@ -0,0 +1,35 @@ +package server + +import ( + "errors" + "io" + "strings" + "syscall" + + "github.com/gofiber/fiber/v2" +) + +// isBenignDisconnect reports whether err is typically caused by the client closing +// the connection early (browser navigation, HTMX redirect, load balancer probes). +func isBenignDisconnect(err error) bool { + if err == nil { + return false + } + + if errors.Is(err, io.EOF) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ECONNRESET) { + return true + } + + msg := err.Error() + return strings.Contains(msg, "broken pipe") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "closed network connection") || + strings.Contains(msg, "use of closed network connection") +} + +// isNoisyGatewayError is true when Fiber surfaces a generic 502 without an application bug. +func isNoisyGatewayError(code int, err error) bool { + return code == fiber.StatusBadGateway || isBenignDisconnect(err) +} diff --git a/server/hydra.go b/server/hydra.go index 01671349..a1e13c5e 100644 --- a/server/hydra.go +++ b/server/hydra.go @@ -28,7 +28,7 @@ func (r *Router) ConsentGet(c *fiber.Ctx) error { "ip": RemoteIP(c), }).Error("Consent endpoint was called without a consent challenge") r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusBadRequest).SendString("consent without challenge") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "hydra.consent_without_challenge")) } cparams := admin.NewGetConsentRequestParams() @@ -40,7 +40,7 @@ func (r *Router) ConsentGet(c *fiber.Ctx) error { "error": err, }).Error("Failed to validate the consent challenge") r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusInternalServerError).SendString("Failed to validate consent") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_validate_consent")) } consent := cresponse.Payload @@ -52,12 +52,12 @@ func (r *Router) ConsentGet(c *fiber.Ctx) error { "username": consent.Subject, }).Warn("Failed to find User record for consent") r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusInternalServerError).SendString("Failed to validate consent") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_validate_consent")) } if viper.GetBool("accounts.require_mfa") && !user.OTPOnly() { r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Access denied.") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "hydra.access_denied")) } params := admin.NewAcceptConsentRequestParams() @@ -83,7 +83,7 @@ func (r *Router) ConsentGet(c *fiber.Ctx) error { "error": err, }).Error("Failed to accept the consent challenge") r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusInternalServerError).SendString("Failed to accept consent") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_accept_consent")) } log.WithFields(log.Fields{ @@ -102,7 +102,7 @@ func (r *Router) LoginOAuthGet(c *fiber.Ctx) error { log.WithFields(log.Fields{ "ip": RemoteIP(c), }).Error("Login OAuth endpoint was called without a challenge") - return c.Status(fiber.StatusBadRequest).SendString("login without challenge") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "hydra.login_without_challenge")) } getparams := admin.NewGetLoginRequestParams() @@ -113,7 +113,7 @@ func (r *Router) LoginOAuthGet(c *fiber.Ctx) error { log.WithFields(log.Fields{ "error": err, }).Error("Failed to validate the login challenge") - return c.Status(fiber.StatusInternalServerError).SendString("Failed to validate login") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_validate_login")) } login := response.Payload @@ -131,12 +131,12 @@ func (r *Router) LoginOAuthGet(c *fiber.Ctx) error { "username": *login.Subject, }).Warn("Failed to find User record for login") r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusInternalServerError).SendString("Failed to validate login") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_validate_login")) } if viper.GetBool("accounts.require_mfa") && !user.OTPOnly() { r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Access denied.") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "hydra.access_denied")) } acceptparams := admin.NewAcceptLoginRequestParams() @@ -152,7 +152,7 @@ func (r *Router) LoginOAuthGet(c *fiber.Ctx) error { "error": err, }).Error("Failed to accept the GET login challenge") r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusInternalServerError).SendString("Failed to accept login") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_accept_login")) } log.WithFields(log.Fields{ @@ -190,7 +190,7 @@ func (r *Router) LoginOAuthPost(username, challenge string, c *fiber.Ctx) error "username": username, "error": err, }).Error("Failed to accept the POST login challenge") - return c.Status(fiber.StatusInternalServerError).SendString("Failed to accept login") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_accept_login")) } log.WithFields(log.Fields{ @@ -216,7 +216,7 @@ func (r *Router) HydraError(c *fiber.Ctx) error { "hint": hint, }).Error("OAuth2 request failed") - return c.Status(fiber.StatusInternalServerError).SendString("OAuth2 Error") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.oauth2_error")) } func (r *Router) revokeHydraAuthenticationSession(username string, c *fiber.Ctx) error { diff --git a/server/middleware.go b/server/middleware.go index 398560dc..3ab72ad5 100644 --- a/server/middleware.go +++ b/server/middleware.go @@ -16,7 +16,7 @@ func SecureHeaders(c *fiber.Ctx) error { c.Set(fiber.HeaderXXSSProtection, "1; mode=block") c.Set(fiber.HeaderXContentTypeOptions, "nosniff") c.Set(fiber.HeaderXFrameOptions, "DENY") - c.Set(fiber.HeaderContentSecurityPolicy, "default-src 'self' 'unsafe-inline'; img-src 'self' data:;script-src 'self' 'unsafe-inline'") + c.Set(fiber.HeaderContentSecurityPolicy, "default-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; script-src 'self' 'unsafe-inline'") if !strings.HasPrefix(c.Path(), "/static") { c.Set("Cache-Control", "no-store") @@ -46,7 +46,7 @@ func NotFoundHandler(c *fiber.Ctx) error { } func HTTPErrorHandler(c *fiber.Ctx, err error) error { - username := c.Locals(ContextKeyUser) + username := c.Locals(ContextKeyUsername) path := c.Path() code := fiber.StatusInternalServerError @@ -54,36 +54,48 @@ func HTTPErrorHandler(c *fiber.Ctx, err error) error { code = e.Code } - log.WithFields(log.Fields{ + fields := log.Fields{ "code": code, "username": username, "path": path, + "method": c.Method(), "ip": RemoteIP(c), - }).Error(err) + "error": err.Error(), + } + + if isNoisyGatewayError(code, err) { + log.WithFields(fields).Warn("HTTP request ended before response completed (client disconnect or timeout)") + } else { + log.WithFields(fields).Error("HTTP request failed") + } if c.Locals("NoErrorTemplate") == "true" { return c.Status(code).SendString("") } - if c.Get("HX-Request", "false") == "true" { - errorPage := fmt.Sprintf("%d-partial.html", code) - err := c.Render(errorPage, nil) - if err != nil { + return renderErrorPage(c, code) +} + +func renderErrorPage(c *fiber.Ctx, code int) error { + vars := fiber.Map{} + hx := c.Get("HX-Request", "false") == "true" + + primary := fmt.Sprintf("%d.html", code) + fallback := "500.html" + if hx { + primary = fmt.Sprintf("%d-partial.html", code) + fallback = "500-partial.html" + } + + if err := c.Render(primary, vars); err != nil { + if err2 := c.Render(fallback, vars); err2 != nil { log.WithFields(log.Fields{ - "error": err, - }).Error("Failed to render custom error partial") + "code": code, + "primary": primary, + "error": err, + }).Error("Failed to render error page") return c.Status(code).SendString("") } - return nil - } - - errorPage := fmt.Sprintf("%d.html", code) - err = c.Render(errorPage, nil) - if err != nil { - log.WithFields(log.Fields{ - "error": err, - }).Error("Failed to render custom error page") - return c.Status(code).SendString("") } return nil @@ -93,7 +105,7 @@ func LimitReachedHandler(c *fiber.Ctx) error { log.WithFields(log.Fields{ "ip": RemoteIP(c), }).Warn("Limit reached") - return c.Status(fiber.StatusTooManyRequests).SendString("Too many requests. Please try again later.") + return c.Status(fiber.StatusTooManyRequests).SendString(Translate("", "common.too_many_requests")) } func (r *Router) RequireHTMX(c *fiber.Ctx) error { diff --git a/server/otp.go b/server/otp.go index d8dc62f6..5e9fa66d 100644 --- a/server/otp.go +++ b/server/otp.go @@ -134,7 +134,7 @@ func (r *Router) OTPTokenVerify(c *fiber.Ctx) error { key, err := otp.NewKeyFromURL(uri) if err != nil || action == "cancel" { client.RemoveOTPToken(uuid) - vars["message"] = "Failed to verify token." + vars["message"] = Translate("", "otp.failed_to_verify_token") return r.tokenList(c, vars) } @@ -154,7 +154,7 @@ func (r *Router) OTPTokenVerify(c *fiber.Ctx) error { "uuid": uuid, "username": user.Username, }).Error("Failed to verify OTP token") - return c.Status(fiber.StatusBadRequest).SendString("Invalid 6-digit code. Please try again.") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "otp.invalid_6_digit_code")) } autoMFA := false diff --git a/server/password.go b/server/password.go index 0347ddbb..2762d34a 100644 --- a/server/password.go +++ b/server/password.go @@ -3,7 +3,9 @@ package server import ( "errors" "fmt" + "net/url" "regexp" + "strings" "time" "github.com/dchest/captcha" @@ -21,13 +23,11 @@ var ( ) // Simple password checker to validate passwords before creating an account -func checkPassword(pass string) error { - minLength := viper.GetInt("accounts.min_passwd_len") - minClasses := viper.GetInt("accounts.min_passwd_classes") - +func checkPassword(pass string, minLength, minClasses int) error { l := len([]rune(pass)) if l < minLength { - return fmt.Errorf("Password does not conform to policy. Min length: %d", minLength) + // Translators: “Min length: %d” – keep the placeholder for the length + return fmt.Errorf(Translate("", "password.min_length"), minLength) } numCategories := 0 @@ -65,42 +65,92 @@ func checkPassword(pass string) error { } if numCategories < minClasses { - return fmt.Errorf("Password does not conform to policy. Try including both upper/lower case, numbers, and other characters") + // Translators: “Password does not conform to policy…” – generic message + return fmt.Errorf(Translate("", "password.policy_not_met")) } return nil } -func validatePassword(password, passwordConfirm string) error { +func (r *Router) passwordPolicyLimits(username string) (minLength, minClasses int) { + minLength = viper.GetInt("accounts.min_passwd_len") + minClasses = viper.GetInt("accounts.min_passwd_classes") + + if username == "" { + return minLength, minClasses + } + + policy, err := r.adminClient.PasswordPolicyForUser(username) + if err != nil { + log.WithFields(log.Fields{ + "username": username, + "error": err, + }).Debug("Using mokey.toml password limits; could not read IPA password policy") + return minLength, minClasses + } + + if policy.MinLength > 0 { + minLength = policy.MinLength + } + if policy.MinClasses > 0 { + minClasses = policy.MinClasses + } + + log.WithFields(log.Fields{ + "username": username, + "policy": policy.CN, + "min_length": minLength, + "min_classes": minClasses, + }).Debug("Using IPA password policy for validation") + + return minLength, minClasses +} + +func (r *Router) checkPasswordForUser(username, pass string) error { + minLength, minClasses := r.passwordPolicyLimits(username) + return checkPassword(pass, minLength, minClasses) +} + +func validatePassword(password, passwordConfirm string, minLength, minClasses int) error { if password == "" { - return errors.New("Please enter a new password") + // Translators: Prompt user to enter a new password + return errors.New(Translate("", "password.enter_new")) } if passwordConfirm == "" { - return errors.New("Please confirm your new password") + // Translators: Prompt user to confirm the new password + return errors.New(Translate("", "password.confirm_new")) } if password != passwordConfirm { - return errors.New("Password do not match. Please confirm your password.") + // Translators: Prompt user to confirm the new password + return errors.New(Translate("", "password.confirm_new")) } - if err := checkPassword(password); err != nil { + if err := checkPassword(password, minLength, minClasses); err != nil { return err } return nil } -func validatePasswordChange(passwordCurrent, password, passwordConfirm string) error { +func (r *Router) validatePasswordForUser(username, password, passwordConfirm string) error { + minLength, minClasses := r.passwordPolicyLimits(username) + return validatePassword(password, passwordConfirm, minLength, minClasses) +} + +func (r *Router) validatePasswordChangeForUser(username, passwordCurrent, password, passwordConfirm string) error { if passwordCurrent == "" { - return errors.New("Please enter you current password") + // Translators: Prompt user to enter current password + return errors.New(Translate("", "password.enter_current")) } if passwordCurrent == passwordConfirm { - return errors.New("Current password is the same as new password. Please set a different password.") + // Translators: Current password equals new password + return errors.New(Translate("", "password.same_as_new")) } - return validatePassword(password, passwordConfirm) + return r.validatePasswordForUser(username, password, passwordConfirm) } func (r *Router) PasswordChange(c *fiber.Ctx) error { @@ -121,11 +171,12 @@ func (r *Router) PasswordChange(c *fiber.Ctx) error { otp := c.FormValue("otpcode") if user.OTPOnly() && otp == "" { - vars["message"] = "Please enter the 6-digit OTP code from your mobile app" + // Translators: OTP prompt for users that only use OTP + vars["message"] = Translate("", "password_change.otp_help") return c.Render("password.html", vars) } - if err := validatePasswordChange(password, newpass, newpass2); err != nil { + if err := r.validatePasswordChangeForUser(user.Username, password, newpass, newpass2); err != nil { vars["message"] = err.Error() return c.Render("password.html", vars) } @@ -144,7 +195,7 @@ func (r *Router) PasswordChange(c *fiber.Ctx) error { "username": user.Username, "error": err.Error(), }).Error("Failed to change password") - vars["message"] = "Fatal system error" + vars["message"] = Translate("", "account.system_error") } } else { err = r.emailer.SendPasswordChangedEmail(user, c) @@ -167,6 +218,12 @@ func (r *Router) PasswordForgot(c *fiber.Ctx) error { vars := fiber.Map{ "captchaID": captcha.New(), } + if username := strings.TrimSpace(c.Query("username")); username != "" { + vars["username"] = username + } + if c.Query("expired") == "1" { + vars["message"] = Translate("", "password_forgot.expired_redirect") + } return c.Render("password-forgot.html", vars) } @@ -250,6 +307,7 @@ func (r *Router) PasswordReset(c *fiber.Ctx) error { vars := fiber.Map{ "claims": claims, "user": user, + "path": c.Path(), } return c.Render("password-reset.html", vars) @@ -259,20 +317,23 @@ func (r *Router) PasswordReset(c *fiber.Ctx) error { passwordConfirm := c.FormValue("password2") otp := c.FormValue("otpcode") - if user.OTPOnly() && otp == "" { - return c.Status(fiber.StatusBadRequest).SendString("Please enter the 6-digit OTP code from your mobile app") + if userHasOTP(user) && otp == "" { + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "password_change.otp_help")) } - if err := validatePassword(password, passwordConfirm); err != nil { + if err := r.validatePasswordForUser(user.Username, password, passwordConfirm); err != nil { return c.Status(fiber.StatusBadRequest).SendString(err.Error()) } - rand, err := r.adminClient.ResetPassword(user.Username) + err = r.adminClient.ResetUserPassword(user.Username, password, otp) if err != nil { - return c.Status(fiber.StatusInternalServerError).SendString("System error please contact administrator") - } + log.WithFields(log.Fields{ + "username": user.Username, + "error": err, + }).Warn("ResetUserPassword failed, trying AdminSetPassword fallback") - err = r.adminClient.SetPassword(user.Username, rand, password, otp) + err = r.adminClient.AdminSetPassword(user.Username, password, otp) + } if err != nil { switch { case errors.Is(err, ipa.ErrPasswordPolicy): @@ -280,19 +341,19 @@ func (r *Router) PasswordReset(c *fiber.Ctx) error { "username": user.Username, "error": err, }).Error("Password does not conform to policy") - return c.Status(fiber.StatusBadRequest).SendString("Your password is too weak. Please ensure your password includes a number and lower/upper case character") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "account.weak_password")) case errors.Is(err, ipa.ErrInvalidPassword): log.WithFields(log.Fields{ "username": user.Username, "error": err, }).Error("invalid password from FreeIPA") - return c.Status(fiber.StatusBadRequest).SendString("Invalid OTP code.") + return c.Status(fiber.StatusBadRequest).SendString(passwordResetInvalidMessage(user, otp)) default: log.WithFields(log.Fields{ "username": user.Username, "error": err, }).Error("failed to set user password in FreeIPA") - return c.Status(fiber.StatusInternalServerError).SendString("System error please contact administrator") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "account.system_error")) } } @@ -312,105 +373,32 @@ func (r *Router) PasswordReset(c *fiber.Ctx) error { }).Info("AUDIT User password changed successfully") r.metrics.totalPasswordResets.Inc() - return c.Render("password-reset-success.html", fiber.Map{}) -} - -func (r *Router) PasswordExpired(c *fiber.Ctx) error { - sess, err := r.session(c) - if err != nil { - log.Warn("Failed to get user session. Logging out") - return r.redirectLogin(c) - } - - username := sess.Get(SessionKeyUsername) - authenticated := sess.Get(SessionKeyAuthenticated) - if username == nil || authenticated == nil { - return r.redirectLogin(c) - } - - if isAuthed, ok := authenticated.(bool); !ok || isAuthed { - return r.redirectLogin(c) - } - - if _, ok := username.(string); !ok { - log.Error("Invalid user in session") - return r.redirectLogin(c) - } - - user, err := r.adminClient.UserShow(username.(string)) - if err != nil { + if updated, showErr := r.adminClient.UserShow(user.Username); showErr == nil { log.WithFields(log.Fields{ - "username": username, - "err": err, - }).Warn("Password expired attempt for unknown username") - return r.redirectLogin(c) + "username": user.Username, + "password_expires": updated.PasswdExpire.UTC().Format(time.RFC3339), + "last_password_chg": updated.LastPasswdChange.UTC().Format(time.RFC3339), + }).Debug("Password reset expiration state") } - password := c.FormValue("password") - newpass := c.FormValue("newpassword") - newpass2 := c.FormValue("newpassword2") - otp := c.FormValue("otp") - - if user.OTPOnly() && otp == "" { - return c.Status(fiber.StatusBadRequest).SendString("Please enter the 6-digit OTP code from your mobile app") - } - - if err := validatePasswordChange(password, newpass, newpass2); err != nil { - return c.Status(fiber.StatusBadRequest).SendString(err.Error()) - } - - err = r.adminClient.SetPassword(user.Username, password, newpass, otp) - if err != nil { - log.WithFields(log.Fields{ - "err": err, - "username": user.Username, - "email": user.Email, - }).Error("Failed to change expired password for user") - - return c.Status(fiber.StatusInternalServerError).SendString("") - } - - err = r.emailer.SendPasswordChangedEmail(user, c) - if err != nil { - log.WithFields(log.Fields{ - "err": err, - "username": user.Username, - "email": user.Email, - }).Error("Failed to send password changed email") - } - - client := ipa.NewDefaultClient() - err = client.RemoteLogin(user.Username, newpass+otp) - if err != nil { - log.WithFields(log.Fields{ - "username": user.Username, - "ipa_client_error": err, - }).Error("Failed to login after expired password change") - return c.Status(fiber.StatusUnauthorized).SendString("Login failed") - } + return c.Render("password-reset-success.html", fiber.Map{}) +} - _, err = client.Ping() - if err != nil { - log.WithFields(log.Fields{ - "username": user.Username, - "ipa_client_error": err, - }).Error("Failed to ping FreeIPA after expired password change") - return c.Status(fiber.StatusUnauthorized).SendString("Invalid credentials") +func passwordResetInvalidMessage(user *ipa.User, otp string) string { + if userHasOTP(user) { + if otp == "" { + return Translate("", "password_change.otp_help") + } + return Translate("", "otptoken.invalid_otp") } + return Translate("", "password_reset.failed") +} - sess.Set(SessionKeyAuthenticated, true) - sess.Set(SessionKeyUsername, user.Username) - sess.Set(SessionKeySID, client.SessionID()) - - if err := r.sessionSave(c, sess); err != nil { - return err +// PasswordExpiredRedirect sends legacy expired-password URLs to the forgot-password flow. +func (r *Router) PasswordExpiredRedirect(c *fiber.Ctx) error { + target := "/auth/forgotpw?expired=1" + if username := strings.TrimSpace(c.FormValue("username")); username != "" { + target = fmt.Sprintf("/auth/forgotpw?expired=1&username=%s", url.QueryEscape(username)) } - - log.WithFields(log.Fields{ - "username": user.Username, - }).Info("AUDIT User logged in and changed expired password successfully") - r.metrics.totalPasswordResets.Inc() - - c.Set("HX-Redirect", "/") - return c.Status(fiber.StatusNoContent).SendString("") + return c.Redirect(target) } diff --git a/server/password_test.go b/server/password_test.go index cf6814e4..240fa70b 100644 --- a/server/password_test.go +++ b/server/password_test.go @@ -3,23 +3,23 @@ package server import ( "testing" - "github.com/spf13/viper" "github.com/stretchr/testify/assert" ) func TestPasswordCheck(t *testing.T) { - viper.Set("accounts.min_passwd_len", 8) - viper.Set("accounts.min_passwd_classes", 3) - assert := assert.New(t) // Too short - assert.Error(checkPassword("123")) + assert.Error(checkPassword("123", 8, 3)) // Not enough classes - assert.Error(checkPassword("123456789")) + assert.Error(checkPassword("123456789", 8, 3)) // Not enough classes - assert.Error(checkPassword("test1234")) + assert.Error(checkPassword("test1234", 8, 3)) // Good - assert.NoError(checkPassword("test!1234")) + assert.NoError(checkPassword("test!1234", 8, 3)) + + // Per-user policy: engineering group requires 16 chars and 2 classes + assert.Error(checkPassword("Short1!abc", 16, 2)) + assert.NoError(checkPassword("LongEnoughPass1!", 16, 2)) } diff --git a/server/router.go b/server/router.go index 6284177d..45d3f42d 100644 --- a/server/router.go +++ b/server/router.go @@ -34,6 +34,7 @@ func NewRouter(storage fiber.Storage) (*Router, error) { } r.adminClient = ipa.NewDefaultClient() + r.adminClient.PasswordMaxLifeFallback = viper.GetInt("accounts.password_max_life_days") err := r.adminClient.LoginWithKeytab(viper.GetString("site.keytab"), viper.GetString("site.ktuser")) if err != nil { @@ -95,6 +96,7 @@ func RemoteIP(c *fiber.Ctx) string { func (r *Router) SetupRoutes(app *fiber.App) { // CSRF tokens stored in sessions app.Use(r.CSRF) + app.Use(r.sanitizeAuthQuery) app.Get("/", r.RequireLogin, r.Index) app.Get("/account", r.RequireLogin, r.Index) @@ -111,7 +113,8 @@ func (r *Router) SetupRoutes(app *fiber.App) { app.Get("/auth/login", r.RequireNoLogin, r.Login) app.Post("/auth/login", r.RequireNoLogin, r.CheckUser) app.Post("/auth/authenticate", r.RequireNoLogin, r.Authenticate) - app.Post("/auth/expiredpw", r.RequireNoLogin, r.PasswordExpired) + app.Get("/auth/expiredpw", r.RequireNoLogin, r.PasswordExpiredRedirect) + app.Post("/auth/expiredpw", r.RequireNoLogin, r.PasswordExpiredRedirect) app.Get("/auth/forgotpw", r.RequireNoLogin, r.PasswordForgot) app.Post("/auth/forgotpw", r.RequireNoLogin, r.PasswordForgot) app.Get("/auth/verify", r.RequireNoLogin, r.AccountVerifyResend) diff --git a/server/server.go b/server/server.go index 35eca2e1..f1c0557e 100644 --- a/server/server.go +++ b/server/server.go @@ -7,6 +7,7 @@ import ( "io/fs" "net/http" "os" + "path/filepath" "strings" "time" @@ -45,6 +46,7 @@ func SetDefaults() { viper.SetDefault("accounts.default_shell", "/bin/bash") viper.SetDefault("accounts.min_passwd_len", 8) viper.SetDefault("accounts.min_passwd_classes", 2) + viper.SetDefault("accounts.password_max_life_days", 0) viper.SetDefault("accounts.otp_hash_algorithm", "sha1") viper.SetDefault("accounts.username_from_email", false) viper.SetDefault("accounts.require_mfa", false) @@ -56,9 +58,10 @@ func SetDefaults() { viper.SetDefault("email.from", "support@example.com") viper.SetDefault("server.secure_cookies", true) viper.SetDefault("server.session_idle_timeout", 900) + viper.SetDefault("server.auth_debug", false) viper.SetDefault("server.listen", "0.0.0.0:8866") - viper.SetDefault("server.read_timeout", 5) - viper.SetDefault("server.write_timeout", 5) + viper.SetDefault("server.read_timeout", 30) + viper.SetDefault("server.write_timeout", 30) viper.SetDefault("server.idle_timeout", 120) viper.SetDefault("server.rate_limit_expiration", 3600) viper.SetDefault("server.rate_limit_max", 10) @@ -79,6 +82,44 @@ func NewServer(address string) (*Server, error) { return s, nil } +func setupFavicon(app *fiber.App, assetsFS http.FileSystem) { + faviconPath := strings.TrimSpace(viper.GetString("site.favicon")) + if faviconPath != "" { + if _, err := os.Stat(faviconPath); err != nil { + log.Warnf("site.favicon not found (%s), skipping favicon middleware: %v", faviconPath, err) + return + } + app.Use(favicon.New(favicon.Config{ + File: faviconPath, + })) + return + } + + // Relative to static_assets_dir root (deploy: assets/css, assets/js, assets/images/…) + const embeddedFavicon = "images/favicon.ico" + if f, err := assetsFS.Open(embeddedFavicon); err == nil { + _ = f.Close() + app.Use(favicon.New(favicon.Config{ + File: embeddedFavicon, + FileSystem: assetsFS, + })) + return + } + + if assetsRoot := strings.TrimSpace(viper.GetString("site.static_assets_dir")); assetsRoot != "" { + absPath := filepath.Join(assetsRoot, embeddedFavicon) + if _, err := os.Stat(absPath); err == nil { + app.Use(favicon.New(favicon.Config{ + File: absPath, + })) + return + } + log.Warnf("favicon not found under static_assets_dir (%s); expected %s", assetsRoot, absPath) + } + + log.Warnf("favicon not found (%s); skipping favicon middleware", embeddedFavicon) +} + func getAssetsFS() http.FileSystem { staticLocalPath := viper.GetString("site.static_assets_dir") if staticLocalPath != "" { @@ -200,16 +241,7 @@ func newFiber() (*fiber.App, error) { MaxAge: 900, })) - if viper.IsSet("site.favicon") { - app.Use(favicon.New(favicon.Config{ - File: viper.GetString("site.favicon"), - })) - } else { - app.Use(favicon.New(favicon.Config{ - File: "images/favicon.ico", - FileSystem: assetsFS, - })) - } + setupFavicon(app, assetsFS) // This must be last app.Use(NotFoundHandler) diff --git a/server/sshpubkey.go b/server/sshpubkey.go index a0709e6d..a09a8a3a 100644 --- a/server/sshpubkey.go +++ b/server/sshpubkey.go @@ -26,7 +26,8 @@ func (r *Router) SSHKeyAdd(c *fiber.Ctx) error { key := c.FormValue("key") if key == "" { - return c.Status(fiber.StatusBadRequest).SendString("Please provide an ssh key") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "sshkey_list.please_provide_sshkey")) + } authKey, err := ipa.NewSSHAuthorizedKey(key) @@ -35,7 +36,7 @@ func (r *Router) SSHKeyAdd(c *fiber.Ctx) error { "username": user.Username, "err": err, }).Error("Failed to add new ssh key") - return c.Status(fiber.StatusBadRequest).SendString("Invalid ssh key") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "sshkey_list.invalid_sshkey")) } if title != "" { diff --git a/server/template.go b/server/template.go index 2c19cd1b..853f1c10 100644 --- a/server/template.go +++ b/server/template.go @@ -8,9 +8,11 @@ import ( "sort" "strings" "time" + "fmt" - "github.com/dustin/go-humanize" "github.com/spf13/viper" + log "github.com/sirupsen/logrus" + "github.com/gofiber/fiber/v2" ) //go:embed templates @@ -24,6 +26,8 @@ var funcMap = template.FuncMap{ "ConfigValueBool": ConfigValueBool, "AllowedDomains": AllowedDomains, "BreakNewlines": BreakNewlines, + "Translate": Translate, + "UserHasOTP": userHasOTP, } type TemplateRenderer struct { @@ -31,14 +35,20 @@ type TemplateRenderer struct { } func NewTemplateRenderer() (*TemplateRenderer, error) { + // Laad vertalingen + err := LoadTranslations() + if err != nil { + return nil, fmt.Errorf("failed to load translations: %w", err) + } tmpl := template.New("") tmpl.Funcs(funcMap) - tmpl, err := tmpl.ParseFS(templateFiles, "templates/*.html") + tmpl, err = tmpl.ParseFS(templateFiles, "templates/*.html") if err != nil { return nil, err } + // Add local templates if available if viper.IsSet("site.templates_dir") { localTemplatePath := filepath.Join(viper.GetString("site.templates_dir"), "*.html") localTemplates, err := filepath.Glob(localTemplatePath) @@ -68,7 +78,31 @@ func (t *TemplateRenderer) Load() error { } func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, layouts ...string) error { - return t.templates.ExecuteTemplate(w, name, data) + // Same logic to check if "lang" is set and apply translation + var dataMap map[string]interface{} + switch v := data.(type) { + case map[string]interface{}: + dataMap = v + case fiber.Map: + dataMap = map[string]interface{}(v) + default: + log.Println("WARN: The provided data is not a map[string]interface{}, wrapping in map") + dataMap = map[string]interface{}{"data": data} + } + + defaultLang := "en" + if viper.IsSet("site.default_language") { + defaultLang = viper.GetString("site.default_language") + } + + if lang, exists := dataMap["lang"]; !exists { + //log.Printf("DEBUG: 'lang' key not found, using default language '%s'", defaultLang) + dataMap["lang"] = defaultLang + } else { + log.Debugf("DEBUG: Found 'lang' key with value: %v", lang) + } + + return t.templates.ExecuteTemplate(w, name, dataMap) } func AllowedDomains() string { @@ -95,7 +129,7 @@ func ConfigValueBool(key string) bool { } func TimeAgo(t time.Time) string { - return humanize.Time(t) + return Dutch.Format(t) } func SplitSSHFP(fp string) []string { diff --git a/server/templates/401.html b/server/templates/401.html index 069ccf28..e0f2f53b 100644 --- a/server/templates/401.html +++ b/server/templates/401.html @@ -1,16 +1,15 @@ {{ template "header.html" . }}
-
+
- -
+
{{ template "footer.html" . }} diff --git a/server/templates/403-partial.html b/server/templates/403-partial.html index 1819cb9d..1dacf2f0 100644 --- a/server/templates/403-partial.html +++ b/server/templates/403-partial.html @@ -1,3 +1,3 @@ diff --git a/server/templates/403.html b/server/templates/403.html index 2cb257fb..af120bc2 100644 --- a/server/templates/403.html +++ b/server/templates/403.html @@ -1,16 +1,15 @@ {{ template "header.html" . }}
-
+
- -
+
{{ template "footer.html" . }} diff --git a/server/templates/404-partial.html b/server/templates/404-partial.html index 6c0ba759..f12224d8 100644 --- a/server/templates/404-partial.html +++ b/server/templates/404-partial.html @@ -1,3 +1,3 @@ diff --git a/server/templates/404.html b/server/templates/404.html index ecdaf7a7..405b2614 100644 --- a/server/templates/404.html +++ b/server/templates/404.html @@ -1,16 +1,15 @@ {{ template "header.html" . }}
-
+
- -
+
{{ template "footer.html" . }} diff --git a/server/templates/408-partial.html b/server/templates/408-partial.html new file mode 100644 index 00000000..b31b5d4b --- /dev/null +++ b/server/templates/408-partial.html @@ -0,0 +1,3 @@ + diff --git a/server/templates/408.html b/server/templates/408.html new file mode 100644 index 00000000..ef9cf2d7 --- /dev/null +++ b/server/templates/408.html @@ -0,0 +1,13 @@ +{{ template "header.html" . }} +
+
+ + + +
+
+{{ template "footer.html" . }} diff --git a/server/templates/500-partial.html b/server/templates/500-partial.html index 2b4437a8..d2a7799c 100644 --- a/server/templates/500-partial.html +++ b/server/templates/500-partial.html @@ -1,3 +1,3 @@ diff --git a/server/templates/500.html b/server/templates/500.html index 29834cc8..fe727a6b 100644 --- a/server/templates/500.html +++ b/server/templates/500.html @@ -1,16 +1,15 @@ {{ template "header.html" . }}
-
+
- -
+
{{ template "footer.html" . }} diff --git a/server/templates/502-partial.html b/server/templates/502-partial.html new file mode 100644 index 00000000..95f3b6c5 --- /dev/null +++ b/server/templates/502-partial.html @@ -0,0 +1,3 @@ + diff --git a/server/templates/502.html b/server/templates/502.html new file mode 100644 index 00000000..bdd17675 --- /dev/null +++ b/server/templates/502.html @@ -0,0 +1,13 @@ +{{ template "header.html" . }} +
+
+ + + +
+
+{{ template "footer.html" . }} diff --git a/server/templates/account-verify-forgot-success.html b/server/templates/account-verify-forgot-success.html index 0727458b..154b4d46 100644 --- a/server/templates/account-verify-forgot-success.html +++ b/server/templates/account-verify-forgot-success.html @@ -1,12 +1,12 @@