Skip to content

Commit 99f00b1

Browse files
authored
Merge pull request #262 from devondragon/issue-260-auth-event-dev-login
fix(auth): publish auth event from authWithoutPassword + add DevLoginAutoConfiguration
2 parents bca11cd + 74cb1ee commit 99f00b1

14 files changed

Lines changed: 588 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ com.digitalsanctuary.spring.user
4242
├── api/ # REST endpoints (UserAPI)
4343
├── audit/ # Audit logging system
4444
├── controller/ # MVC controllers for HTML pages
45+
├── dev/ # Dev login auto-configuration (local profile only)
4546
├── dto/ # Data transfer objects
4647
├── event/ # Spring application events
4748
├── exceptions/ # Custom exceptions
@@ -110,6 +111,7 @@ All configuration uses `user.*` prefix in application.yml. Key property groups:
110111
- `user.roles-and-privileges` - Role-to-privilege mapping (applied on startup)
111112
- `user.role-hierarchy` - Role inheritance (e.g., ROLE_ADMIN > ROLE_MANAGER)
112113
- `user.gdpr.*` - GDPR features (enabled, exportBeforeDeletion, consentTracking)
114+
- `user.dev.*` - Dev login (autoLoginEnabled, loginRedirectUrl) — requires `local` profile
113115
- `user.purgetokens.cron.*` - Token cleanup schedule
114116
- `user.session.invalidation.warn-threshold` - Performance warning threshold
115117
- `user.actuallyDeleteAccount` - Hard delete vs disable

CONFIG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,35 @@ WebAuthn requires two additional tables: `user_entities` and `user_credentials`.
105105
- Passkey labels are limited to 64 characters.
106106
- When a user account is deleted, all associated WebAuthn credentials and user entities are automatically cleaned up via the `UserPreDeleteEvent` listener. The database schema also uses `ON DELETE CASCADE` as a safety net.
107107

108+
## Dev Login Settings
109+
110+
Provides a reusable "login as" controller for local development, so consuming applications don't need to write boilerplate dev-login controllers. **This feature is disabled by default and requires both a property flag and the `local` Spring profile to activate.**
111+
112+
- **Auto-Login Enabled (`user.dev.auto-login-enabled`)**: Master toggle for the dev login feature. Defaults to `false`. Must be set to `true` **and** the `local` Spring profile must be active for the endpoints to be registered.
113+
- **Login Redirect URL (`user.dev.login-redirect-url`)**: The URL to redirect to after a successful dev login. Defaults to `/`.
114+
115+
**Example configuration:**
116+
```yaml
117+
# application-local.yml (only active with spring.profiles.active=local)
118+
user:
119+
dev:
120+
auto-login-enabled: true
121+
login-redirect-url: /dashboard
122+
```
123+
124+
**Endpoints** (only available when enabled with the `local` profile):
125+
126+
| Endpoint | Method | Description |
127+
|----------|--------|-------------|
128+
| `/dev/login-as/{email}` | GET | Authenticate as the specified user and redirect |
129+
| `/dev/users` | GET | List all enabled user emails |
130+
131+
**Important Notes:**
132+
- The `local` Spring profile **must** be active. Without it, the controller and warning beans are never registered regardless of the property value.
133+
- When enabled, `/dev/**` is automatically added to the unprotected URI list and CSRF-ignored URIs in `WebSecurityConfig`.
134+
- A prominent WARN-level banner is logged on startup when dev login is active.
135+
- **NEVER enable this in production.** It bypasses all password authentication.
136+
108137
## Mail Configuration
109138

110139
- **From Address (`spring.mail.fromAddress`)**: The email address used as the sender in outgoing emails.

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond
103103
- Configuration-driven features
104104
- Comprehensive documentation
105105
- Demo application for reference
106+
- Built-in dev login controller for quick user switching during local development
106107

