Skip to content

Commit 01867cf

Browse files
committed
Sync WLAN password (NT hash) to Keycloak on password change
When projectforge.keycloak.wlanPasswordAttribute is set, KeycloakMasterLoginHandler writes the Samba NT hash of the WLAN password to the configured Keycloak user attribute on every WLAN password change. Intended for RADIUS/WPA2-Enterprise setups where a RADIUS server reads the NT hash directly from Keycloak. - KeycloakConfig: add wlanPasswordAttribute property - KeycloakAdminClient: add getUserById() for fetching current user attributes before update - KeycloakMasterLoginHandler: implement syncWlanPasswordToKeycloak() - application.properties: document new property (commented out) - README.md: document WLAN password sync in Phase 2, user attributes table, and migration note (users must set a new WLAN password once to trigger sync)
1 parent ba26784 commit 01867cf

5 files changed

Lines changed: 81 additions & 9 deletions

File tree

projectforge-business/src/main/resources/application.properties

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ projectforge.keycloak.syncPasswords=false
4949
# Group attribute mappings: pfFieldName=keycloakAttributeName
5050
# Supported fields: description, organization
5151
# projectforge.keycloak.groupAttributes.description=description
52+
# WLAN password sync: Keycloak attribute name for the Samba NT hash of the WLAN password.
53+
# If set, the NT hash is written to this attribute on every WLAN password change (KeycloakMasterLoginHandler only).
54+
# projectforge.keycloak.wlanPasswordAttribute=sambaNTPassword
5255
hibernate.search.directory.root=${projectforge.base.dir}/hibernateSearch
5356
projectforge.resourcesDirectory=
5457
projectforge.fontsDirectory=

projectforge-keycloak/README.md

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,16 @@
1111

1212
Create the following custom user attributes with validators:
1313

14-
| Attribute | Min Length | Max Length | Notes |
15-
|----------------|------------|------------|-------------------------------------------------|
16-
| `jiraUsername` | 0 | 100 | length |
17-
| `mobilePhone` | 0 | 255 | length |
18-
| `gender` | | | options: `MALE`, `FEMALE`, `DIVERSE`, `UNKNOWN` |
19-
| `locale` | 0 || Validated automatically (`de`, `en`) |
20-
| `organization` | 0 | 255 | length |
21-
| `description` | 0 | 255 | length |
22-
| `nickname` | 0 | 255 | length |
14+
| Attribute | Min Length | Max Length | Notes |
15+
|------------------|------------|------------|-------------------------------------------------|
16+
| `jiraUsername` | 0 | 100 | length |
17+
| `mobilePhone` | 0 | 255 | length |
18+
| `gender` | | | options: `MALE`, `FEMALE`, `DIVERSE`, `UNKNOWN` |
19+
| `locale` | 0 || Validated automatically (`de`, `en`) |
20+
| `organization` | 0 | 255 | length |
21+
| `description` | 0 | 255 | length |
22+
| `nickname` | 0 | 255 | length |
23+
| `sambaNTWlanPassword`| 0 | 255 | WLAN password (Samba NT hash); see below |
2324

2425
## Keycloak Admin: Username Validator
2526

