Skip to content

Commit 43e56da

Browse files
authored
Merge pull request #243 from devondragon/feature/admin-password-reset
feat: Add admin-initiated password reset with session invalidation
2 parents b64ea44 + 2c4dd35 commit 43e56da

7 files changed

Lines changed: 1118 additions & 42 deletions

File tree

CONFIG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ Welcome to the User Framework SpringBoot Configuration Guide! This document outl
2929
- **Account Deletion (`user.actuallyDeleteAccount`)**: Set to `true` to enable account deletion. Defaults to `false` where accounts are disabled instead of deleted.
3030
- **Registration Email Verification (`user.registration.sendVerificationEmail`)**: Enable (`true`) or disable (`false`) sending verification emails post-registration.
3131

32+
## Admin Settings
33+
34+
- **Admin App URL (`user.admin.appUrl`)**: Base URL for admin-initiated password reset emails. Required when using `initiateAdminPasswordReset(user)` without explicit URL. Example: `https://myapp.com`
35+
- **Session Invalidation Warn Threshold (`user.session.invalidation.warn-threshold`)**: Number of active sessions that triggers a performance warning during session invalidation. Defaults to `1000`.
36+
3237
## Audit Logging
3338

3439
- **Log File Path (`user.audit.logFilePath`)**: The path to the audit log file.

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond
6262
- Registration, with optional email verification.
6363
- Login and logout functionality.
6464
- Forgot password flow.
65+
- Admin-initiated password reset with optional session invalidation.
6566
- Database-backed user store using Spring JPA.
6667
- SSO support for Google
6768
- SSO support for Facebook
@@ -521,6 +522,43 @@ Users can:
521522
- Change their password
522523
- Delete their account (configurable to either disable or fully delete)
523524

525+
### Admin Password Reset
526+
527+
Administrators can trigger password resets for users programmatically:
528+
529+
```java
530+
@Autowired
531+
private UserEmailService userEmailService;
532+
533+
// Reset password and invalidate all user sessions
534+
int sessionsInvalidated = userEmailService.initiateAdminPasswordReset(user, appUrl, true);
535+
536+
// Reset password without invalidating sessions
537+
userEmailService.initiateAdminPasswordReset(user, appUrl, false);
538+
539+
// Use configured appUrl (from user.admin.appUrl property)
540+
userEmailService.initiateAdminPasswordReset(user);
541+
```
542+
543+
**Features:**
544+
- Requires `ROLE_ADMIN` authorization (`@PreAuthorize`)
545+
- Optional session invalidation to force re-authentication
546+
- Sends password reset email with secure token
547+
- Comprehensive audit logging with correlation IDs
548+
- Cryptographically secure tokens (256-bit entropy)
549+
550+
**Configuration:**
551+
```yaml
552+
user:
553+
admin:
554+
appUrl: https://myapp.com # Base URL for password reset links
555+
```
556+
557+
**Security Notes:**
558+
- Admin identity is derived from `SecurityContext`, not user input
559+
- Sessions are invalidated *after* email is sent to prevent lockout
560+
- URL validation prevents XSS (blocks javascript:, data: schemes)
561+
524562
## Email Verification
525563

526564

