Compare commits

..

7 Commits

Author SHA1 Message Date
Christoph Haas
4c986cc74c feat: add support for PKCE (#686) 2026-05-26 22:47:38 +02:00
Dan Berg
0cf04d07e0 fix vue and oauth redirects under web base path (#683)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2026-05-19 21:52:54 +02:00
Christoph Haas
c2b4a5d03c chore: update Go dependencies
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
2026-05-18 22:33:42 +02:00
nesbyte
835f76bf58 feat(docs): how to troubleshoot admin_group_regex with oidc (#684)
Added instructions for identifying claims in OIDC user info payload for admin rights.
2026-05-18 22:31:46 +02:00
Mykhailo Roit
958dcb8fa9 feat: sanitize external identity provider user data (#681)
* feat: sanitize external user data

* remove config option to disable Sanitization: sanitize_external_user_data

* cleanup

---------

Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
2026-05-18 22:28:27 +02:00
ratorin
ff935a404e feat(i18n): add Japanese (ja) translation (#682)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
- Add frontend/src/lang/translations/ja.json (full translation of all 422 entries from en.json)
- Register ja in frontend/src/lang/index.js so it appears in the language selector

Tested locally with v2.2.3 build. UI strings render correctly in Japanese.

Closes: language support request

Co-authored-by: Taro Kawakami <tarokun@tunagufactory.jp>
2026-05-03 21:23:34 +02:00
h44z
1c133b6f6e Improved default peer handling (#674)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* create default peers for newly created interfaces (#666)

* allow to manually create default peers for an interface (#666)
2026-04-16 21:55:41 +02:00
36 changed files with 2752 additions and 144 deletions

View File

@@ -47,6 +47,8 @@ auth:
extra_scopes:
- https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile
use_pkce: true
pkce_method: S256
registration_enabled: true
logout_idp_session: true
- id: oidc2
@@ -79,6 +81,7 @@ auth:
user_identifier: sub
is_admin: this-attribute-must-be-true
registration_enabled: true
use_pkce: false
- id: google_plain_oauth_with_groups
provider_name: google4
display_name: Login with</br>Google4
@@ -113,4 +116,4 @@ backend:
api_verify_tls: true
api_timeout: 30s
concurrency: 5
debug: false
debug: false

View File

@@ -602,6 +602,7 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- **Description:** WgPortal can grant a user admin rights by matching the value of the `is_admin` claim against a regular expression. Alternatively, a regular expression can be used to check if a user is member of a specific group listed in the `user_group` claim. The regular expressions are defined in `admin_value_regex` and `admin_group_regex`.
- `admin_value_regex`: A regular expression to match the `is_admin` claim. By default, this expression matches the string "true" (`^true$`).
- `admin_group_regex`: A regular expression to match the `user_groups` claim. Each entry in the `user_groups` claim is checked against this regex.
- To identify which claim to match against, set log_level: debug and reload the config. Log in with the intended admin account and inspect the logs for the OIDC user info payload. If the required claim is missing it must be added by the OIDC provider. If it is present, use its value as the pattern for admin_group_regex.
#### `registration_enabled`
- **Default:** `false`
@@ -616,6 +617,14 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`:
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
#### `use_pkce`
- **Default:** `true`
- **Description:** If `true`, Proof Key for Code Exchange (PKCE) is used for the OIDC authorization code flow. A fresh `code_verifier` is generated per login request, the matching `code_challenge` is sent with the authorization request, and the `code_verifier` is included in the token exchange. Set to `false` only for providers that do not support PKCE.
#### `pkce_method`
- **Default:** `S256`
- **Description:** PKCE challenge method to use when `use_pkce` is enabled. Supported values are `S256` and `plain`. `S256` is recommended; use `plain` only for providers that explicitly require it.
#### `logout_idp_session`
- **Default:** `true`
- **Description:** If `true` (default), WireGuard Portal will redirect the user to the OIDC provider's `end_session_endpoint` after local logout, terminating the session at the IdP as well. Set to `false` to only invalidate the local WireGuard Portal session without touching the IdP session.
@@ -702,6 +711,14 @@ Below are the properties for each OAuth provider entry inside `auth.oauth`:
- **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging).
- **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues.
#### `use_pkce`
- **Default:** `true`
- **Description:** If `true`, Proof Key for Code Exchange (PKCE) is used for the OIDC authorization code flow. A fresh `code_verifier` is generated per login request, the matching `code_challenge` is sent with the authorization request, and the `code_verifier` is included in the token exchange. Set to `false` only for providers that do not support PKCE.
#### `pkce_method`
- **Default:** `S256`
- **Description:** PKCE challenge method to use when `use_pkce` is enabled. Supported values are `S256` and `plain`. `S256` is recommended; use `plain` only for providers that explicitly require it.
---
### LDAP

View File

@@ -8,6 +8,23 @@ To enable encryption, set the [`encryption_passphrase`](../configuration/overvie
> :warning: Important: Once encryption is enabled, it cannot be disabled, and the passphrase cannot be changed!
> Only new or updated records will be encrypted; existing data remains in plaintext until its next modified.
## External Identity Provider Data Sanitization
When users authenticate via LDAP, OIDC, or OAuth, WireGuard Portal sanitizes the field values received from the provider before storing them. This protects against several classes of attack that a compromised or misconfigured identity provider could introduce:
- **Unsafe control characters** — Unicode control and format characters, null bytes, and invalid UTF-8 bytes are stripped from external profile fields before they reach the Vue.js UI or email templates.
- **Email header injection** — carriage return and line feed characters in email fields are rejected entirely, and email fields must parse as plain email addresses.
- **Log injection** — unsafe control and format characters are stripped from all external profile fields and from sanitization log context.
- **Denial of service via oversized fields** — field lengths are capped (e.g., 256 runes for identifiers, 254 characters for email addresses).
- **Reserved identifier collision** — reserved user identifiers such as `"all"`, `"new"`, `"id"`, and internal system user identifiers are rejected.
- **Unsafe authorization groups** — OIDC/OAuth group claims are sanitized before group-based checks; groups changed by control/format stripping or truncation are dropped rather than repaired into allowed/admin matches.
Sanitization is always enabled and cannot be disabled.
When sanitization modifies or clears a field value, a `WARN` log entry is emitted with the provider name, provider type, and field name — but never the raw or sanitized value, to avoid leaking sensitive data into logs. This makes it straightforward to detect and investigate potentially malicious or misconfigured providers.
---
## UI and API Access
WireGuard Portal provides a web UI and a REST API for user interaction. It is important to secure these interfaces to prevent unauthorized access and data breaches.
@@ -21,4 +38,4 @@ A detailed explanation is available in the [Reverse Proxy](../getting-started/re
### Secure Authentication
To prevent unauthorized access, WireGuard Portal supports integrating with secure authentication providers such as LDAP, OAuth2, or Passkeys, see [Authentication](./authentication.md) for more details.
When possible, use centralized authentication and enforce multi-factor authentication (MFA) at the provider level for enhanced account security.
For local accounts, administrators should enforce strong password requirements.
For local accounts, administrators should enforce strong password requirements.

View File

@@ -9,6 +9,7 @@ import uk from './translations/uk.json';
import vi from './translations/vi.json';
import zh from './translations/zh.json';
import es from './translations/es.json';
import ja from './translations/ja.json';
import {createI18n} from "vue-i18n";
@@ -33,7 +34,8 @@ const i18n = createI18n({
"uk": uk,
"vi": vi,
"zh": zh,
"es": es,
"es": es,
"ja": ja,
}
});

View File

@@ -0,0 +1,651 @@
{
"languages": {
"en": "日本語"
},
"general": {
"pagination": {
"size": "件数",
"all": "全件 (低速)"
},
"search": {
"placeholder": "検索...",
"button": "検索"
},
"select-all": "すべて選択",
"yes": "はい",
"no": "いいえ",
"cancel": "キャンセル",
"close": "閉じる",
"save": "保存",
"delete": "削除"
},
"login": {
"headline": "ログイン",
"username": {
"label": "ユーザー名",
"placeholder": "ユーザー名を入力してください"
},
"password": {
"label": "パスワード",
"placeholder": "パスワードを入力してください"
},
"button": "ログイン",
"button-webauthn": "パスキーでログイン"
},
"menu": {
"home": "ホーム",
"interfaces": "インターフェース",
"users": "ユーザー",
"lang": "言語切替",
"profile": "マイプロフィール",
"settings": "設定",
"audit": "監査ログ",
"login": "ログイン",
"logout": "ログアウト",
"keygen": "鍵生成",
"calculator": "IP計算機"
},
"home": {
"headline": "WireGuard® VPN ポータル",
"info-headline": "詳細情報",
"abstract": "WireGuard® は最新の暗号技術を活用した、シンプルかつ高速なモダンVPNです。IPsec より高速・シンプル・軽量・実用的な設計を目指し、OpenVPN を大きく上回る性能を発揮します。",
"installation": {
"box-header": "WireGuard インストール",
"headline": "インストール",
"content": "クライアントソフトウェアのインストール手順は WireGuard 公式サイトでご確認いただけます。",
"button": "インストール手順を開く"
},
"about-wg": {
"box-header": "WireGuard について",
"headline": "概要",
"content": "WireGuard® は最新の暗号技術を活用したシンプルかつ高速なモダンVPNです。",
"button": "詳細"
},
"about-portal": {
"box-header": "WireGuard Portal について",
"headline": "WireGuard Portal",
"content": "WireGuard Portal は WireGuard を Web から簡単に設定できる管理ポータルです。",
"button": "詳細"
},
"profiles": {
"headline": "VPN プロファイル",
"abstract": "個人 VPN 設定の確認・ダウンロードはユーザープロファイルから行えます。",
"content": "設定済みプロファイルの一覧は下のボタンから開けます。",
"button": "マイプロフィールを開く"
},
"admin": {
"headline": "管理エリア",
"abstract": "管理エリアでは、WireGuard ピア、サーバーインターフェース、および WireGuard Portal にログイン可能なユーザーを管理できます。",
"content": "",
"button-admin": "サーバー管理を開く",
"button-user": "ユーザー管理を開く"
}
},
"interfaces": {
"headline": "インターフェース管理",
"headline-peers": "現在の VPN ピア",
"headline-endpoints": "現在のエンドポイント",
"no-interface": {
"default-selection": "利用可能なインターフェースなし",
"headline": "インターフェースが見つかりません...",
"abstract": "上の「+」ボタンから新しい WireGuard インターフェースを作成してください。"
},
"no-peer": {
"headline": "ピアがありません",
"abstract": "選択した WireGuard インターフェースには現在ピアが登録されていません。"
},
"table-heading": {
"name": "名前",
"user": "ユーザー",
"ip": "IP",
"endpoint": "エンドポイント",
"status": "ステータス"
},
"interface": {
"headline": "インターフェース状態:",
"backend": "バックエンド",
"unknown-backend": "不明",
"wrong-backend": "バックエンドが無効です。代わりにローカル WireGuard バックエンドを使用します。",
"key": "公開鍵",
"endpoint": "公開エンドポイント",
"port": "待受ポート",
"peers": "有効ピア数",
"total-peers": "全ピア数",
"endpoints": "有効エンドポイント数",
"total-endpoints": "全エンドポイント数",
"ip": "IPアドレス",
"default-allowed-ip": "デフォルト許可IP",
"dns": "DNSサーバー",
"mtu": "MTU",
"default-keep-alive": "デフォルトキープアライブ間隔",
"default-dns": "デフォルトDNSサーバー",
"button-show-config": "設定を表示",
"button-download-config": "設定をダウンロード",
"button-store-config": "wg-quick 用に設定を保存",
"button-edit": "インターフェースを編集"
},
"button-add-interface": "インターフェース追加",
"button-add-peer": "ピア追加",
"button-add-peers": "複数ピアを追加",
"button-show-peer": "ピア詳細",
"button-edit-peer": "ピア編集",
"button-bulk-delete": "選択したピアを削除",
"button-bulk-enable": "選択したピアを有効化",
"button-bulk-disable": "選択したピアを無効化",
"confirm-bulk-delete": "{count} 件のピアを削除してもよろしいですか?",
"confirm-bulk-disable": "{count} 件のピアを無効化してもよろしいですか?",
"peer-disabled": "ピアは無効化されています。理由:",
"peer-expiring": "ピアの有効期限:",
"peer-connected": "接続中",
"peer-not-connected": "未接続",
"peer-handshake": "最終ハンドシェイク:"
},
"users": {
"headline": "ユーザー管理",
"table-heading": {
"id": "ID",
"email": "メール",
"firstname": "名",
"lastname": "姓",
"sources": "ソース",
"peers": "ピア",
"admin": "管理者"
},
"no-user": {
"headline": "ユーザーがいません",
"abstract": "現在 WireGuard Portal に登録されているユーザーはいません。"
},
"button-add-user": "ユーザー追加",
"button-show-user": "ユーザー詳細",
"button-edit-user": "ユーザー編集",
"button-bulk-delete": "選択したユーザーを削除",
"button-bulk-enable": "選択したユーザーを有効化",
"button-bulk-disable": "選択したユーザーを無効化",
"button-bulk-lock": "選択したユーザーをロック",
"button-bulk-unlock": "選択したユーザーのロック解除",
"confirm-bulk-delete": "{count} 件のユーザーを削除してもよろしいですか?",
"confirm-bulk-disable": "{count} 件のユーザーを無効化してもよろしいですか?",
"confirm-bulk-lock": "{count} 件のユーザーをロックしてもよろしいですか?",
"user-disabled": "ユーザーは無効化されています。理由:",
"user-locked": "アカウントはロックされています。理由:",
"admin": "管理者権限あり",
"no-admin": "管理者権限なし"
},
"profile": {
"headline": "マイ VPN ピア",
"table-heading": {
"name": "名前",
"ip": "IP",
"stats": "ステータス",
"interface": "サーバーインターフェース"
},
"no-peer": {
"headline": "ピアがありません",
"abstract": "現在、あなたのユーザープロフィールにはピアが関連付けられていません。"
},
"peer-connected": "接続中",
"button-add-peer": "ピア追加",
"button-show-peer": "ピア詳細",
"button-edit-peer": "ピア編集"
},
"settings": {
"headline": "設定",
"abstract": "ここで個人設定を変更できます。",
"api": {
"headline": "API 設定",
"abstract": "RESTful API の設定はこちらで行います。",
"active-description": "あなたのアカウントで API は現在有効です。すべての API リクエストは Basic 認証で行います。以下の認証情報を使用してください。",
"inactive-description": "API は現在無効です。下のボタンを押して有効化してください。",
"user-label": "API ユーザー名:",
"user-placeholder": "API ユーザー",
"token-label": "API パスワード:",
"token-placeholder": "API トークン",
"token-created-label": "API アクセス許可日時: ",
"button-disable-title": "API を無効化します。現在のトークンは無効になります。",
"button-disable-text": "API を無効化",
"button-enable-title": "API を有効化します。新しいトークンが生成されます。",
"button-enable-text": "API を有効化",
"api-link": "API ドキュメント"
},
"webauthn": {
"headline": "パスキー設定",
"abstract": "パスキーはパスワード不要でユーザー認証を行う最新の方法です。ブラウザに安全に保存され、WireGuard Portal へのログインに使用できます。",
"active-description": "現在、あなたのアカウントには少なくとも 1 つのパスキーが登録されています。",
"inactive-description": "あなたのアカウントにはパスキーが登録されていません。下のボタンから新しいパスキーを登録してください。",
"table": {
"name": "名前",
"created": "作成日時",
"actions": ""
},
"credentials-list": "登録済みパスキー",
"modal-delete": {
"headline": "パスキー削除",
"abstract": "このパスキーを削除してもよろしいですか? 削除後はこのパスキーでログインできなくなります。",
"created": "作成日時:",
"button-delete": "削除",
"button-cancel": "キャンセル"
},
"button-rename-title": "リネーム",
"button-rename-text": "パスキーの名前を変更します。",
"button-save-title": "保存",
"button-save-text": "新しいパスキー名を保存します。",
"button-cancel-title": "キャンセル",
"button-cancel-text": "リネームをキャンセルします。",
"button-delete-title": "削除",
"button-delete-text": "パスキーを削除します。削除後はこのパスキーでログインできなくなります。",
"button-register-title": "パスキー登録",
"button-register-text": "新しいパスキーを登録してアカウントを保護します。"
},
"password": {
"headline": "パスワード設定",
"abstract": "ここでパスワードを変更できます。",
"current-label": "現在のパスワード",
"new-label": "新しいパスワード",
"new-confirm-label": "新しいパスワード(確認)",
"change-button-text": "パスワード変更",
"invalid-confirm-label": "パスワードが一致しません",
"weak-label": "パスワードが脆弱です"
}
},
"audit": {
"headline": "監査ログ",
"abstract": "WireGuard Portal で実行されたすべての操作の監査ログを確認できます。",
"no-entries": {
"headline": "ログがありません",
"abstract": "現在、監査ログは記録されていません。"
},
"entries-headline": "ログエントリ",
"table-heading": {
"id": "#",
"time": "日時",
"user": "ユーザー",
"severity": "重要度",
"origin": "発生元",
"message": "メッセージ"
}
},
"keygen": {
"headline": "WireGuard 鍵生成",
"abstract": "新しい WireGuard 鍵を生成します。鍵はローカルブラウザで生成され、サーバーには送信されません。",
"headline-keypair": "新しい鍵ペア",
"headline-preshared-key": "新しい事前共有鍵",
"button-generate": "生成",
"private-key": {
"label": "秘密鍵",
"placeholder": "秘密鍵"
},
"public-key": {
"label": "公開鍵",
"placeholder": "公開鍵"
},
"preshared-key": {
"label": "事前共有鍵",
"placeholder": "事前共有鍵"
}
},
"calculator": {
"headline": "WireGuard IP 計算機",
"abstract": "WireGuard の Allowed IPs を生成します。IP サブネットはローカルブラウザで生成され、サーバーには送信されません。",
"headline-allowed-ip": "新しい Allowed IPs",
"button-exclude-private": "プライベートIP範囲を除外",
"allowed-ip": {
"label": "Allowed IPs",
"placeholder": "0.0.0.0/0, ::/0",
"empty": "値は必須です"
},
"dissallowed-ip": {
"label": "除外IP",
"placeholder": "10.0.0.0/8, 192.168.0.0/16",
"invalid": "無効なアドレス: {addr}"
},
"new-allowed-ip": {
"label": "Allowed IPs",
"placeholder": ""
}
},
"modals": {
"user-view": {
"headline": "ユーザーアカウント:",
"tab-user": "情報",
"tab-peers": "ピア",
"headline-info": "ユーザー情報:",
"headline-notes": "備考:",
"email": "メール",
"firstname": "名",
"lastname": "姓",
"phone": "電話番号",
"department": "部署",
"api-enabled": "API アクセス",
"disabled": "アカウント無効",
"locked": "アカウントロック中",
"no-peers": "このユーザーには関連するピアがありません。",
"peers": {
"name": "名前",
"interface": "インターフェース",
"ip": "IP"
}
},
"user-edit": {
"headline-edit": "ユーザー編集:",
"headline-new": "新規ユーザー",
"header-general": "全般",
"header-personal": "ユーザー情報",
"header-notes": "備考",
"header-state": "状態",
"identifier": {
"label": "識別子",
"placeholder": "一意のユーザー識別子"
},
"source": {
"label": "ソース",
"placeholder": "ユーザーのソース"
},
"password": {
"label": "パスワード",
"placeholder": "強固なパスワード",
"description": "現在のパスワードを保持する場合は空のままにします。",
"too-weak": "パスワードが脆弱です。より強固なパスワードを使用してください。"
},
"email": {
"label": "メール",
"placeholder": "メールアドレス"
},
"phone": {
"label": "電話",
"placeholder": "電話番号"
},
"department": {
"label": "部署",
"placeholder": "部署名"
},
"firstname": {
"label": "名",
"placeholder": "名"
},
"lastname": {
"label": "姓",
"placeholder": "姓"
},
"notes": {
"label": "備考",
"placeholder": ""
},
"disabled": {
"label": "無効化 (WireGuard 接続およびログインを禁止)"
},
"locked": {
"label": "ロック (ログイン禁止、WireGuard 接続は引き続き有効)"
},
"admin": {
"label": "管理者"
},
"persist-local-changes": {
"label": "ローカル変更を保持"
},
"sync-warning": "同期されたユーザーを変更するには、ローカル変更の保持を有効化してください。そうしないと次回の同期時に変更が上書きされます。",
"confirm-delete": "ユーザー '{id}' を削除してもよろしいですか?"
},
"interface-view": {
"headline": "インターフェース設定:"
},
"interface-edit": {
"headline-edit": "インターフェース編集:",
"headline-new": "新規インターフェース",
"tab-interface": "インターフェース",
"tab-peerdef": "ピアのデフォルト",
"header-general": "全般",
"header-network": "ネットワーク",
"header-crypto": "暗号化",
"header-hooks": "インターフェースフック",
"header-peer-hooks": "フック",
"header-state": "状態",
"identifier": {
"label": "識別子",
"placeholder": "一意のインターフェース識別子"
},
"mode": {
"label": "インターフェースモード",
"server": "サーバーモード",
"client": "クライアントモード",
"any": "不明モード"
},
"backend": {
"label": "インターフェースバックエンド",
"invalid-label": "元のバックエンドが利用できなくなったため、ローカル WireGuard バックエンドを使用します。",
"local": "ローカル WireGuard バックエンド"
},
"display-name": {
"label": "表示名",
"placeholder": "インターフェースの説明的な名前"
},
"private-key": {
"label": "秘密鍵",
"placeholder": "秘密鍵"
},
"public-key": {
"label": "公開鍵",
"placeholder": "公開鍵"
},
"ip": {
"label": "IPアドレス",
"placeholder": "IPアドレス (CIDR 形式)"
},
"listen-port": {
"label": "待受ポート",
"placeholder": "待ち受けポート"
},
"dns": {
"label": "DNSサーバー",
"placeholder": "使用するDNSサーバー"
},
"dns-search": {
"label": "DNS 検索ドメイン",
"placeholder": "DNS 検索プレフィックス"
},
"mtu": {
"label": "MTU",
"placeholder": "インターフェース MTU (0 = デフォルト)"
},
"firewall-mark": {
"label": "ファイアウォールマーク",
"placeholder": "送信トラフィックに付与するファイアウォールマーク (0 = 自動)"
},
"routing-table": {
"label": "ルーティングテーブル",
"placeholder": "ルーティングテーブルID",
"description": "特殊値: off = 経路を管理しない、0 = 自動"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"post-up": {
"label": "Post-Up",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"post-down": {
"label": "Post-Down",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"disabled": {
"label": "インターフェース無効化"
},
"create-default-peer": {
"label": "新規ユーザー用にデフォルトピアを作成"
},
"save-config": {
"label": "wg-quick 設定を自動保存"
},
"defaults": {
"endpoint": {
"label": "エンドポイントアドレス",
"placeholder": "エンドポイントアドレス",
"description": "ピアが接続するエンドポイントアドレス。(例: wg.example.com または wg.example.com:51820)"
},
"networks": {
"label": "IP ネットワーク",
"placeholder": "ネットワークアドレス",
"description": "ピアにはこれらのサブネットから IP が割り当てられます。"
},
"allowed-ip": {
"label": "Allowed IPs",
"placeholder": "デフォルトの Allowed IPs"
},
"mtu": {
"label": "MTU",
"placeholder": "クライアント MTU (0 = デフォルト)"
},
"keep-alive": {
"label": "キープアライブ間隔",
"placeholder": "Persistent Keepalive (0 = デフォルト)"
}
},
"button-apply-defaults": "ピアのデフォルトを適用",
"button-create-default-peers": "デフォルトピアを作成",
"confirm-delete": "インターフェース '{id}' を削除してもよろしいですか?"
},
"peer-view": {
"headline-peer": "ピア:",
"headline-endpoint": "エンドポイント:",
"section-info": "ピア情報",
"section-status": "現在の状態",
"section-config": "設定",
"identifier": "識別子",
"ip": "IPアドレス",
"allowed-ip": "Allowed IPs",
"extra-allowed-ip": "サーバー側 Allowed IPs",
"user": "関連ユーザー",
"notes": "備考",
"expiry-status": "有効期限",
"disabled-status": "無効化日時",
"traffic": "通信量",
"connection-status": "接続統計",
"upload": "アップロード(サーバー → ピア、バイト)",
"download": "ダウンロード(ピア → サーバー、バイト)",
"pingable": "ping 応答",
"handshake": "最終ハンドシェイク",
"connected-since": "接続開始日時",
"endpoint": "エンドポイント",
"endpoint-key": "エンドポイント公開鍵",
"keepalive": "Persistent Keepalive",
"button-download": "設定をダウンロード",
"button-email": "設定をメール送信",
"style-label": "設定スタイル"
},
"peer-edit": {
"headline-edit-peer": "ピア編集:",
"headline-edit-endpoint": "エンドポイント編集:",
"headline-new-peer": "ピア作成",
"headline-new-endpoint": "エンドポイント作成",
"header-general": "全般",
"header-network": "ネットワーク",
"header-crypto": "暗号化",
"header-hooks": "フック (ピア側で実行)",
"header-state": "状態",
"display-name": {
"label": "表示名",
"placeholder": "ピアの説明的な名前"
},
"linked-user": {
"label": "関連ユーザー",
"placeholder": "このピアを所有するユーザーアカウント"
},
"private-key": {
"label": "秘密鍵",
"placeholder": "秘密鍵",
"help": "秘密鍵はサーバー上に安全に保存されます。すでにユーザーがコピーを持っている場合は空欄でも構いません。サーバーはピアの公開鍵のみで動作します。"
},
"public-key": {
"label": "公開鍵",
"placeholder": "公開鍵"
},
"preshared-key": {
"label": "事前共有鍵",
"placeholder": "オプションの事前共有鍵"
},
"endpoint-public-key": {
"label": "エンドポイント公開鍵",
"placeholder": "リモートエンドポイントの公開鍵"
},
"endpoint": {
"label": "エンドポイントアドレス",
"placeholder": "リモートエンドポイントのアドレス"
},
"ip": {
"label": "IPアドレス",
"placeholder": "IPアドレス (CIDR 形式)"
},
"allowed-ip": {
"label": "Allowed IPs",
"placeholder": "Allowed IPs (CIDR 形式)"
},
"extra-allowed-ip": {
"label": "追加の Allowed IPs",
"placeholder": "追加 Allowed IPs (サーバー側)",
"description": "これらの IP はリモート WireGuard インターフェースの Allowed IPs に追加されます。"
},
"dns": {
"label": "DNSサーバー",
"placeholder": "使用するDNSサーバー"
},
"dns-search": {
"label": "DNS 検索ドメイン",
"placeholder": "DNS 検索プレフィックス"
},
"keep-alive": {
"label": "キープアライブ間隔",
"placeholder": "Persistent Keepalive (0 = デフォルト)"
},
"mtu": {
"label": "MTU",
"placeholder": "クライアント MTU (0 = デフォルト)"
},
"pre-up": {
"label": "Pre-Up",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"post-up": {
"label": "Post-Up",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"pre-down": {
"label": "Pre-Down",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"post-down": {
"label": "Post-Down",
"placeholder": "; で区切られた1つ以上の bash コマンド"
},
"disabled": {
"label": "ピア無効化"
},
"ignore-global": {
"label": "グローバル設定を無視"
},
"expires-at": {
"label": "有効期限"
},
"confirm-delete": "ピア '{id}' を削除してもよろしいですか?"
},
"peer-multi-create": {
"headline-peer": "複数ピア作成",
"headline-endpoint": "複数エンドポイント作成",
"identifiers": {
"label": "ユーザー識別子",
"placeholder": "ユーザー識別子",
"description": "ピアを作成するユーザー識別子(ユーザー名)。"
},
"prefix": {
"headline-peer": "ピア:",
"headline-endpoint": "エンドポイント:",
"label": "表示名プレフィックス",
"placeholder": "プレフィックス",
"description": "ピア表示名に追加されるプレフィックス。"
}
}
}
}

View File

@@ -6,8 +6,10 @@ import {authStore} from '@/stores/auth'
import {securityStore} from '@/stores/security'
import {notify} from "@kyvg/vue3-notification";
const routerBase = `${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}`
const router = createRouter({
history: createWebHashHistory(),
history: createWebHashHistory(routerBase),
routes: [
{
path: '/',

View File

@@ -83,7 +83,9 @@ const externalLogin = function (provider) {
console.log("Performing external login for provider", provider.Identifier);
loggingIn.value = true;
console.log(router.currentRoute.value);
let currentUri = window.location.origin + "/#" + router.currentRoute.value.fullPath;
const currentUrl = new URL(`${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}`, window.location.origin);
currentUrl.hash = router.currentRoute.value.fullPath;
let currentUri = currentUrl.toString();
let redirectUrl = `${WGPORTAL_BACKEND_BASE_URL}${provider.ProviderUrl}`;
redirectUrl += "?redirect=true";
redirectUrl += "&return=" + encodeURIComponent(currentUri);

37
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-pkgz/routegroup v1.6.0
github.com/go-playground/validator/v10 v10.30.2
github.com/go-webauthn/webauthn v0.16.4
github.com/go-webauthn/webauthn v0.17.3
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/prometheus-community/pro-bing v0.8.0
@@ -22,31 +22,33 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/compressed v1.0.1
golang.org/x/crypto v0.50.0
golang.org/x/crypto v0.51.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.43.0
golang.org/x/sys v0.44.0
golang.org/x/text v0.37.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlserver v1.6.3
gorm.io/gorm v1.31.1
pgregory.net/rapid v1.2.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v0.23.0 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
@@ -58,10 +60,10 @@ require (
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-sql-driver/mysql v1.10.0 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.3 // indirect
github.com/go-webauthn/x v0.2.5 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
@@ -69,16 +71,16 @@ require (
github.com/google/go-tpm v0.9.8 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mdlayher/genetlink v1.4.0 // indirect
github.com/mdlayher/netlink v1.11.0 // indirect
github.com/mdlayher/netlink v1.11.2 // indirect
github.com/mdlayher/socket v0.6.0 // indirect
github.com/microsoft/go-mssqldb v1.9.8 // indirect
github.com/microsoft/go-mssqldb v1.10.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
@@ -95,16 +97,15 @@ require (
github.com/yeqown/reedsolomon v1.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.2 // indirect
modernc.org/sqlite v1.50.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

43
go.sum
View File

@@ -5,6 +5,7 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQ
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
@@ -14,6 +15,7 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
@@ -22,6 +24,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfg
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
@@ -50,6 +54,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@@ -64,6 +70,8 @@ github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baD
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-openapi/jsonpointer v0.23.0 h1:c25HFTJ6uWGmoe5BQI6p72p4o7KnlWYsy1MeFlAumsw=
github.com/go-openapi/jsonpointer v0.23.0/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
@@ -101,14 +109,20 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4CyGN+1Q=
github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4=
github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
@@ -145,6 +159,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -178,15 +194,21 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o=
github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA=
github.com/mdlayher/netlink v1.11.0 h1:Cot7ixQZL6P/pxRFB4z3jRdGPYeZosFT+WHS3sMXy8Y=
github.com/mdlayher/netlink v1.11.0/go.mod h1:rMwDzh42W85uW3yTtiTRZFX9uway98aDQ5i+D8Jq4g4=
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.8 h1:d4IFMvF/o+HdpXUqbBfzHvn/NlFA75YGcfHUUvDFJEM=
github.com/microsoft/go-mssqldb v1.9.8/go.mod h1:eGSRSGAW4hKMy5YcAenhCDjIRm2rhqIdmmwgciMzLus=
github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
@@ -280,6 +302,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -288,6 +312,8 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -307,6 +333,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -340,6 +368,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -370,6 +400,8 @@ golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -378,6 +410,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
@@ -406,8 +440,10 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -418,19 +454,26 @@ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-pkgz/routegroup"
@@ -23,9 +24,9 @@ type AuthenticationService interface {
// PlainLogin authenticates a user with a username and password.
PlainLogin(ctx context.Context, username, password string) (*domain.User, error)
// OauthLoginStep1 initiates the OAuth login flow.
OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce string, err error)
OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce, codeVerifier string, err error)
// OauthLoginStep2 completes the OAuth login flow and logins the user in.
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, string, error)
OauthLoginStep2(ctx context.Context, providerId, nonce, code, codeVerifier string) (*domain.User, string, error)
// OauthProviderLogoutUrl returns an IdP logout URL for the given provider if supported.
OauthProviderLogoutUrl(providerId, idTokenHint, postLogoutRedirectUri string) (string, bool)
}
@@ -201,9 +202,8 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
provider := request.Path(r, "provider")
var returnUrl *url.URL
var returnParams string
redirectToReturn := func() {
respond.Redirect(w, r, http.StatusFound, returnUrl.String()+"?"+returnParams)
redirectToReturn := func(loginState string) {
respond.Redirect(w, r, http.StatusFound, e.returnUrlWithLoginState(returnUrl, loginState))
}
if returnTo != "" {
@@ -212,21 +212,18 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"})
return
}
if u, err := url.Parse(returnTo); err == nil {
returnUrl = u
u, err := url.Parse(returnTo)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"})
return
}
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "err") // by default, we set the state to error
returnUrl.RawQuery = "" // remove potential query params
returnParams = queryParams.Encode()
returnUrl = u
}
if currentSession.LoggedIn {
if autoRedirect && e.isValidReturnUrl(returnTo) {
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
if autoRedirect && returnUrl != nil {
redirectToReturn("success")
} else {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "already logged in"})
@@ -234,12 +231,12 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
return
}
authCodeUrl, state, nonce, err := e.authService.OauthLoginStep1(context.Background(), provider)
authCodeUrl, state, nonce, codeVerifier, err := e.authService.OauthLoginStep1(context.Background(), provider)
if err != nil {
slog.Debug("failed to create oauth auth code URL",
"provider", provider, "error", err)
if autoRedirect && e.isValidReturnUrl(returnTo) {
redirectToReturn()
if autoRedirect && returnUrl != nil {
redirectToReturn("err")
} else {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
@@ -250,6 +247,7 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
authSession := e.session.GetData(r.Context())
authSession.OauthState = state
authSession.OauthNonce = nonce
authSession.OauthCodeVerifier = codeVerifier
authSession.OauthProvider = provider
authSession.OauthReturnTo = returnTo
e.session.SetData(r.Context(), authSession)
@@ -278,27 +276,19 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
currentSession := e.session.GetData(r.Context())
var returnUrl *url.URL
var returnParams string
redirectToReturn := func() {
respond.Redirect(w, r, http.StatusFound, returnUrl.String()+"?"+returnParams)
redirectToReturn := func(loginState string) {
respond.Redirect(w, r, http.StatusFound, e.returnUrlWithLoginState(returnUrl, loginState))
}
if currentSession.OauthReturnTo != "" {
if currentSession.OauthReturnTo != "" && e.isValidReturnUrl(currentSession.OauthReturnTo) {
if u, err := url.Parse(currentSession.OauthReturnTo); err == nil {
returnUrl = u
}
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "err") // by default, we set the state to error
returnUrl.RawQuery = "" // remove potential query params
returnParams = queryParams.Encode()
}
if currentSession.LoggedIn {
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
if returnUrl != nil {
redirectToReturn("success")
} else {
respond.JSON(w, http.StatusBadRequest, model.Error{Message: "already logged in"})
}
@@ -312,8 +302,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
if provider != currentSession.OauthProvider {
slog.Debug("invalid oauth provider in callback",
"expected", currentSession.OauthProvider, "got", provider, "state", oauthState)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
redirectToReturn()
if returnUrl != nil {
redirectToReturn("err")
} else {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid oauth provider"})
@@ -323,8 +313,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
if oauthState != currentSession.OauthState {
slog.Debug("invalid oauth state in callback",
"expected", currentSession.OauthState, "got", oauthState, "provider", provider)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
redirectToReturn()
if returnUrl != nil {
redirectToReturn("err")
} else {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid oauth state"})
@@ -334,13 +324,13 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits
user, idTokenHint, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce,
oauthCode)
oauthCode, currentSession.OauthCodeVerifier)
cancel()
if err != nil {
slog.Debug("failed to process oauth code",
"provider", provider, "state", oauthState, "error", err)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
redirectToReturn()
if returnUrl != nil {
redirectToReturn("err")
} else {
respond.JSON(w, http.StatusUnauthorized,
model.Error{Code: http.StatusUnauthorized, Message: err.Error()})
@@ -350,11 +340,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
e.setAuthenticatedUser(r, user, provider, idTokenHint)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
if returnUrl != nil {
redirectToReturn("success")
} else {
respond.JSON(w, http.StatusOK, user)
}
@@ -376,6 +363,7 @@ func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User, o
currentSession.OauthState = ""
currentSession.OauthNonce = ""
currentSession.OauthCodeVerifier = ""
currentSession.OauthProvider = oauthProvider
currentSession.OauthReturnTo = ""
currentSession.OauthIdToken = idTokenHint
@@ -444,11 +432,7 @@ func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc {
return
}
postLogoutRedirectUri := e.cfg.Web.ExternalUrl
if e.cfg.Web.BasePath != "" {
postLogoutRedirectUri += e.cfg.Web.BasePath
}
postLogoutRedirectUri += "/#/login"
postLogoutRedirectUri := e.frontendUrl("/login")
var redirectUrl *string
if currentSession.OauthProvider != "" {
@@ -479,9 +463,60 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
return false
}
if e.cfg.Web.BasePath != "" {
expectedPath := e.cfg.Web.BasePath + "/app"
if returnUrlParsed.Path != expectedPath && !strings.HasPrefix(returnUrlParsed.Path, expectedPath+"/") {
return false
}
}
return true
}
func (e AuthEndpoint) frontendUrl(route string) string {
frontendUrl := e.cfg.Web.ExternalUrl + e.cfg.Web.BasePath + "/app/"
if route != "" {
frontendUrl += "#" + route
}
return frontendUrl
}
func (e AuthEndpoint) returnUrlWithLoginState(returnUrl *url.URL, loginState string) string {
if returnUrl == nil {
frontendURL, err := url.Parse(e.frontendUrl("/login"))
if err != nil {
return e.frontendUrl("/login")
}
returnUrl = frontendURL
}
redirectUrl := *returnUrl
if redirectUrl.Fragment != "" {
fragmentPath := redirectUrl.Fragment
fragmentQuery := ""
if queryStart := strings.Index(fragmentPath, "?"); queryStart >= 0 {
fragmentQuery = fragmentPath[queryStart+1:]
fragmentPath = fragmentPath[:queryStart]
}
queryParams, err := url.ParseQuery(fragmentQuery)
if err != nil {
queryParams = url.Values{}
}
queryParams.Set("wgLoginState", loginState)
redirectUrl.Fragment = fragmentPath + "?" + queryParams.Encode()
return redirectUrl.String()
}
queryParams := redirectUrl.Query()
queryParams.Set("wgLoginState", loginState)
redirectUrl.RawQuery = queryParams.Encode()
return redirectUrl.String()
}
// handleWebAuthnCredentialsGet returns a gorm Handler function.
//
// @ID auth_handleWebAuthnCredentialsGet

View File

@@ -0,0 +1,114 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/h44z/wg-portal/internal/config"
)
type testSession struct {
data SessionData
}
func (s *testSession) SetData(_ context.Context, val SessionData) {
s.data = val
}
func (s *testSession) GetData(_ context.Context) SessionData {
return s.data
}
func (s *testSession) DestroyData(_ context.Context) {
s.data = SessionData{}
}
func newBasePathAuthEndpoint(session Session) AuthEndpoint {
return AuthEndpoint{
cfg: &config.Config{
Web: config.WebConfig{
ExternalUrl: "https://wg.example.com",
BasePath: "/subpath",
},
},
session: session,
}
}
func TestAuthEndpointIsValidReturnUrlRequiresBasePathApp(t *testing.T) {
ep := newBasePathAuthEndpoint(&testSession{})
valid := []string{
"https://wg.example.com/subpath/app/#/login",
"https://wg.example.com/subpath/app/#/login?all=true",
"https://wg.example.com/subpath/app/?beforeHash=true#/login",
}
for _, returnURL := range valid {
if !ep.isValidReturnUrl(returnURL) {
t.Fatalf("expected return URL to be valid: %s", returnURL)
}
}
invalid := []string{
"https://wg.example.com/#/login",
"https://wg.example.com/subpath/#/login",
"https://other.example.com/subpath/app/#/login",
}
for _, returnURL := range invalid {
if ep.isValidReturnUrl(returnURL) {
t.Fatalf("expected return URL to be invalid: %s", returnURL)
}
}
}
func TestAuthEndpointOauthCallbackRedirectsToBasePathHashRoute(t *testing.T) {
session := &testSession{data: SessionData{
LoggedIn: true,
OauthReturnTo: "https://wg.example.com/subpath/app/#/login",
}}
ep := newBasePathAuthEndpoint(session)
req := httptest.NewRequest(http.MethodGet, "/api/v0/auth/login/google/callback", nil)
req.SetPathValue("provider", "google")
res := httptest.NewRecorder()
ep.handleOauthCallbackGet().ServeHTTP(res, req)
if res.Code != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, res.Code)
}
if got, want := res.Header().Get("Location"), "https://wg.example.com/subpath/app/#/login?wgLoginState=success"; got != want {
t.Fatalf("expected redirect %q, got %q", want, got)
}
}
func TestAuthEndpointReturnUrlWithLoginStatePreservesHashQuery(t *testing.T) {
session := &testSession{data: SessionData{
LoggedIn: true,
OauthReturnTo: "https://wg.example.com/subpath/app/#/login?all=true",
}}
ep := newBasePathAuthEndpoint(session)
req := httptest.NewRequest(http.MethodGet, "/api/v0/auth/login/google/callback", nil)
req.SetPathValue("provider", "google")
res := httptest.NewRecorder()
ep.handleOauthCallbackGet().ServeHTTP(res, req)
if res.Code != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, res.Code)
}
if got, want := res.Header().Get("Location"), "https://wg.example.com/subpath/app/#/login?all=true&wgLoginState=success"; got != want {
t.Fatalf("expected redirect %q, got %q", want, got)
}
}
func TestAuthEndpointFrontendUrlUsesBasePathAppMount(t *testing.T) {
ep := newBasePathAuthEndpoint(&testSession{})
if got, want := ep.frontendUrl("/login"), "https://wg.example.com/subpath/app/#/login"; got != want {
t.Fatalf("expected frontend URL %q, got %q", want, got)
}
}

View File

@@ -26,11 +26,12 @@ type SessionData struct {
Lastname string
Email string
OauthState string
OauthNonce string
OauthProvider string
OauthReturnTo string
OauthIdToken string
OauthState string
OauthNonce string
OauthCodeVerifier string
OauthProvider string
OauthReturnTo string
OauthIdToken string
WebAuthnData string
@@ -80,16 +81,17 @@ func (s *SessionWrapper) DestroyData(ctx context.Context) {
func (s *SessionWrapper) defaultSessionData() SessionData {
return SessionData{
LoggedIn: false,
IsAdmin: false,
UserIdentifier: "",
Firstname: "",
Lastname: "",
Email: "",
OauthState: "",
OauthNonce: "",
OauthProvider: "",
OauthReturnTo: "",
OauthIdToken: "",
LoggedIn: false,
IsAdmin: false,
UserIdentifier: "",
Firstname: "",
Lastname: "",
Email: "",
OauthState: "",
OauthNonce: "",
OauthCodeVerifier: "",
OauthProvider: "",
OauthReturnTo: "",
OauthIdToken: "",
}
}

View File

@@ -47,6 +47,11 @@ const (
AuthenticatorTypeOidc AuthenticatorType = "oidc"
)
const (
pkceMethodS256 = "S256" // SHA-256 hashing
pkceMethodPlain = "plain" // plain text
)
// AuthenticatorOauth is the interface for all OAuth authenticators.
type AuthenticatorOauth interface {
// GetName returns the name of the authenticator.
@@ -70,6 +75,10 @@ type AuthenticatorOauth interface {
GetAllowedUserGroups() []string
// GetLogoutUrl returns an IdP logout URL if supported by the provider.
GetLogoutUrl(idTokenHint, postLogoutRedirectUri string) (string, bool)
// PKCEAuthCodeOptions returns PKCE options for the authorization request and the verifier for the token exchange.
PKCEAuthCodeOptions() ([]oauth2.AuthCodeOption, string)
// PKCETokenOptions returns PKCE options for the token exchange.
PKCETokenOptions(verifier string) []oauth2.AuthCodeOption
}
// AuthenticatorLdap is the interface for all LDAP authenticators.
@@ -448,30 +457,34 @@ func (a *Authenticator) passwordAuthentication(
// OauthLoginStep1 starts the oauth authentication flow by returning the authentication URL, state and nonce.
func (a *Authenticator) OauthLoginStep1(_ context.Context, providerId string) (
authCodeUrl, state, nonce string,
authCodeUrl, state, nonce, codeVerifier string,
err error,
) {
oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok {
return "", "", "", fmt.Errorf("missing oauth provider %s", providerId)
return "", "", "", "", fmt.Errorf("missing oauth provider %s", providerId)
}
// Prepare authentication flow, set state cookies
state, err = a.randString(16)
if err != nil {
return "", "", "", fmt.Errorf("failed to generate state: %w", err)
return "", "", "", "", fmt.Errorf("failed to generate state: %w", err)
}
// Generate PKCE code verifier and challenge if enabled. Otherwise, options will be empty.
authCodeOptions, codeVerifier := oauthProvider.PKCEAuthCodeOptions()
switch oauthProvider.GetType() {
case AuthenticatorTypeOAuth:
authCodeUrl = oauthProvider.AuthCodeURL(state)
authCodeUrl = oauthProvider.AuthCodeURL(state, authCodeOptions...)
case AuthenticatorTypeOidc:
nonce, err = a.randString(16)
if err != nil {
return "", "", "", fmt.Errorf("failed to generate nonce: %w", err)
return "", "", "", "", fmt.Errorf("failed to generate nonce: %w", err)
}
authCodeUrl = oauthProvider.AuthCodeURL(state, oidc.Nonce(nonce))
authCodeOptions = append(authCodeOptions, oidc.Nonce(nonce))
authCodeUrl = oauthProvider.AuthCodeURL(state, authCodeOptions...)
}
return
@@ -531,13 +544,16 @@ func isAnyAllowedUserGroup(userGroups, allowedUserGroups []string) bool {
// OauthLoginStep2 finishes the oauth authentication flow by exchanging the code for an access token and
// fetching the user information.
func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, string, error) {
func (a *Authenticator) OauthLoginStep2(
ctx context.Context,
providerId, nonce, code, codeVerifier string,
) (*domain.User, string, error) {
oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok {
return nil, "", fmt.Errorf("missing oauth provider %s", providerId)
}
oauth2Token, err := oauthProvider.Exchange(ctx, code)
oauth2Token, err := oauthProvider.Exchange(ctx, code, oauthProvider.PKCETokenOptions(codeVerifier)...)
if err != nil {
return nil, "", fmt.Errorf("unable to exchange code: %w", err)
}

View File

@@ -149,5 +149,9 @@ func (l LdapAuthenticator) ParseUserInfo(raw map[string]any) (*domain.Authentica
AdminInfoAvailable: adminInfoAvailable,
}
if err := userInfo.Sanitize("ldap", l.cfg.ProviderName); err != nil {
return nil, err
}
return userInfo, nil
}

View File

@@ -0,0 +1,98 @@
package auth
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/testutil"
)
// makeLdapAuthenticator creates a minimal LdapAuthenticator for testing ParseUserInfo.
func makeLdapAuthenticator() *LdapAuthenticator {
return &LdapAuthenticator{
cfg: &config.LdapProvider{
ProviderName: "test-ldap",
FieldMap: config.LdapFields{
BaseFields: config.BaseFields{
UserIdentifier: "uid",
Email: "mail",
Firstname: "givenName",
Lastname: "sn",
Phone: "telephoneNumber",
Department: "department",
},
GroupMembership: "", // no group membership check
},
},
}
}
// makeRawLdapMap builds a minimal raw LDAP attribute map for ParseUserInfo.
func makeRawLdapMap(uid, mail, givenName, sn, phone, department string) map[string]any {
return map[string]any{
"uid": uid,
"mail": mail,
"givenName": givenName,
"sn": sn,
"telephoneNumber": phone,
"department": department,
}
}
// Test: firstname contains \x00 → output firstname has no null byte,
// one WARN log entry with field: "firstname".
func TestLdapParseUserInfo_NullByteInFirstname(t *testing.T) {
auth := makeLdapAuthenticator()
raw := makeRawLdapMap("alice", "alice@example.com", "Ali\x00ce", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
info, err := auth.ParseUserInfo(raw)
records := restore()
require.NoError(t, err)
assert.NotContains(t, info.Firstname, "\x00", "firstname should have null byte removed")
assert.Equal(t, "Alice", info.Firstname)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount, "expected exactly one WARN log entry")
rec, found := testutil.FindWarnWithField(records, "firstname")
assert.True(t, found, "expected WARN log entry with field=firstname")
if found {
assert.Equal(t, "WARN", rec["level"])
}
}
// Test: all fields clean → no WARN log entries emitted.
func TestLdapParseUserInfo_AllFieldsClean(t *testing.T) {
auth := makeLdapAuthenticator()
raw := makeRawLdapMap("alice", "alice@example.com", "Alice", "Smith", "+1 555-1234", "Engineering")
restore := testutil.CaptureWarnLogs(t)
info, err := auth.ParseUserInfo(raw)
records := restore()
require.NoError(t, err)
assert.Equal(t, domain.UserIdentifier("alice"), info.Identifier)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 0, warnCount, "expected no WARN log entries when all fields are clean")
}
// Test: identifier is "all" → returns ErrInvalidData.
func TestLdapParseUserInfo_IdentifierAll(t *testing.T) {
auth := makeLdapAuthenticator()
raw := makeRawLdapMap("all", "all@example.com", "Alice", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
_, err := auth.ParseUserInfo(raw)
_ = restore()
require.Error(t, err)
assert.True(t, errors.Is(err, domain.ErrInvalidData), "expected ErrInvalidData when identifier is 'all'")
}

View File

@@ -30,6 +30,8 @@ type PlainOauthAuthenticator struct {
sensitiveInfoLogging bool
allowedDomains []string
allowedUserGroups []string
usePKCE bool
pkceMethod string
}
func newPlainOauthAuthenticator(
@@ -62,6 +64,14 @@ func newPlainOauthAuthenticator(
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains
provider.allowedUserGroups = cfg.AllowedUserGroups
provider.usePKCE = cfg.UsePKCE == nil || *cfg.UsePKCE
provider.pkceMethod = cfg.PKCEMethod
if provider.pkceMethod == "" {
provider.pkceMethod = pkceMethodS256
}
if provider.usePKCE && provider.pkceMethod != pkceMethodS256 && provider.pkceMethod != pkceMethodPlain {
return nil, fmt.Errorf("unsupported PKCE method %q, allowed: S256, plain", provider.pkceMethod)
}
return provider, nil
}
@@ -83,6 +93,32 @@ func (p PlainOauthAuthenticator) GetLogoutUrl(_, _ string) (string, bool) {
return "", false
}
// PKCEAuthCodeOptions returns PKCE options for the authorization request and the verifier for the token exchange.
func (p PlainOauthAuthenticator) PKCEAuthCodeOptions() ([]oauth2.AuthCodeOption, string) {
if !p.usePKCE {
return nil, ""
}
verifier := oauth2.GenerateVerifier()
if p.pkceMethod == pkceMethodPlain {
return []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_challenge", verifier),
oauth2.SetAuthURLParam("code_challenge_method", pkceMethodPlain),
}, verifier
}
return []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(verifier)}, verifier
}
// PKCETokenOptions returns PKCE options for the token exchange.
func (p PlainOauthAuthenticator) PKCETokenOptions(verifier string) []oauth2.AuthCodeOption {
if !p.usePKCE || verifier == "" {
return nil
}
return []oauth2.AuthCodeOption{oauth2.VerifierOption(verifier)}
}
// RegistrationEnabled returns whether registration is enabled for the OAuth authenticator.
func (p PlainOauthAuthenticator) RegistrationEnabled() bool {
return p.registrationEnabled
@@ -155,5 +191,5 @@ func (p PlainOauthAuthenticator) GetUserInfo(
// ParseUserInfo parses the user information from the raw data.
func (p PlainOauthAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) {
return parseOauthUserInfo(p.userInfoMapping, p.userAdminMapping, raw)
return parseOauthUserInfo(p.userInfoMapping, p.userAdminMapping, raw, "oauth", p.name)
}

View File

@@ -0,0 +1,61 @@
package auth
import (
"testing"
"golang.org/x/oauth2"
)
func TestPlainOauthAuthenticatorPKCES256Options(t *testing.T) {
authenticator := PlainOauthAuthenticator{usePKCE: true, pkceMethod: "S256"}
options, verifier := authenticator.PKCEAuthCodeOptions()
if verifier == "" {
t.Fatal("expected verifier")
}
values := authCodeValues(t, options)
if values.Get("code_challenge") == "" {
t.Fatal("expected code_challenge")
}
if values.Get("code_challenge_method") != "S256" {
t.Fatalf("expected S256 challenge method, got %q", values.Get("code_challenge_method"))
}
tokenOptions := authenticator.PKCETokenOptions(verifier)
if len(tokenOptions) != 1 {
t.Fatalf("expected one token option, got %d", len(tokenOptions))
}
}
func TestPlainOauthAuthenticatorPKCEPlainOptions(t *testing.T) {
authenticator := PlainOauthAuthenticator{usePKCE: true, pkceMethod: "plain"}
options, verifier := authenticator.PKCEAuthCodeOptions()
values := authCodeValues(t, options)
if values.Get("code_challenge") != verifier {
t.Fatalf("expected plain challenge %q, got %q", verifier, values.Get("code_challenge"))
}
if values.Get("code_challenge_method") != "plain" {
t.Fatalf("expected plain challenge method, got %q", values.Get("code_challenge_method"))
}
}
func TestPlainOauthAuthenticatorPKCEDisabled(t *testing.T) {
authenticator := PlainOauthAuthenticator{usePKCE: false, pkceMethod: "S256"}
options, verifier := authenticator.PKCEAuthCodeOptions()
if len(options) != 0 {
t.Fatalf("expected no auth code options, got %d", len(options))
}
if verifier != "" {
t.Fatalf("expected empty verifier, got %q", verifier)
}
tokenOptions := authenticator.PKCETokenOptions(oauth2.GenerateVerifier())
if len(tokenOptions) != 0 {
t.Fatalf("expected no token options, got %d", len(tokenOptions))
}
}

View File

@@ -30,6 +30,8 @@ type OidcAuthenticator struct {
allowedUserGroups []string
endSessionEndpoint string
logoutIdpSession bool
usePKCE bool
pkceMethod string
}
func newOidcAuthenticator(
@@ -67,6 +69,14 @@ func newOidcAuthenticator(
provider.allowedDomains = cfg.AllowedDomains
provider.allowedUserGroups = cfg.AllowedUserGroups
provider.logoutIdpSession = cfg.LogoutIdpSession == nil || *cfg.LogoutIdpSession
provider.usePKCE = cfg.UsePKCE == nil || *cfg.UsePKCE
provider.pkceMethod = cfg.PKCEMethod
if provider.pkceMethod == "" {
provider.pkceMethod = pkceMethodS256
}
if provider.usePKCE && provider.pkceMethod != pkceMethodS256 && provider.pkceMethod != pkceMethodPlain {
return nil, fmt.Errorf("unsupported PKCE method %q, allowed: S256, plain", provider.pkceMethod)
}
var providerMetadata struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
@@ -121,6 +131,32 @@ func (o OidcAuthenticator) GetLogoutUrl(idTokenHint, postLogoutRedirectUri strin
return logoutUrl.String(), true
}
// PKCEAuthCodeOptions returns PKCE options for the authorization request and the verifier for the token exchange.
func (o OidcAuthenticator) PKCEAuthCodeOptions() ([]oauth2.AuthCodeOption, string) {
if !o.usePKCE {
return nil, ""
}
verifier := oauth2.GenerateVerifier()
if o.pkceMethod == pkceMethodPlain {
return []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_challenge", verifier),
oauth2.SetAuthURLParam("code_challenge_method", pkceMethodPlain),
}, verifier
}
return []oauth2.AuthCodeOption{oauth2.S256ChallengeOption(verifier)}, verifier
}
// PKCETokenOptions returns PKCE options for the token exchange.
func (o OidcAuthenticator) PKCETokenOptions(verifier string) []oauth2.AuthCodeOption {
if !o.usePKCE || verifier == "" {
return nil
}
return []oauth2.AuthCodeOption{oauth2.VerifierOption(verifier)}
}
// RegistrationEnabled returns whether registration is enabled for this authenticator.
func (o OidcAuthenticator) RegistrationEnabled() bool {
return o.registrationEnabled
@@ -194,5 +230,5 @@ func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token,
// ParseUserInfo parses the user info.
func (o OidcAuthenticator) ParseUserInfo(raw map[string]any) (*domain.AuthenticatorUserInfo, error) {
return parseOauthUserInfo(o.userInfoMapping, o.userAdminMapping, raw)
return parseOauthUserInfo(o.userInfoMapping, o.userAdminMapping, raw, "oidc", o.name)
}

View File

@@ -0,0 +1,79 @@
package auth
import (
"net/url"
"testing"
"golang.org/x/oauth2"
)
func authCodeValues(t *testing.T, options []oauth2.AuthCodeOption) url.Values {
t.Helper()
config := oauth2.Config{
ClientID: "client-id",
Endpoint: oauth2.Endpoint{AuthURL: "https://example.com/auth"},
RedirectURL: "https://wg.example.com/callback",
}
authCodeURL, err := url.Parse(config.AuthCodeURL("state", options...))
if err != nil {
t.Fatalf("failed to parse auth code URL: %v", err)
}
return authCodeURL.Query()
}
func TestOidcAuthenticatorPKCES256Options(t *testing.T) {
authenticator := OidcAuthenticator{usePKCE: true, pkceMethod: "S256"}
options, verifier := authenticator.PKCEAuthCodeOptions()
if verifier == "" {
t.Fatal("expected verifier")
}
values := authCodeValues(t, options)
if values.Get("code_challenge") == "" {
t.Fatal("expected code_challenge")
}
if values.Get("code_challenge_method") != "S256" {
t.Fatalf("expected S256 challenge method, got %q", values.Get("code_challenge_method"))
}
tokenOptions := authenticator.PKCETokenOptions(verifier)
if len(tokenOptions) != 1 {
t.Fatalf("expected one token option, got %d", len(tokenOptions))
}
}
func TestOidcAuthenticatorPKCEPlainOptions(t *testing.T) {
authenticator := OidcAuthenticator{usePKCE: true, pkceMethod: "plain"}
options, verifier := authenticator.PKCEAuthCodeOptions()
values := authCodeValues(t, options)
if values.Get("code_challenge") != verifier {
t.Fatalf("expected plain challenge %q, got %q", verifier, values.Get("code_challenge"))
}
if values.Get("code_challenge_method") != "plain" {
t.Fatalf("expected plain challenge method, got %q", values.Get("code_challenge_method"))
}
}
func TestOidcAuthenticatorPKCEDisabled(t *testing.T) {
authenticator := OidcAuthenticator{usePKCE: false, pkceMethod: "S256"}
options, verifier := authenticator.PKCEAuthCodeOptions()
if len(options) != 0 {
t.Fatalf("expected no auth code options, got %d", len(options))
}
if verifier != "" {
t.Fatalf("expected empty verifier, got %q", verifier)
}
tokenOptions := authenticator.PKCETokenOptions(oauth2.GenerateVerifier())
if len(tokenOptions) != 0 {
t.Fatalf("expected no token options, got %d", len(tokenOptions))
}
}

View File

@@ -13,6 +13,8 @@ func parseOauthUserInfo(
mapping config.OauthFields,
adminMapping *config.OauthAdminMapping,
raw map[string]any,
providerType string,
providerName string,
) (*domain.AuthenticatorUserInfo, error) {
var isAdmin bool
var adminInfoAvailable bool
@@ -27,18 +29,6 @@ func parseOauthUserInfo(
}
}
// next try to parse the user's groups
if !isAdmin && mapping.UserGroups != "" && adminMapping.AdminGroupRegex != "" {
adminInfoAvailable = true
re := adminMapping.GetAdminGroupRegex()
for _, group := range userGroups {
if re.MatchString(strings.TrimSpace(group)) {
isAdmin = true
break
}
}
}
userInfo := &domain.AuthenticatorUserInfo{
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, mapping.UserIdentifier, "")),
Email: internal.MapDefaultString(raw, mapping.Email, ""),
@@ -51,6 +41,24 @@ func parseOauthUserInfo(
AdminInfoAvailable: adminInfoAvailable,
}
if err := userInfo.Sanitize(providerType, providerName); err != nil {
return nil, err
}
// check admin group match after sanitization
if !isAdmin && mapping.UserGroups != "" && adminMapping.AdminGroupRegex != "" {
adminInfoAvailable = true
re := adminMapping.GetAdminGroupRegex()
for _, group := range userInfo.UserGroups {
if re.MatchString(group) {
isAdmin = true
break
}
}
userInfo.IsAdmin = isAdmin
userInfo.AdminInfoAvailable = adminInfoAvailable
}
return userInfo, nil
}

View File

@@ -0,0 +1,148 @@
package auth
import (
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/testutil"
)
// makeOauthFieldMapping returns a minimal OauthFields mapping for testing.
func makeOauthFieldMapping() config.OauthFields {
return config.OauthFields{
BaseFields: config.BaseFields{
UserIdentifier: "sub",
Email: "email",
Firstname: "given_name",
Lastname: "family_name",
Phone: "phone",
Department: "department",
},
}
}
// makeOauthRaw builds a minimal raw OAuth user info map.
func makeOauthRaw(sub, email, givenName, familyName, phone, department string) map[string]any {
return map[string]any{
"sub": sub,
"email": email,
"given_name": givenName,
"family_name": familyName,
"phone": phone,
"department": department,
}
}
// Test: email containing \r\n → output email is "",
// one WARN log entry with field: "email" and cleared indication.
func TestParseOauthUserInfo_CRLFInEmail(t *testing.T) {
mapping := makeOauthFieldMapping()
adminMapping := &config.OauthAdminMapping{}
raw := makeOauthRaw("user123", "user\r\n@example.com", "Alice", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
info, err := parseOauthUserInfo(mapping, adminMapping, raw, "oauth", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, "", info.Email, "email should be cleared when it contains CR/LF")
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount, "expected exactly one WARN log entry")
rec, found := testutil.FindWarnWithField(records, "email")
assert.True(t, found, "expected WARN log entry with field=email")
if found {
msg, _ := rec["msg"].(string)
assert.Contains(t, msg, "cleared", "expected 'cleared' in log message when email is cleared")
}
}
// Test: two fields modified (email cleared, firstname truncated) →
// two separate WARN log entries.
func TestParseOauthUserInfo_TwoFieldsModified(t *testing.T) {
mapping := makeOauthFieldMapping()
adminMapping := &config.OauthAdminMapping{}
longFirstname := strings.Repeat("A", 200)
raw := makeOauthRaw("user123", "bad\r\nemail@example.com", longFirstname, "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
info, err := parseOauthUserInfo(mapping, adminMapping, raw, "oauth", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, "", info.Email, "email should be cleared")
assert.Equal(t, 128, len([]rune(info.Firstname)), "firstname should be truncated to 128 runes")
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 2, warnCount, "expected exactly two WARN log entries (one per modified field)")
_, emailFound := testutil.FindWarnWithField(records, "email")
assert.True(t, emailFound, "expected WARN log entry with field=email")
_, firstnameFound := testutil.FindWarnWithField(records, "firstname")
assert.True(t, firstnameFound, "expected WARN log entry with field=firstname")
}
// Test: identifier "all" → returns ErrInvalidData.
func TestParseOauthUserInfo_IdentifierAll(t *testing.T) {
mapping := makeOauthFieldMapping()
adminMapping := &config.OauthAdminMapping{}
raw := makeOauthRaw("all", "all@example.com", "Alice", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
_, err := parseOauthUserInfo(mapping, adminMapping, raw, "oauth", "test-provider")
_ = restore()
require.Error(t, err)
assert.True(t, errors.Is(err, domain.ErrInvalidData), "expected ErrInvalidData when identifier is 'all'")
}
func TestParseOauthUserInfo_DropsModifiedGroupBeforeAdminMatch(t *testing.T) {
mapping := makeOauthFieldMapping()
mapping.UserGroups = "groups"
adminMapping := &config.OauthAdminMapping{
AdminGroupRegex: "^wgportal-admins$",
}
raw := makeOauthRaw("user123", "user@example.com", "Alice", "Smith", "", "")
raw["groups"] = []any{"wgportal-\u200badmins"}
restore := testutil.CaptureWarnLogs(t)
info, err := parseOauthUserInfo(mapping, adminMapping, raw, "oidc", "test-provider")
records := restore()
require.NoError(t, err)
require.NotNil(t, info)
assert.False(t, info.IsAdmin, "sanitization must not repair a modified group into an admin match")
assert.Empty(t, info.UserGroups)
rec, found := testutil.FindWarnWithField(records, "user_group")
assert.True(t, found, "expected WARN log entry with field=user_group")
if found {
assert.Equal(t, "oidc", rec["provider_type"])
}
}
func TestParseOauthUserInfo_AllowsWhitespaceOnlyGroupTrim(t *testing.T) {
mapping := makeOauthFieldMapping()
mapping.UserGroups = "groups"
adminMapping := &config.OauthAdminMapping{
AdminGroupRegex: "^wgportal-admins$",
}
raw := makeOauthRaw("user123", "user@example.com", "Alice", "Smith", "", "")
raw["groups"] = []any{" wgportal-admins "}
info, err := parseOauthUserInfo(mapping, adminMapping, raw, "oidc", "test-provider")
require.NoError(t, err)
require.NotNil(t, info)
assert.True(t, info.IsAdmin)
assert.Equal(t, []string{"wgportal-admins"}, info.UserGroups)
}

View File

@@ -43,7 +43,7 @@ func Test_parseOauthUserInfo_no_admin(t *testing.T) {
})
adminMapping := &config.OauthAdminMapping{}
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo)
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo, "oauth", "test-provider")
assert.NoError(t, err)
assert.False(t, info.IsAdmin)
assert.Equal(t, info.Firstname, "Test User")
@@ -90,7 +90,7 @@ func Test_parseOauthUserInfo_admin_group(t *testing.T) {
AdminGroupRegex: "^wgportal-admins@mydomain.net$",
}
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo)
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo, "oauth", "test-provider")
assert.NoError(t, err)
assert.True(t, info.IsAdmin)
assert.Equal(t, info.Firstname, "Test User")
@@ -132,7 +132,7 @@ func Test_parseOauthUserInfo_admin_value(t *testing.T) {
})
adminMapping := &config.OauthAdminMapping{} // test with default regex
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo)
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo, "oauth", "test-provider")
assert.NoError(t, err)
assert.True(t, info.IsAdmin)
assert.Equal(t, info.Firstname, "Test User")
@@ -175,7 +175,7 @@ func Test_parseOauthUserInfo_admin_value_custom(t *testing.T) {
AdminValueRegex: "^1$",
}
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo)
info, err := parseOauthUserInfo(fieldMapping, adminMapping, userInfo, "oauth", "test-provider")
assert.NoError(t, err)
assert.True(t, info.IsAdmin)
assert.Equal(t, info.Firstname, "Test User")

View File

@@ -0,0 +1,90 @@
package auth
import (
"bytes"
"encoding/json"
"log/slog"
"testing"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/testutil"
)
// captureWarnLogsInline redirects the default slog logger to a buffer, calls fn,
// restores the original logger, and returns the captured log records.
func captureWarnLogsInline(fn func()) []map[string]any {
original := slog.Default()
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
slog.SetDefault(slog.New(handler))
fn()
slog.SetDefault(original)
var records []map[string]any
decoder := json.NewDecoder(&buf)
for decoder.More() {
var rec map[string]any
if err := decoder.Decode(&rec); err == nil {
records = append(records, rec)
}
}
return records
}
// Property 7: Sanitization change logging completeness
func TestPropertySanitizationChangeLoggingCompleteness(t *testing.T) {
mapping := makeOauthFieldMapping()
adminMapping := &config.OauthAdminMapping{}
rapid.Check(t, func(t *rapid.T) {
sub := rapid.StringMatching(`[a-zA-Z0-9_@.-]{1,50}`).Draw(t, "sub")
email := rapid.String().Draw(t, "email")
firstname := rapid.String().Draw(t, "firstname")
lastname := rapid.String().Draw(t, "lastname")
phone := rapid.String().Draw(t, "phone")
department := rapid.String().Draw(t, "department")
if sub == "" {
sub = "testuser"
}
raw := makeOauthRaw(sub, email, firstname, lastname, phone, department)
// Count how many fields will actually change after sanitization
expectedChanges := 0
if domain.SanitizeIdentifier(sub, 256) != sub {
expectedChanges++
}
if domain.SanitizeEmail(email, 254) != email {
expectedChanges++
}
if domain.SanitizeString(firstname, 128) != firstname {
expectedChanges++
}
if domain.SanitizeString(lastname, 128) != lastname {
expectedChanges++
}
if domain.SanitizePhone(phone, 50) != phone {
expectedChanges++
}
if domain.SanitizeString(department, 128) != department {
expectedChanges++
}
var records []map[string]any
records = captureWarnLogsInline(func() {
_, _ = parseOauthUserInfo(mapping, adminMapping, raw, "oauth", "test-provider")
})
actualWarnCount := testutil.CountWarnEntries(records)
require.Equal(t, expectedChanges, actualWarnCount,
"number of WARN log entries (%d) must equal number of fields changed by sanitization (%d)",
actualWarnCount, expectedChanges)
})
}

View File

@@ -28,7 +28,7 @@ func convertRawLdapUser(
uid := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
return &domain.User{
user := &domain.User{
BaseModel: domain.BaseModel{
CreatedBy: domain.CtxSystemLdapSyncer,
UpdatedBy: domain.CtxSystemLdapSyncer,
@@ -49,10 +49,16 @@ func convertRawLdapUser(
Lastname: internal.MapDefaultString(rawUser, fields.Lastname, ""),
Phone: internal.MapDefaultString(rawUser, fields.Phone, ""),
Department: internal.MapDefaultString(rawUser, fields.Department, ""),
Notes: "",
Password: "",
Disabled: nil,
}, nil
}
if err := user.SanitizeExternalData("ldap", providerName); err != nil {
return nil, err
}
// Update authentication identifier after sanitization
user.Authentications[0].UserIdentifier = user.Identifier
return user, nil
}
func userChangedInLdap(dbUser, ldapUser *domain.User) bool {

View File

@@ -0,0 +1,136 @@
package users
import (
"errors"
"testing"
"github.com/go-ldap/ldap/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/h44z/wg-portal/internal/testutil"
)
// makeTestLdapFields returns a minimal LdapFields config for testing.
func makeTestLdapFields() *config.LdapFields {
return &config.LdapFields{
BaseFields: config.BaseFields{
UserIdentifier: "uid",
Email: "mail",
Firstname: "givenName",
Lastname: "sn",
Phone: "telephoneNumber",
Department: "department",
},
GroupMembership: "memberOf",
}
}
// makeTestAdminGroupDN returns a parsed DN for testing (a non-matching group).
func makeTestAdminGroupDN(t *testing.T) *ldap.DN {
t.Helper()
dn, err := ldap.ParseDN("cn=admins,dc=example,dc=com")
require.NoError(t, err)
return dn
}
// makeRawLdapUser builds a raw LDAP user map for convertRawLdapUser.
func makeRawLdapUser(uid, mail, givenName, sn, phone, department string) map[string]any {
return map[string]any{
"uid": uid,
"mail": mail,
"givenName": givenName,
"sn": sn,
"telephoneNumber": phone,
"department": department,
"memberOf": [][]byte{}, // no group memberships
}
}
// Test: identifier "all" → returns ErrInvalidData,
// one WARN log entry with field: "identifier" and cleared indication.
func TestConvertRawLdapUser_IdentifierAll(t *testing.T) {
fields := makeTestLdapFields()
adminGroupDN := makeTestAdminGroupDN(t)
raw := makeRawLdapUser("all", "all@example.com", "Alice", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
user, err := convertRawLdapUser("test-ldap", raw, fields, adminGroupDN)
records := restore()
require.Error(t, err)
assert.True(t, errors.Is(err, domain.ErrInvalidData), "expected ErrInvalidData when identifier is 'all'")
assert.Nil(t, user)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount, "expected exactly one WARN log entry")
rec, found := testutil.FindWarnWithField(records, "identifier")
assert.True(t, found, "expected WARN log entry with field=identifier")
if found {
msg, _ := rec["msg"].(string)
assert.Contains(t, msg, "cleared", "expected 'cleared' in log message when identifier is cleared")
}
}
// Test: firstname contains \x00 → output firstname has null byte removed,
// one WARN log entry with field: "firstname".
func TestConvertRawLdapUser_NullByteInFirstname(t *testing.T) {
fields := makeTestLdapFields()
adminGroupDN := makeTestAdminGroupDN(t)
raw := makeRawLdapUser("alice", "alice@example.com", "Ali\x00ce", "Smith", "", "")
restore := testutil.CaptureWarnLogs(t)
user, err := convertRawLdapUser("test-ldap", raw, fields, adminGroupDN)
records := restore()
require.NoError(t, err)
require.NotNil(t, user)
assert.NotContains(t, user.Firstname, "\x00", "firstname should have null byte removed")
assert.Equal(t, "Alice", user.Firstname)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount, "expected exactly one WARN log entry")
rec, found := testutil.FindWarnWithField(records, "firstname")
assert.True(t, found, "expected WARN log entry with field=firstname")
if found {
assert.Equal(t, "WARN", rec["level"])
}
}
// Test: all fields clean → no WARN log entries emitted.
func TestConvertRawLdapUser_AllFieldsClean(t *testing.T) {
fields := makeTestLdapFields()
adminGroupDN := makeTestAdminGroupDN(t)
raw := makeRawLdapUser("alice", "alice@example.com", "Alice", "Smith", "+1 555-1234", "Engineering")
restore := testutil.CaptureWarnLogs(t)
user, err := convertRawLdapUser("test-ldap", raw, fields, adminGroupDN)
records := restore()
require.NoError(t, err)
require.NotNil(t, user)
assert.Equal(t, domain.UserIdentifier("alice"), user.Identifier)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 0, warnCount, "expected no WARN log entries when all fields are clean")
}
func TestLdapUserIdentifier_NormalizesSyncComparisons(t *testing.T) {
raw := map[string]any{"uid": " alice\x00 "}
got := ldapUserIdentifier(raw, "uid")
assert.Equal(t, domain.UserIdentifier("alice"), got)
}
func TestLdapUserIdentifier_RejectsReservedIdentifier(t *testing.T) {
raw := map[string]any{"uid": " all "}
got := ldapUserIdentifier(raw, "uid")
assert.Empty(t, got)
}

View File

@@ -109,6 +109,11 @@ func (m Manager) updateLdapUsers(
for _, rawUser := range rawUsers {
user, err := convertRawLdapUser(provider.ProviderName, rawUser, fields, adminGroupDN)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
if errors.Is(err, domain.ErrInvalidData) {
slog.Warn("skipping LDAP user with invalid data after sanitization",
"raw-dn", rawUser["dn"], "error", err)
continue
}
return fmt.Errorf("failed to convert LDAP data for %v: %w", rawUser["dn"], err)
}
@@ -212,7 +217,7 @@ func (m Manager) disableMissingLdapUsers(
existsInLDAP := false
for _, rawUser := range rawUsers {
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, fields.UserIdentifier, ""))
userId := ldapUserIdentifier(rawUser, fields.UserIdentifier)
if user.Identifier == userId {
existsInLDAP = true
break
@@ -258,19 +263,19 @@ func (m Manager) updateInterfaceLdapFilters(
// Combined filter: user must match the provider's base SyncFilter AND the interface's LdapGroupFilter
combinedFilter := fmt.Sprintf("(&(%s)(%s))", provider.SyncFilter, groupFilter)
rawUsers, err := internal.LdapFindAllUsers(conn, provider.BaseDN, combinedFilter, &provider.FieldMap)
if err != nil {
slog.Error("failed to find users for interface filter",
"interface", ifaceId,
"provider", provider.ProviderName,
slog.Error("failed to find users for interface filter",
"interface", ifaceId,
"provider", provider.ProviderName,
"error", err)
continue
}
matchedUserIds := make([]domain.UserIdentifier, 0, len(rawUsers))
for _, rawUser := range rawUsers {
userId := domain.UserIdentifier(internal.MapDefaultString(rawUser, provider.FieldMap.UserIdentifier, ""))
userId := ldapUserIdentifier(rawUser, provider.FieldMap.UserIdentifier)
if userId != "" {
matchedUserIds = append(matchedUserIds, userId)
}
@@ -285,17 +290,26 @@ func (m Manager) updateInterfaceLdapFilters(
return i, nil
})
if err != nil {
slog.Error("failed to save interface ldap allowed users",
"interface", ifaceId,
"provider", provider.ProviderName,
slog.Error("failed to save interface ldap allowed users",
"interface", ifaceId,
"provider", provider.ProviderName,
"error", err)
} else {
slog.Debug("updated interface ldap allowed users",
"interface", ifaceId,
"provider", provider.ProviderName,
slog.Debug("updated interface ldap allowed users",
"interface", ifaceId,
"provider", provider.ProviderName,
"matched_count", len(matchedUserIds))
}
}
return nil
}
func ldapUserIdentifier(rawUser map[string]any, field string) domain.UserIdentifier {
identifier := internal.MapDefaultString(rawUser, field, "")
identifier = domain.SanitizeIdentifier(identifier, 256)
if identifier == "" {
return ""
}
return domain.UserIdentifier(identifier)
}

View File

@@ -365,19 +365,7 @@ func (m Manager) validateCreation(ctx context.Context, new *domain.User) error {
return fmt.Errorf("invalid user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == "all" { // the 'all' user identifier collides with the rest api routes
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == "new" { // the 'new' user identifier collides with the rest api routes
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == "id" { // the 'id' user identifier collides with the rest api routes
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}
if new.Identifier == domain.CtxSystemAdminId || new.Identifier == domain.CtxUnknownUserId {
if domain.IsReservedUserIdentifier(new.Identifier) {
return fmt.Errorf("reserved user identifier: %w", domain.ErrInvalidData)
}

View File

@@ -279,6 +279,14 @@ type OpenIDConnectProvider struct {
// This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"`
// UsePKCE controls whether Proof Key for Code Exchange is used during the authorization code flow.
// If unset, PKCE is enabled by default.
UsePKCE *bool `yaml:"use_pkce"`
// PKCEMethod controls which PKCE challenge method is used. Supported values are "S256" and "plain".
// If empty, "S256" is used.
PKCEMethod string `yaml:"pkce_method"`
// LogoutIdpSession controls whether the user's session at the OIDC provider is terminated on logout.
// If set to true (default), the user will be redirected to the IdP's end_session_endpoint after local logout.
// If set to false, only the local wg-portal session is invalidated.
@@ -332,6 +340,14 @@ type OAuthProvider struct {
// If LogSensitiveInfo is set to true, sensitive information retrieved from the OAuth provider will be logged in trace level.
// This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"`
// UsePKCE controls whether Proof Key for Code Exchange is used during the authorization code flow.
// If unset, PKCE is enabled by default.
UsePKCE *bool `yaml:"use_pkce"`
// PKCEMethod controls which PKCE challenge method is used. Supported values are "S256" and "plain".
// If empty, "S256" is used.
PKCEMethod string `yaml:"pkce_method"`
}
// WebauthnConfig contains the configuration for the WebAuthn authenticator.

View File

@@ -1,5 +1,10 @@
package domain
import (
"fmt"
"strings"
)
type LoginProvider string
type LoginProviderInfo struct {
@@ -20,3 +25,52 @@ type AuthenticatorUserInfo struct {
IsAdmin bool
AdminInfoAvailable bool // true if the IsAdmin flag is valid
}
// Sanitize sanitizes all external identity provider fields in place.
// Returns ErrInvalidData if the identifier becomes empty after sanitization.
func (u *AuthenticatorUserInfo) Sanitize(providerType, providerName string) error {
identifier := string(u.Identifier)
LogSanitizeChange(providerType, providerName, "identifier", identifier,
func() string { return SanitizeIdentifier(identifier, 256) }, &identifier)
u.Identifier = UserIdentifier(identifier)
email := u.Email
LogSanitizeChange(providerType, providerName, "email", email,
func() string { return SanitizeEmail(email, 254) }, &u.Email)
LogSanitizeChange(providerType, providerName, "firstname", u.Firstname,
func() string { return SanitizeString(u.Firstname, 128) }, &u.Firstname)
LogSanitizeChange(providerType, providerName, "lastname", u.Lastname,
func() string { return SanitizeString(u.Lastname, 128) }, &u.Lastname)
LogSanitizeChange(providerType, providerName, "phone", u.Phone,
func() string { return SanitizePhone(u.Phone, 50) }, &u.Phone)
LogSanitizeChange(providerType, providerName, "department", u.Department,
func() string { return SanitizeString(u.Department, 128) }, &u.Department)
u.UserGroups = sanitizeGroups(providerType, providerName, u.UserGroups)
if u.Identifier == "" {
return fmt.Errorf("empty user identifier: %w", ErrInvalidData)
}
return nil
}
// sanitizeGroups sanitizes group names, dropping any that were modified by sanitization.
func sanitizeGroups(providerType, providerName string, rawGroups []string) []string {
if len(rawGroups) == 0 {
return rawGroups
}
groups := make([]string, 0, len(rawGroups))
for _, rawGroup := range rawGroups {
sanitized := rawGroup
LogSanitizeChange(providerType, providerName, "user_group", rawGroup,
func() string { return SanitizeString(rawGroup, 256) }, &sanitized)
if sanitized == "" || sanitized != strings.TrimSpace(rawGroup) {
continue
}
groups = append(groups, sanitized)
}
return groups
}

View File

@@ -0,0 +1,103 @@
package domain
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/testutil"
)
func TestAuthenticatorUserInfo_Sanitize_NullByteInFirstname(t *testing.T) {
info := &AuthenticatorUserInfo{
Identifier: "alice",
Email: "alice@example.com",
Firstname: "Ali\x00ce",
Lastname: "Smith",
}
restore := testutil.CaptureWarnLogs(t)
err := info.Sanitize("ldap", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, "Alice", info.Firstname)
warnCount := testutil.CountWarnEntries(records)
assert.Equal(t, 1, warnCount)
_, found := testutil.FindWarnWithField(records, "firstname")
assert.True(t, found)
}
func TestAuthenticatorUserInfo_Sanitize_AllFieldsClean(t *testing.T) {
info := &AuthenticatorUserInfo{
Identifier: "alice",
Email: "alice@example.com",
Firstname: "Alice",
Lastname: "Smith",
Phone: "+1 555-1234",
Department: "Engineering",
}
restore := testutil.CaptureWarnLogs(t)
err := info.Sanitize("ldap", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, UserIdentifier("alice"), info.Identifier)
assert.Equal(t, 0, testutil.CountWarnEntries(records))
}
func TestAuthenticatorUserInfo_Sanitize_IdentifierAll(t *testing.T) {
info := &AuthenticatorUserInfo{
Identifier: "all",
Email: "all@example.com",
Firstname: "Alice",
Lastname: "Smith",
}
err := info.Sanitize("ldap", "test-provider")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidData))
}
func TestAuthenticatorUserInfo_Sanitize_CRLFInEmail(t *testing.T) {
info := &AuthenticatorUserInfo{
Identifier: "user123",
Email: "user\r\n@example.com",
Firstname: "Alice",
Lastname: "Smith",
}
restore := testutil.CaptureWarnLogs(t)
err := info.Sanitize("oauth", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, "", info.Email)
_, found := testutil.FindWarnWithField(records, "email")
assert.True(t, found)
}
func TestAuthenticatorUserInfo_Sanitize_GroupsWithZeroWidthChars(t *testing.T) {
info := &AuthenticatorUserInfo{
Identifier: "user123",
Email: "user@example.com",
UserGroups: []string{"wgportal-\u200badmins"},
}
restore := testutil.CaptureWarnLogs(t)
err := info.Sanitize("oidc", "test-provider")
records := restore()
require.NoError(t, err)
assert.Empty(t, info.UserGroups)
_, found := testutil.FindWarnWithField(records, "user_group")
assert.True(t, found)
}

151
internal/domain/sanitize.go Normal file
View File

@@ -0,0 +1,151 @@
package domain
import (
"log/slog"
"net/mail"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/unicode/norm"
)
// LogSanitizeChange applies sanitizeFn to raw, logs when the value changes, and writes
// the sanitized value to dest. Raw and sanitized values are intentionally omitted.
func LogSanitizeChange(
providerType string,
providerName string,
field string,
raw string,
sanitizeFn func() string,
dest *string,
) {
sanitized := sanitizeFn()
if sanitized != raw {
message := "sanitization modified field value from external provider"
if sanitized == "" {
message = "sanitization cleared field value from external provider"
}
slog.Warn(message,
"provider_type", SanitizeString(providerType, 64),
"provider", SanitizeString(providerName, 128),
"field", SanitizeString(field, 64),
)
}
*dest = sanitized
}
var reservedUserIdentifiers = map[string]struct{}{
"all": {},
"new": {},
"id": {},
CtxSystemAdminId: {},
CtxUnknownUserId: {},
CtxSystemLdapSyncer: {},
CtxSystemWgImporter: {},
CtxSystemV1Migrator: {},
CtxSystemDBMigrator: {},
}
// SanitizeString normalizes to NFC, trims leading and trailing whitespace, strips Unicode
// control and format characters, drops invalid UTF-8 bytes, and truncates the result to
// maxLen runes. If maxLen <= 0, returns "".
func SanitizeString(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
s = norm.NFC.String(strings.TrimSpace(s))
var b strings.Builder
b.Grow(len(s))
for len(s) > 0 {
r, size := utf8.DecodeRuneInString(s)
s = s[size:]
if r == utf8.RuneError && size == 1 {
continue
}
if !unicode.IsControl(r) && !unicode.Is(unicode.Cf, r) {
b.WriteRune(r)
}
}
s = b.String()
if utf8.RuneCountInString(s) > maxLen {
runes := []rune(s)
s = string(runes[:maxLen])
}
return strings.TrimSpace(s)
}
// SanitizeEmail applies SanitizeString first, then returns "" if the original s
// contains CR/LF or if the sanitized result is not a plain email address.
func SanitizeEmail(s string, maxLen int) string {
if strings.ContainsRune(s, '\r') || strings.ContainsRune(s, '\n') {
return ""
}
sanitized := SanitizeString(s, maxLen)
if sanitized == "" || strings.Count(sanitized, "@") != 1 {
return ""
}
addr, err := mail.ParseAddress(sanitized)
if err != nil || addr.Name != "" || addr.Address != sanitized {
return ""
}
return sanitized
}
// SanitizePhone applies SanitizeString first, then removes all characters not in the
// set [0-9+\-() .]. Returns "" if the result after filtering is empty.
func SanitizePhone(s string, maxLen int) string {
sanitized := SanitizeString(s, maxLen)
// Remove all characters not in [0-9+\-() .]
var b strings.Builder
b.Grow(len(sanitized))
for _, r := range sanitized {
if isAllowedPhoneRune(r) {
b.WriteRune(r)
}
}
result := strings.TrimSpace(b.String())
if result == "" {
return ""
}
return result
}
// isAllowedPhoneRune reports whether r is in the allowed phone character set [0-9+\-() .].
func isAllowedPhoneRune(r rune) bool {
switch {
case r >= '0' && r <= '9':
return true
case r == '+', r == '-', r == '(', r == ')', r == ' ', r == '.':
return true
default:
return false
}
}
// SanitizeIdentifier applies SanitizeString first, then returns "" if the result equals
// a reserved user identifier (case-sensitive, exact match).
func SanitizeIdentifier(s string, maxLen int) string {
sanitized := SanitizeString(s, maxLen)
if IsReservedUserIdentifier(UserIdentifier(sanitized)) {
return ""
}
return sanitized
}
func IsReservedUserIdentifier(identifier UserIdentifier) bool {
_, reserved := reservedUserIdentifiers[string(identifier)]
return reserved
}

View File

@@ -0,0 +1,503 @@
package domain
import (
"net/mail"
"strings"
"testing"
"unicode"
"unicode/utf8"
"pgregory.net/rapid"
)
func TestSanitizeString(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
want string
}{
{
name: "null byte removed",
input: "\x00",
maxLen: 64,
want: "",
},
{
name: "CR removed",
input: "\r",
maxLen: 64,
want: "",
},
{
name: "LF removed",
input: "\n",
maxLen: 64,
want: "",
},
{
name: "tab removed",
input: "\t",
maxLen: 64,
want: "",
},
{
name: "leading and trailing whitespace trimmed",
input: " hello ",
maxLen: 64,
want: "hello",
},
{
name: "multi-byte UTF-8 truncation at rune boundary",
input: "héllo",
maxLen: 3,
want: "hél", // 3 runes, not 3 bytes
},
{
name: "empty input",
input: "",
maxLen: 64,
want: "",
},
{
name: "maxLen zero returns empty",
input: "hello",
maxLen: 0,
want: "",
},
{
name: "string longer than maxLen truncated",
input: "abcdefgh",
maxLen: 4,
want: "abcd",
},
{
name: "mixed control chars and normal chars",
input: "hel\x00lo\r\nworld",
maxLen: 64,
want: "helloworld",
},
{
name: "only whitespace returns empty",
input: " ",
maxLen: 64,
want: "",
},
{
name: "string exactly at maxLen not truncated",
input: "abc",
maxLen: 3,
want: "abc",
},
{
name: "negative maxLen returns empty",
input: "hello",
maxLen: -1,
want: "",
},
{
name: "DEL control removed",
input: "hel\x7flo",
maxLen: 64,
want: "hello",
},
{
name: "zero-width format character removed",
input: "ali\u200bce",
maxLen: 64,
want: "alice",
},
{
name: "invalid UTF-8 byte removed",
input: "a\xffb",
maxLen: 64,
want: "ab",
},
{
name: "unicode normalized to NFC",
input: "e\u0301",
maxLen: 64,
want: "é",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SanitizeString(tc.input, tc.maxLen)
if got != tc.want {
t.Errorf("SanitizeString(%q, %d) = %q; want %q", tc.input, tc.maxLen, got, tc.want)
}
})
}
}
func TestSanitizeEmail(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
want string
}{
{
name: "valid email passes through unchanged",
input: "user@example.com",
maxLen: 254,
want: "user@example.com",
},
{
name: "CR in email returns empty",
input: "user\r@example.com",
maxLen: 254,
want: "",
},
{
name: "LF in email returns empty",
input: "user\n@example.com",
maxLen: 254,
want: "",
},
{
name: "missing @ returns empty",
input: "userexample.com",
maxLen: 254,
want: "",
},
{
name: "whitespace-only returns empty",
input: " ",
maxLen: 254,
want: "",
},
{
name: "email with leading/trailing whitespace trimmed and returned",
input: " user@example.com ",
maxLen: 254,
want: "user@example.com",
},
{
name: "empty input returns empty",
input: "",
maxLen: 254,
want: "",
},
{
name: "display-name address rejected",
input: "User <user@example.com>",
maxLen: 254,
want: "",
},
{
name: "multiple at signs rejected",
input: "user@@example.com",
maxLen: 254,
want: "",
},
{
name: "invalid address rejected",
input: "user@",
maxLen: 254,
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SanitizeEmail(tc.input, tc.maxLen)
if got != tc.want {
t.Errorf("SanitizeEmail(%q, %d) = %q; want %q", tc.input, tc.maxLen, got, tc.want)
}
})
}
}
func TestSanitizePhone(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
want string
}{
{
name: "valid phone passes through unchanged",
input: "+1 (555) 123-4567",
maxLen: 50,
want: "+1 (555) 123-4567",
},
{
name: "non-allowed chars stripped",
input: "abc+1def",
maxLen: 50,
want: "+1",
},
{
name: "all-stripped input returns empty",
input: "abc",
maxLen: 50,
want: "",
},
{
name: "mixed allowed and non-allowed chars",
input: "+49 (0) 123-456.789",
maxLen: 50,
want: "+49 (0) 123-456.789",
},
{
name: "empty input returns empty",
input: "",
maxLen: 50,
want: "",
},
{
name: "only digits passes through",
input: "1234567890",
maxLen: 50,
want: "1234567890",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SanitizePhone(tc.input, tc.maxLen)
if got != tc.want {
t.Errorf("SanitizePhone(%q, %d) = %q; want %q", tc.input, tc.maxLen, got, tc.want)
}
})
}
}
func TestSanitizeIdentifier(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
want string
}{
{
name: "reserved value all returns empty",
input: "all",
maxLen: 256,
want: "",
},
{
name: "all with surrounding whitespace returns empty",
input: " all ",
maxLen: 256,
want: "",
},
{
name: "reserved value new returns empty",
input: "new",
maxLen: 256,
want: "",
},
{
name: "reserved value id returns empty",
input: "id",
maxLen: 256,
want: "",
},
{
name: "system admin identifier returns empty",
input: string(CtxSystemAdminId),
maxLen: 256,
want: "",
},
{
name: "unknown user identifier returns empty",
input: string(CtxUnknownUserId),
maxLen: 256,
want: "",
},
{
name: "LDAP syncer identifier returns empty",
input: string(CtxSystemLdapSyncer),
maxLen: 256,
want: "",
},
{
name: "ALL uppercase passes through (case-sensitive)",
input: "ALL",
maxLen: 256,
want: "ALL",
},
{
name: "valid email identifier passes through",
input: "alice@example.com",
maxLen: 256,
want: "alice@example.com",
},
{
name: "normal identifier passes through",
input: "alice",
maxLen: 256,
want: "alice",
},
{
name: "empty input returns empty",
input: "",
maxLen: 256,
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SanitizeIdentifier(tc.input, tc.maxLen)
if got != tc.want {
t.Errorf("SanitizeIdentifier(%q, %d) = %q; want %q", tc.input, tc.maxLen, got, tc.want)
}
})
}
}
func TestSanitizeXSSPayload(t *testing.T) {
// XSS payload: null byte removed, angle brackets preserved
input := "<script>\x00alert(1)</script>"
want := "<script>alert(1)</script>"
got := SanitizeString(input, 256)
if got != want {
t.Errorf("SanitizeString(%q, 256) = %q; want %q", input, got, want)
}
}
// ---------------------------------------------------------------------------
// Property 1: SanitizeString output invariants
// ---------------------------------------------------------------------------
// Feature: external-identity-sanitization, Property 1: SanitizeString output is free of control characters and bounded in length
func TestPropertySanitizeStringOutputInvariants(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
s := rapid.String().Draw(t, "s")
maxLen := rapid.IntRange(0, 512).Draw(t, "maxLen")
result := SanitizeString(s, maxLen)
// No control or format runes in result
for _, r := range result {
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
t.Fatalf("result %q contains unsafe character %U", result, r)
}
}
if !utf8.ValidString(result) {
t.Fatalf("result %q is not valid UTF-8", result)
}
// No leading or trailing whitespace
if result != strings.TrimSpace(result) {
t.Fatalf("result %q has leading or trailing whitespace", result)
}
// Rune count <= maxLen
runeCount := utf8.RuneCountInString(result)
if runeCount > maxLen {
t.Fatalf("result %q has %d runes, exceeds maxLen %d", result, runeCount, maxLen)
}
})
}
// ---------------------------------------------------------------------------
// Property 2: SanitizeString is idempotent
// ---------------------------------------------------------------------------
// Feature: external-identity-sanitization, Property 2: SanitizeString is idempotent
func TestPropertySanitizeStringIdempotent(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
s := rapid.String().Draw(t, "s")
maxLen := rapid.IntRange(1, 512).Draw(t, "maxLen")
once := SanitizeString(s, maxLen)
twice := SanitizeString(once, maxLen)
if once != twice {
t.Fatalf("SanitizeString is not idempotent: once=%q, twice=%q (input=%q, maxLen=%d)",
once, twice, s, maxLen)
}
})
}
// ---------------------------------------------------------------------------
// Property 3: SanitizeEmail rejection rules
// ---------------------------------------------------------------------------
// Feature: external-identity-sanitization, Property 3: SanitizeEmail rejects strings without "@" or containing CR/LF
func TestPropertySanitizeEmailRejectionRules(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
s := rapid.String().Draw(t, "s")
maxLen := rapid.IntRange(1, 512).Draw(t, "maxLen")
result := SanitizeEmail(s, maxLen)
sanitized := SanitizeString(s, maxLen)
addr, parseErr := mail.ParseAddress(sanitized)
reject := strings.ContainsAny(s, "\r\n") ||
sanitized == "" ||
strings.Count(sanitized, "@") != 1 ||
parseErr != nil ||
addr.Name != "" ||
addr.Address != sanitized
if reject {
if result != "" {
t.Fatalf("SanitizeEmail(%q, %d) = %q; expected empty string (contains CR/LF or no @)",
s, maxLen, result)
}
}
})
}
// ---------------------------------------------------------------------------
// Property 4: SanitizePhone allowed character set
// ---------------------------------------------------------------------------
// isAllowedPhoneCharTest mirrors the internal isAllowedPhoneRune logic for test assertions.
func isAllowedPhoneCharTest(r rune) bool {
switch {
case r >= '0' && r <= '9':
return true
case r == '+', r == '-', r == '(', r == ')', r == ' ', r == '.':
return true
default:
return false
}
}
// Feature: external-identity-sanitization, Property 4: SanitizePhone output contains only allowed characters
func TestPropertySanitizePhoneAllowedChars(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
s := rapid.String().Draw(t, "s")
maxLen := rapid.IntRange(1, 512).Draw(t, "maxLen")
result := SanitizePhone(s, maxLen)
for _, r := range result {
if !isAllowedPhoneCharTest(r) {
t.Fatalf("SanitizePhone(%q, %d) = %q; contains disallowed rune %U (%c)",
s, maxLen, result, r, r)
}
}
})
}
// ---------------------------------------------------------------------------
// Property 5: SanitizeIdentifier rejects reserved identifiers
// ---------------------------------------------------------------------------
// Feature: external-identity-sanitization, Property 5: SanitizeIdentifier rejects reserved values
func TestPropertySanitizeIdentifierRejectsReservedValues(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
s := rapid.String().Draw(t, "s")
maxLen := rapid.IntRange(1, 512).Draw(t, "maxLen")
result := SanitizeIdentifier(s, maxLen)
sanitized := SanitizeString(s, maxLen)
_, reserved := reservedUserIdentifiers[sanitized]
if reserved {
if result != "" {
t.Fatalf("SanitizeIdentifier(%q, %d) = %q; expected empty string when sanitized is reserved",
s, maxLen, result)
}
} else {
if result != sanitized {
t.Fatalf("SanitizeIdentifier(%q, %d) = %q; expected %q (== SanitizeString result)",
s, maxLen, result, sanitized)
}
}
})
}