@@ -82,6 +83,24 @@ Additional property:
8283
projectforge.keycloak.syncPasswords=true
8384
```
8485

86+
#### WLAN Password Sync (optional)
87+
88+
If WLAN passwords are managed in PF (e.g. for RADIUS/WPA2-Enterprise), the Samba NT hash can be
89+
written to a custom Keycloak user attribute on every WLAN password change:
90+
91+
```properties
92+
# Keycloak attribute name that receives the Samba NT hash on every WLAN password change.
93+
projectforge.keycloak.wlanPasswordAttribute=sambaNTWlanPassword
94+
```
95+
96+
- The value stored is the **Samba NT hash** of the WLAN password (same format as LDAP `sambaNTWlanPassword`).
97+
- A RADIUS server can read this attribute from Keycloak to perform MS-CHAPv2 authentication.
98+
- The attribute must be created in the Keycloak realm beforehand (see User Attributes table above).
99+
- If `wlanPasswordAttribute` is not set, no WLAN password is written to Keycloak (default behaviour).
100+
- **Migration note:** The sync is triggered only when a user actively changes their WLAN password in PF.
101+
There is no bulk-sync of existing WLAN passwords. During migration, all users must therefore
102+
set a new WLAN password once so that the NT hash is written to Keycloak.
103+
85104
### Phase 3 — Keycloak as Master (target state)
86105

87106
**Handler:** `KeycloakLoginHandler`

projectforge-keycloak/src/main/kotlin/org/projectforge/keycloak/client/KeycloakAdminClient.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ private val log = KotlinLogging.logger {}
5050
* Writes (migration + password sync):
5151
* POST /admin/realms/{realm}/users → createUser()
5252
* PUT /admin/realms/{realm}/users/{id} → updateUser()
53+
*
54+
* Single-user read:
55+
* GET /admin/realms/{realm}/users/{id} → getUserById()
5356
* POST /admin/realms/{realm}/groups → createGroup()
5457
* PUT /admin/realms/{realm}/users/{id}/groups/{groupId} → addUserToGroup()
5558
* DELETE /admin/realms/{realm}/users/{id}/groups/{groupId} → removeUserFromGroup()
@@ -176,6 +179,15 @@ open class KeycloakAdminClient(
176179
}
177180
}
178181

182+
/**
183+
* Returns a single Keycloak user by their ID, or null if not found.
184+
*/
185+
fun getUserById(userId: String): KeycloakUser? {
186+
val url = "$adminBaseUrl/users/$userId"
187+
val response = restTemplate.exchange(url, HttpMethod.GET, HttpEntity<Void>(bearerHeaders()), KeycloakUser::class.java)
188+
return response.body
189+
}
190+
179191
/**
180192
* Finds a Keycloak user by username. Returns null if not found.
181193
* Uses the search endpoint for exact username matching.

projectforge-keycloak/src/main/kotlin/org/projectforge/keycloak/config/KeycloakConfig.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ open class KeycloakConfig {
9595
*/
9696
var syncPasswords: Boolean = false
9797

98+
/**
99+
* Keycloak attribute name for the WLAN (Samba NT) password hash.
100+
* If set, the NT hash is written to this Keycloak user attribute on every WLAN password change.
101+
* Example: projectforge.keycloak.wlanPasswordAttribute=sambaNTPassword
102+
*/
103+
var wlanPasswordAttribute: String? = null
104+
98105
fun isConfigured() = serverUrl.isNotBlank() && realm.isNotBlank()
99106
&& clientId.isNotBlank() && clientSecret.isNotBlank()
100107
}

projectforge-keycloak/src/main/kotlin/org/projectforge/keycloak/handler/KeycloakMasterLoginHandler.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
package org.projectforge.keycloak.handler
2525

26+
import arlut.csd.crypto.SmbEncrypt
2627
import mu.KotlinLogging
2728
import org.projectforge.common.logging.LogDuration
2829
import org.projectforge.business.ldap.LdapMasterLoginHandler
@@ -210,6 +211,12 @@ open class KeycloakMasterLoginHandler : LoginHandler {
210211
}
211212

212213
override fun wlanPasswordChanged(user: PFUserDO, newPassword: CharArray) {
214+
val wlanAttr = keycloakConfig.wlanPasswordAttribute
215+
if (keycloakConfig.isConfigured() && !wlanAttr.isNullOrBlank()
216+
&& !user.localUser && !user.deleted
217+
) {
218+
syncWlanPasswordToKeycloak(user, newPassword, wlanAttr)
219+
}
213220
if (isLdapConfigured()) {
214221
try {
215222
ldapMasterLoginHandler.wlanPasswordChanged(user, newPassword)
@@ -432,6 +439,30 @@ open class KeycloakMasterLoginHandler : LoginHandler {
432439
}
433440
}
434441

442+
private fun syncWlanPasswordToKeycloak(user: PFUserDO, password: CharArray, attributeName: String) {
443+
try {
444+
val kcId = user.keycloakId ?: run {
445+
val found = keycloakAdminClient.findUserByUsername(user.username ?: return)?.id
446+
if (found == null) {
447+
log.info("Keycloak user not found for '${user.username}', skipping WLAN password sync.")
448+
return
449+
}
450+
found
451+
}
452+
val ntHash = SmbEncrypt.NTUNICODEHash(password)
453+
val currentUser = keycloakAdminClient.getUserById(kcId) ?: run {
454+
log.warn("Keycloak user id '$kcId' not found, skipping WLAN password sync.")
455+
return
456+
}
457+
val updatedAttrs = (currentUser.attributes ?: emptyMap()).toMutableMap()
458+
updatedAttrs[attributeName] = listOf(ntHash)
459+
keycloakAdminClient.updateUser(kcId, currentUser.copy(attributes = updatedAttrs))
460+
log.info("WLAN password (NT hash) synced to Keycloak attribute '$attributeName' for user: ${user.username}")
461+
} catch (ex: Exception) {
462+
log.error("Failed to sync WLAN password to Keycloak for user '${user.username}' (ignoring): ${ex.message}", ex)
463+
}
464+
}
465+
435466
/** Returns true if LDAP is configured (projectforge.ldap.server is set). */
436467
private fun isLdapConfigured(): Boolean = !ldapService.ldapConfig?.server.isNullOrBlank()
437468

0 commit comments

Comments
 (0)