107108
- **GDPR Compliance** (opt-in)
108109
- Data export (Right of Access - Article 15)
@@ -709,6 +710,38 @@ To enable SSO:
709710
jwk-set-uri: ${DS_SPRING_USER_KEYCLOAK_PROVIDER_JWK_SET_URI}
710711
```
711712

713+
### Dev Login (Local Development)
714+
715+
The framework includes a built-in dev login controller that lets developers quickly switch between user accounts without entering passwords. This eliminates the need for consuming applications to write boilerplate dev-login controllers.
716+
717+
**Setup:**
718+
719+
1. Activate the `local` Spring profile (e.g., `spring.profiles.active=local`)
720+
2. Enable dev login in your configuration:
721+
722+
```yaml
723+
# application-local.yml
724+
user:
725+
dev:
726+
auto-login-enabled: true
727+
login-redirect-url: /dashboard # optional, defaults to /
728+
```
729+
730+
**Endpoints** (only available when enabled with the `local` profile):
731+
732+
| Endpoint | Method | Description |
733+
|----------|--------|-------------|
734+
| `/dev/login-as/{email}` | GET | Authenticate as the specified user and redirect |
735+
| `/dev/users` | GET | List all enabled user emails (JSON) |
736+
737+
**Example usage during development:**
738+
- Visit `http://localhost:8080/dev/users` to see available accounts
739+
- Visit `http://localhost:8080/dev/login-as/admin@example.com` to instantly log in as that user
740+
741+
**Security:** This feature requires **both** `user.dev.auto-login-enabled=true` **and** the `local` Spring profile to be active. A prominent warning banner is logged on startup when dev login is active. **Never enable this in production.**
742+
743+
See the [Configuration Guide](CONFIG.md) for all dev login settings.
744+
712745
## Extensibility
713746