View File

@@ -282,6 +282,32 @@ func (u *User) CreateDefaultPeers() bool {
return true
}
// SanitizeExternalData sanitizes user profile fields received from an external identity provider.
// Returns ErrInvalidData if the identifier becomes empty after sanitization.
func (u *User) SanitizeExternalData(providerType, providerName string) error {
identifier := string(u.Identifier)
LogSanitizeChange(providerType, providerName, "identifier", identifier,
func() string { return SanitizeIdentifier(identifier, 256) }, &identifier)
u.Identifier = UserIdentifier(identifier)
LogSanitizeChange(providerType, providerName, "email", u.Email,
func() string { return SanitizeEmail(u.Email, 254) }, &u.Email)
LogSanitizeChange(providerType, providerName, "firstname", u.Firstname,
func() string { return SanitizeString(u.Firstname, 128) }, &u.Firstname)
LogSanitizeChange(providerType, providerName, "lastname", u.Lastname,
func() string { return SanitizeString(u.Lastname, 128) }, &u.Lastname)
LogSanitizeChange(providerType, providerName, "phone", u.Phone,
func() string { return SanitizePhone(u.Phone, 50) }, &u.Phone)
LogSanitizeChange(providerType, providerName, "department", u.Department,
func() string { return SanitizeString(u.Department, 128) }, &u.Department)
if u.Identifier == "" {
return fmt.Errorf("empty user identifier: %w", ErrInvalidData)
}
return nil
}
// region webauthn
func (u *User) WebAuthnID() []byte {

View File

@@ -0,0 +1,64 @@
package domain
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/h44z/wg-portal/internal/testutil"
)
func TestUser_SanitizeExternalData_NullByteInFirstname(t *testing.T) {
u := &User{
Identifier: "alice",
Email: "alice@example.com",
Firstname: "Ali\x00ce",
Lastname: "Smith",
}
restore := testutil.CaptureWarnLogs(t)
err := u.SanitizeExternalData("ldap", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, "Alice", u.Firstname)
assert.Equal(t, 1, testutil.CountWarnEntries(records))
_, found := testutil.FindWarnWithField(records, "firstname")
assert.True(t, found)
}
func TestUser_SanitizeExternalData_IdentifierAll(t *testing.T) {
u := &User{
Identifier: "all",
Email: "all@example.com",
Firstname: "Alice",
Lastname: "Smith",
}
err := u.SanitizeExternalData("ldap", "test-provider")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidData))
}
func TestUser_SanitizeExternalData_AllFieldsClean(t *testing.T) {
u := &User{
Identifier: "alice",
Email: "alice@example.com",
Firstname: "Alice",
Lastname: "Smith",
Phone: "+1 555-1234",
Department: "Engineering",
}
restore := testutil.CaptureWarnLogs(t)
err := u.SanitizeExternalData("ldap", "test-provider")
records := restore()
require.NoError(t, err)
assert.Equal(t, UserIdentifier("alice"), u.Identifier)
assert.Equal(t, 0, testutil.CountWarnEntries(records))
}

