Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ Welcome to the User Framework SpringBoot Configuration Guide! This document outl
- **Account Deletion (`user.actuallyDeleteAccount`)**: Set to `true` to enable account deletion. Defaults to `false` where accounts are disabled instead of deleted.
- **Registration Email Verification (`user.registration.sendVerificationEmail`)**: Enable (`true`) or disable (`false`) sending verification emails post-registration.

## Admin Settings

- **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`
- **Session Invalidation Warn Threshold (`user.session.invalidation.warn-threshold`)**: Number of active sessions that triggers a performance warning during session invalidation. Defaults to `1000`.

## Audit Logging

- **Log File Path (`user.audit.logFilePath`)**: The path to the audit log file.
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond
- Registration, with optional email verification.
- Login and logout functionality.
- Forgot password flow.
- Admin-initiated password reset with optional session invalidation.
- Database-backed user store using Spring JPA.
- SSO support for Google
- SSO support for Facebook
Expand Down Expand Up @@ -521,6 +522,43 @@ Users can:
- Change their password
- Delete their account (configurable to either disable or fully delete)

### Admin Password Reset

Administrators can trigger password resets for users programmatically:

```java
@Autowired
private UserEmailService userEmailService;

// Reset password and invalidate all user sessions
int sessionsInvalidated = userEmailService.initiateAdminPasswordReset(user, appUrl, true);

// Reset password without invalidating sessions
userEmailService.initiateAdminPasswordReset(user, appUrl, false);

// Use configured appUrl (from user.admin.appUrl property)
userEmailService.initiateAdminPasswordReset(user);
```

**Features:**
- Requires `ROLE_ADMIN` authorization (`@PreAuthorize`)
- Optional session invalidation to force re-authentication
- Sends password reset email with secure token
- Comprehensive audit logging with correlation IDs
- Cryptographically secure tokens (256-bit entropy)

**Configuration:**
```yaml
user:
admin:
appUrl: https://myapp.com # Base URL for password reset links
```

**Security Notes:**
- Admin identity is derived from `SecurityContext`, not user input
- Sessions are invalidated *after* email is sent to prevent lockout
- URL validation prevents XSS (blocks javascript:, data: schemes)

## Email Verification


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ public String getName() {

@Override
public Map<String, Object> getClaims() {
return oidcUserInfo.getClaims();
return oidcUserInfo != null ? oidcUserInfo.getClaims() : Map.of();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.digitalsanctuary.spring.user.service;

import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.stereotype.Service;
import com.digitalsanctuary.spring.user.persistence.model.User;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
* Service for invalidating user sessions. This is useful for admin-initiated password resets
* and other security operations that require forcing users to re-authenticate.
*
* <p><strong>Race Condition Note:</strong> This service uses Spring's SessionRegistry to track
* and invalidate sessions. Due to the nature of the SessionRegistry API, there is an inherent
* race condition: sessions created after {@link SessionRegistry#getAllPrincipals()} is called
* but before {@link SessionInformation#expireNow()} completes will not be invalidated. This is
* a known limitation of the SessionRegistry approach. For most use cases (admin password reset),
* this is acceptable as the window is very small.</p>
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class SessionInvalidationService {

private final SessionRegistry sessionRegistry;

/** Threshold for warning about high principal count that may impact performance. */
@Value("${user.session.invalidation.warn-threshold:1000}")
private int warnThreshold;

/**
* Invalidates all active sessions for the given user.
* This forces the user to re-authenticate on their next request.
*
* <p><strong>Note:</strong> Sessions created after this method starts iterating
* but before it completes will not be invalidated. This race condition is inherent
* to the SessionRegistry API and is acceptable for most security operations.</p>
*
* @param user the user whose sessions should be invalidated
* @return the number of sessions that were invalidated
*/
public int invalidateUserSessions(User user) {
if (user == null) {
log.warn("SessionInvalidationService.invalidateUserSessions: user is null");
return 0;
}

int invalidatedCount = 0;
List<Object> principals = sessionRegistry.getAllPrincipals();

// Performance monitoring: warn if principal count is high
if (principals.size() > warnThreshold) {
log.warn("SessionInvalidationService.invalidateUserSessions: high principal count ({}) may impact performance",
principals.size());
}

log.debug("SessionInvalidationService.invalidateUserSessions: scanning {} principals for user {}",
principals.size(), user.getEmail());

// NOTE: Sessions created after getAllPrincipals() but before expireNow()
// will not be invalidated. This is a known limitation of SessionRegistry.
for (Object principal : principals) {
User principalUser = extractUser(principal);

if (principalUser != null && principalUser.getId().equals(user.getId())) {
List<SessionInformation> sessions = sessionRegistry.getAllSessions(principal, false);
for (SessionInformation session : sessions) {
session.expireNow();
invalidatedCount++;
// Log truncated session ID to avoid exposing full session identifiers
String sessionId = session.getSessionId();
String safeSessionId = sessionId.length() > 8 ? sessionId.substring(0, 8) + "..." : sessionId;
log.debug("SessionInvalidationService.invalidateUserSessions: expired session {} for user {}",
safeSessionId, user.getEmail());
}
}
}

log.info("SessionInvalidationService.invalidateUserSessions: invalidated {} sessions for user {} (scanned {} principals)",
invalidatedCount, user.getEmail(), principals.size());
return invalidatedCount;
}

/**
* Extracts the User object from a principal.
* Handles both User and DSUserDetails principal types.
*
* @param principal the security principal
* @return the User object, or null if not extractable
*/
private User extractUser(Object principal) {
if (principal instanceof User user) {
return user;
} else if (principal instanceof DSUserDetails dsUserDetails) {
return dsUserDetails.getUser();
}
return null;
}
}
Loading