714747
The framework is designed to be extended without modifying the core code.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.digitalsanctuary.spring.user.dev;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
import org.springframework.context.annotation.PropertySource;
5+
import org.springframework.stereotype.Component;
6+
import lombok.Data;
7+
8+
/**
9+
* Configuration properties for the dev login feature.
10+
* <p>
11+
* This enables a quick "login as" endpoint for local development, removing the need
12+
* for consuming applications to write boilerplate dev-login controllers.
13+
* </p>
14+
* <p>
15+
* <strong>SECURITY WARNING:</strong> This feature should only be enabled in local/dev
16+
* environments. It allows authentication without a password via a simple GET request.
17+
* </p>
18+
*/
19+
@Data
20+
@Component
21+
@PropertySource("classpath:config/dsspringuserconfig.properties")
22+
@ConfigurationProperties(prefix = "user.dev")
23+
public class DevLoginConfigProperties {
24+
25+
/**
26+
* Whether the dev auto-login feature is enabled. Defaults to false.
27+
* Must be explicitly set to true AND the "local" profile must be active.
28+
*/
29+
private boolean autoLoginEnabled = false;
30+
31+
/**
32+
* The URL to redirect to after a successful dev login. Defaults to "/".
33+
*/
34+
private String loginRedirectUrl = "/";
35+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package com.digitalsanctuary.spring.user.dev;
2+
3+
import java.util.List;
4+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
5+
import org.springframework.context.annotation.Profile;
6+
import org.springframework.http.HttpStatus;
7+
import org.springframework.http.ResponseEntity;
8+
import org.springframework.web.bind.annotation.GetMapping;
9+
import org.springframework.web.bind.annotation.PathVariable;
10+
import org.springframework.web.bind.annotation.RequestMapping;
11+
import org.springframework.web.bind.annotation.RestController;
12+
import com.digitalsanctuary.spring.user.persistence.model.User;
13+
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
14+
import com.digitalsanctuary.spring.user.service.UserService;
15+
import com.digitalsanctuary.spring.user.util.JSONResponse;
16+
import lombok.RequiredArgsConstructor;
17+
import lombok.extern.slf4j.Slf4j;
18+
19+
/**
20+
* Development-only controller providing quick login-as functionality.
21+
* <p>
22+
* This controller is only active when the "local" Spring profile is active AND
23+
* {@code user.dev.auto-login-enabled} is set to {@code true}. It allows developers
24+
* to quickly switch between user accounts without entering passwords.
25+
* </p>
26+
* <p>
27+
* <strong>SECURITY WARNING:</strong> This controller must NEVER be enabled in
28+
* production environments. It bypasses all password authentication.
29+
* </p>
30+
*/
31+
@Slf4j
32+
@RestController
33+
@RequestMapping("/dev")
34+
@RequiredArgsConstructor
35+
@Profile("local")
36+
@ConditionalOnProperty(name = "user.dev.auto-login-enabled", havingValue = "true", matchIfMissing = false)
37+
public class DevLoginController {
38+
39+
private final UserService userService;
40+
private final UserRepository userRepository;
41+
private final DevLoginConfigProperties devLoginConfigProperties;
42+
43+
/**
44+
* Logs in as the specified user by email without requiring a password.
45+
* After successful authentication, returns a redirect response to the configured URL.
46+
*
47+
* @param email the email of the user to log in as
48+
* @return a redirect response on success, or an error response with 404/403 status
49+
*/
50+
@GetMapping("/login-as/{email}")
51+
public ResponseEntity<JSONResponse> loginAs(@PathVariable String email) {
52+
log.warn("Dev login attempt for user: {}", email);
53+
54+
User user = userService.findUserByEmail(email);
55+
if (user == null) {
56+
log.warn("Dev login failed: user not found for email: {}", email);
57+
return ResponseEntity.status(HttpStatus.NOT_FOUND)
58+
.body(JSONResponse.builder().success(false).message("User not found: " + email).code(404).build());
59+
}
60+
61+
if (!user.isEnabled()) {
62+
log.warn("Dev login failed: user is disabled: {}", email);
63+
return ResponseEntity.status(HttpStatus.FORBIDDEN)
64+
.body(JSONResponse.builder().success(false).message("User is disabled: " + email).code(403)
65+
.build());
66+
}
67+
68+
userService.authWithoutPassword(user);
69+
log.warn("Dev login successful for user: {}", email);
70+
71+
return ResponseEntity.status(HttpStatus.FOUND)
72+
.header("Location", devLoginConfigProperties.getLoginRedirectUrl())
73+
.build();
74+
}
75+
76+
/**
77+
* Lists all enabled user emails available for dev login.
78+
*
79+
* @return a JSONResponse containing the list of enabled user emails
80+
*/
81+
@GetMapping("/users")
82+
public ResponseEntity<JSONResponse> listUsers() {
83+
List<String> enabledEmails = userRepository.findAllByEnabledTrue().stream()
84+
.map(User::getEmail)
85+
.toList();
86+
87+
return ResponseEntity.ok(JSONResponse.builder().success(true)
88+
.message("Found " + enabledEmails.size() + " enabled users").data(enabledEmails).build());
89+
}
90+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.digitalsanctuary.spring.user.dev;
2+
3+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
4+
import org.springframework.context.annotation.Profile;
5+
import org.springframework.stereotype.Component;
6+
import jakarta.annotation.PostConstruct;
7+
import lombok.extern.slf4j.Slf4j;
8+
9+
/**
10+
* Logs a prominent warning on startup when the dev login feature is active.
11+
* This ensures developers are aware that passwordless authentication is enabled.
12+
*/
13+
@Slf4j
14+
@Component
15+
@Profile("local")
16+
@ConditionalOnProperty(name = "user.dev.auto-login-enabled", havingValue = "true", matchIfMissing = false)
17+
public class DevLoginStartupWarning {
18+
19+
@PostConstruct
20+
public void logWarning() {
21+
log.warn("========================================================");
22+
log.warn(" DEV LOGIN IS ACTIVE");
23+
log.warn(" Passwordless authentication is enabled at /dev/login-as/{email}");
24+
log.warn(" DO NOT enable this in production!");
25+
log.warn("========================================================");
26+
}
27+
}

src/main/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepository.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.digitalsanctuary.spring.user.persistence.repository;
22

3+
import java.util.List;
34
import org.springframework.data.jpa.repository.JpaRepository;
45
import com.digitalsanctuary.spring.user.persistence.model.User;
56

@@ -16,6 +17,13 @@ public interface UserRepository extends JpaRepository<User, Long> {
1617
*/
1718
User findByEmail(String email);
1819

20+
/**
21+
* Find all enabled users.
22+
*
23+
* @return list of enabled users
24+
*/
25+
List<User> findAllByEnabledTrue();
26+
1927
/**
2028
* Delete.
2129
*

src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.springframework.context.ApplicationEventPublisher;
1111
import org.springframework.context.annotation.Bean;
1212
import org.springframework.context.annotation.Configuration;
13+
import org.springframework.core.env.Environment;
1314
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
1415
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
1516
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
@@ -115,13 +116,17 @@ public class WebSecurityConfig {
115116
@Value("${user.security.rememberMe.key:#{null}}")
116117
private String rememberMeKey;
117118

119+
@Value("${user.dev.auto-login-enabled:false}")
120+
private boolean devAutoLoginEnabled;
121+
118122
private final UserDetailsService userDetailsService;
119123
private final LoginSuccessService loginSuccessService;
120124
private final LogoutSuccessService logoutSuccessService;
121125
private final RolesAndPrivilegesConfig rolesAndPrivilegesConfig;
122126
private final DSOAuth2UserService dsOAuth2UserService;
123127
private final DSOidcUserService dsOidcUserService;
124128
private final WebAuthnConfigProperties webAuthnConfigProperties;
129+
private final Environment environment;
125130

126131
/**
127132
*
@@ -150,10 +155,14 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
150155
.deleteCookies("JSESSIONID"));
151156

152157
// If we have URIs to disable CSRF validation on, do so here
153-
String[] disableCSRFURIsArray = getDisableCSRFURIsArray();
154-
if (disableCSRFURIsArray.length > 0) {
158+
String[] baseDisableCSRFURIs = getDisableCSRFURIsArray();
159+
List<String> csrfIgnoreList = new ArrayList<>(Arrays.asList(baseDisableCSRFURIs));
160+
if (devAutoLoginEnabled && environment.matchesProfiles("local")) {
161+
csrfIgnoreList.add("/dev/**");
162+
}
163+
if (!csrfIgnoreList.isEmpty()) {
155164
http.csrf(csrf -> {
156-
csrf.ignoringRequestMatchers(disableCSRFURIsArray);
165+
csrf.ignoringRequestMatchers(csrfIgnoreList.toArray(new String[0]));
157166
});
158167
}
159168

@@ -266,6 +275,9 @@ private List<String> getUnprotectedURIsList() {
266275
unprotectedURIs.add(registrationSuccessURI);
267276
unprotectedURIs.add(forgotPasswordPendingURI);
268277
unprotectedURIs.add(forgotPasswordChangeURI);
278+
if (devAutoLoginEnabled && environment.matchesProfiles("local")) {
279+
unprotectedURIs.add("/dev/**");
280+
}
269281
unprotectedURIs.removeAll(Collections.emptyList());
270282
return unprotectedURIs;
271283
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.springframework.beans.factory.annotation.Value;
1111
import org.springframework.context.ApplicationEventPublisher;
1212
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
13+
import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent;
1314
import org.springframework.security.core.Authentication;
1415
import org.springframework.security.core.GrantedAuthority;
1516
import org.springframework.security.core.context.SecurityContextHolder;
@@ -554,6 +555,12 @@ public void authWithoutPassword(User user) {
554555
// Store security context in session
555556
storeSecurityContextInSession();
556557

558+
// Publish authentication event for listeners (session profile, brute-force reset)
559+
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
560+
if (authentication != null) {
561+
eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authentication, this.getClass()));
562+
}
563+
557564
log.debug("UserService.authWithoutPassword: authenticated user: {}", user.getEmail());
558565
}
559566

src/main/resources/config/dsspringuserconfig.properties

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ user.security.password.history-count=3
124124
# Percentage of similarity allowed with username/email
125125
user.security.password.similarity-threshold=70
126126

127+
# Dev Login Configuration (disabled by default; opt-in feature for local development only)
128+
# When enabled with the "local" profile, provides /dev/login-as/{email} for passwordless dev login.
129+
user.dev.auto-login-enabled=false
130+
user.dev.login-redirect-url=/
131+
127132
# WebAuthn / Passkey Configuration (disabled by default; opt-in feature)
128133
user.webauthn.enabled=false
129134
user.webauthn.rpId=localhost

0 commit comments

Comments
 (0)