32
internal/sanitize/log.go Normal file
View File

@@ -0,0 +1,32 @@
package sanitize
import (
"log/slog"
"github.com/h44z/wg-portal/internal/domain"
)
// LogChange applies sanitizeFn to raw, logs when the value changes, and writes
// the sanitized value to dest. Raw and sanitized values are intentionally omitted.
func LogChange(
providerType string,
providerName string,
field string,
raw string,
sanitizeFn func() string,
dest *string,
) {
sanitized := sanitizeFn()
if sanitized != raw {
message := "sanitization modified field value from external provider"
if sanitized == "" {
message = "sanitization cleared field value from external provider"
}
slog.Warn(message,
"provider_type", domain.SanitizeString(providerType, 64),
"provider", domain.SanitizeString(providerName, 128),
"field", domain.SanitizeString(field, 64),
)
}
*dest = sanitized
}

View File

@@ -0,0 +1,50 @@
package testutil
import (
"bytes"
"encoding/json"
"log/slog"
"testing"
)
func CaptureWarnLogs(t *testing.T) (restore func() []map[string]any) {
t.Helper()
original := slog.Default()
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
slog.SetDefault(slog.New(handler))
return func() []map[string]any {
slog.SetDefault(original)
var records []map[string]any
decoder := json.NewDecoder(&buf)
for decoder.More() {
var rec map[string]any
if err := decoder.Decode(&rec); err == nil {
records = append(records, rec)
}
}
return records
}
}
func CountWarnEntries(records []map[string]any) int {
count := 0
for _, r := range records {
if lvl, ok := r["level"].(string); ok && lvl == "WARN" {
count++
}
}
return count
}
func FindWarnWithField(records []map[string]any, fieldName string) (map[string]any, bool) {
for _, r := range records {
if lvl, ok := r["level"].(string); ok && lvl == "WARN" {
if f, ok := r["field"].(string); ok && f == fieldName {
return r, true
}
}
}
return nil, false
}