src/main/java/com/digitalsanctuary/spring/user/service/DSUserDetails.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ public String getName() {
199199

200200
@Override
201201
public Map<String, Object> getClaims() {
202-
return oidcUserInfo.getClaims();
202+
return oidcUserInfo != null ? oidcUserInfo.getClaims() : Map.of();
203203
}
204204

205205
@Override
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.digitalsanctuary.spring.user.service;
2+
3+
import java.util.List;
4+
import org.springframework.beans.factory.annotation.Value;
5+
import org.springframework.security.core.session.SessionInformation;
6+
import org.springframework.security.core.session.SessionRegistry;
7+
import org.springframework.stereotype.Service;
8+
import com.digitalsanctuary.spring.user.persistence.model.User;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
12+
/**
13+
* Service for invalidating user sessions. This is useful for admin-initiated password resets
14+
* and other security operations that require forcing users to re-authenticate.
15+
*
16+
* <p><strong>Race Condition Note:</strong> This service uses Spring's SessionRegistry to track
17+
* and invalidate sessions. Due to the nature of the SessionRegistry API, there is an inherent
18+
* race condition: sessions created after {@link SessionRegistry#getAllPrincipals()} is called
19+
* but before {@link SessionInformation#expireNow()} completes will not be invalidated. This is
20+
* a known limitation of the SessionRegistry approach. For most use cases (admin password reset),
21+
* this is acceptable as the window is very small.</p>
22+
*/
23+
@Slf4j
24+
@RequiredArgsConstructor
25+
@Service
26+
public class SessionInvalidationService {
27+
28+
private final SessionRegistry sessionRegistry;
29+
30+
/** Threshold for warning about high principal count that may impact performance. */
31+
@Value("${user.session.invalidation.warn-threshold:1000}")
32+
private int warnThreshold;
33+
34+
/**
35+
* Invalidates all active sessions for the given user.
36+
* This forces the user to re-authenticate on their next request.
37+
*
38+
* <p><strong>Note:</strong> Sessions created after this method starts iterating
39+
* but before it completes will not be invalidated. This race condition is inherent
40+
* to the SessionRegistry API and is acceptable for most security operations.</p>
41+
*
42+
* @param user the user whose sessions should be invalidated
43+
* @return the number of sessions that were invalidated
44+
*/
45+
public int invalidateUserSessions(User user) {
46+
if (user == null) {
47+
log.warn("SessionInvalidationService.invalidateUserSessions: user is null");
48+
return 0;
49+
}
50+
51+
int invalidatedCount = 0;
52+
List<Object> principals = sessionRegistry.getAllPrincipals();
53+
54+
// Performance monitoring: warn if principal count is high
55+
if (principals.size() > warnThreshold) {
56+
log.warn("SessionInvalidationService.invalidateUserSessions: high principal count ({}) may impact performance",
57+
principals.size());
58+
}
59+
60+
log.debug("SessionInvalidationService.invalidateUserSessions: scanning {} principals for user {}",
61+
principals.size(), user.getEmail());
62+
63+
// NOTE: Sessions created after getAllPrincipals() but before expireNow()
64+
// will not be invalidated. This is a known limitation of SessionRegistry.
65+
for (Object principal : principals) {
66+
User principalUser = extractUser(principal);
67+
68+
if (principalUser != null && principalUser.getId().equals(user.getId())) {
69+
List<SessionInformation> sessions = sessionRegistry.getAllSessions(principal, false);
70+
for (SessionInformation session : sessions) {
71+
session.expireNow();
72+
invalidatedCount++;
73+
// Log truncated session ID to avoid exposing full session identifiers
74+
String sessionId = session.getSessionId();
75+
String safeSessionId = sessionId.length() > 8 ? sessionId.substring(0, 8) + "..." : sessionId;
76+
log.debug("SessionInvalidationService.invalidateUserSessions: expired session {} for user {}",
77+
safeSessionId, user.getEmail());
78+
}
79+
}
80+
}
81+
82+
log.info("SessionInvalidationService.invalidateUserSessions: invalidated {} sessions for user {} (scanned {} principals)",
83+
invalidatedCount, user.getEmail(), principals.size());
84+
return invalidatedCount;
85+
}
86+
87+
/**
88+
* Extracts the User object from a principal.
89+
* Handles both User and DSUserDetails principal types.
90+
*
91+
* @param principal the security principal
92+
* @return the User object, or null if not extractable
93+
*/
94+
private User extractUser(Object principal) {
95+
if (principal instanceof User user) {
96+
return user;
97+
} else if (principal instanceof DSUserDetails dsUserDetails) {
98+
return dsUserDetails.getUser();
99+
}
100+
return null;
101+
}
102+
}

0 commit comments

Comments
 (0)