Skip to content

Commit 5d785a8

Browse files
committed
feat(dev): add DevLoginAutoConfiguration for local development
Add a reusable dev-login auto-configuration so consuming apps don't need to write boilerplate dev-login controllers. The feature is guarded by both @Profile("local") and @ConditionalOnProperty to ensure it can never activate in production. - DevLoginConfigProperties: user.dev.auto-login-enabled, login-redirect-url - DevLoginController: GET /dev/login-as/{email}, GET /dev/users - DevLoginStartupWarning: WARN banner on startup when active - WebSecurityConfig: conditionally add /dev/** to unprotected and CSRF-ignored URIs - Property defaults in dsspringuserconfig.properties - Unit tests (DevLoginControllerTest) - Integration tests (DevLoginIntegrationTest, DevLoginDisabledTest) Relates to #260
1 parent 6132ea6 commit 5d785a8

8 files changed

Lines changed: 454 additions & 2 deletions

File tree

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: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.digitalsanctuary.spring.user.dev;
2+
3+
import java.io.IOException;
4+
import java.util.List;
5+
import java.util.stream.Collectors;
6+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
7+
import org.springframework.context.annotation.Profile;
8+
import org.springframework.http.HttpStatus;
9+
import org.springframework.http.ResponseEntity;
10+
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.PathVariable;
12+
import org.springframework.web.bind.annotation.RequestMapping;
13+
import org.springframework.web.bind.annotation.RestController;
14+
import com.digitalsanctuary.spring.user.persistence.model.User;
15+
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
16+
import com.digitalsanctuary.spring.user.service.UserService;
17+
import com.digitalsanctuary.spring.user.util.JSONResponse;
18+
import jakarta.servlet.http.HttpServletResponse;
19+
import lombok.RequiredArgsConstructor;
20+
import lombok.extern.slf4j.Slf4j;
21+
22+
/**
23+
* Development-only controller providing quick login-as functionality.
24+
* <p>
25+
* This controller is only active when the "local" Spring profile is active AND
26+
* {@code user.dev.auto-login-enabled} is set to {@code true}. It allows developers
27+
* to quickly switch between user accounts without entering passwords.
28+
* </p>
29+
* <p>
30+
* <strong>SECURITY WARNING:</strong> This controller must NEVER be enabled in
31+
* production environments. It bypasses all password authentication.
32+
* </p>
33+
*/
34+
@Slf4j
35+
@RestController
36+
@RequestMapping("/dev")
37+
@RequiredArgsConstructor
38+
@Profile("local")
39+
@ConditionalOnProperty(name = "user.dev.auto-login-enabled", havingValue = "true", matchIfMissing = false)
40+
public class DevLoginController {
41+
42+
private final UserService userService;
43+
private final UserRepository userRepository;
44+
private final DevLoginConfigProperties devLoginConfigProperties;
45+
46+
/**
47+
* Logs in as the specified user by email without requiring a password.
48+
* After successful authentication, redirects to the configured redirect URL.
49+
*
50+
* @param email the email of the user to log in as
51+
* @param response the HTTP servlet response for redirect
52+
* @return a ResponseEntity with error details if authentication fails
53+
* @throws IOException if the redirect fails
54+
*/
55+
@GetMapping("/login-as/{email}")
56+
public ResponseEntity<JSONResponse> loginAs(@PathVariable String email, HttpServletResponse response)
57+
throws IOException {
58+
log.warn("Dev login attempt for user: {}", email);
59+
60+
User user = userService.findUserByEmail(email);
61+
if (user == null) {
62+
log.warn("Dev login failed: user not found for email: {}", email);
63+
return ResponseEntity.status(HttpStatus.NOT_FOUND)
64+
.body(JSONResponse.builder().success(false).message("User not found: " + email).code(404).build());
65+
}
66+
67+
if (!user.isEnabled()) {
68+
log.warn("Dev login failed: user is disabled: {}", email);
69+
return ResponseEntity.status(HttpStatus.FORBIDDEN)
70+
.body(JSONResponse.builder().success(false).message("User is disabled: " + email).code(403)
71+
.build());
72+
}
73+
74+
userService.authWithoutPassword(user);
75+
log.warn("Dev login successful for user: {}", email);
76+
77+
response.sendRedirect(devLoginConfigProperties.getLoginRedirectUrl());
78+
return null;
79+
}
80+
81+
/**
82+
* Lists all enabled user emails available for dev login.
83+
*
84+
* @return a JSONResponse containing the list of enabled user emails
85+
*/
86+
@GetMapping("/users")
87+
public ResponseEntity<JSONResponse> listUsers() {
88+
List<String> enabledEmails = userRepository.findAll().stream().filter(User::isEnabled).map(User::getEmail)
89+
.collect(Collectors.toList());
90+
91+
return ResponseEntity.ok(JSONResponse.builder().success(true).message("Found " + enabledEmails.size()
92+
+ " enabled users").data(enabledEmails).build());
93+
}
94+
}
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/security/WebSecurityConfig.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ public class WebSecurityConfig {
113113
@Value("${user.security.rememberMe.key:#{null}}")
114114
private String rememberMeKey;
115115

116+
@Value("${user.dev.auto-login-enabled:false}")
117+
private boolean devAutoLoginEnabled;
118+
116119
private final UserDetailsService userDetailsService;
117120
private final LoginSuccessService loginSuccessService;
118121
private final LogoutSuccessService logoutSuccessService;
@@ -149,9 +152,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
149152

150153
// If we have URIs to disable CSRF validation on, do so here
151154
String[] disableCSRFURIsArray = getDisableCSRFURIsArray();
152-
if (disableCSRFURIsArray.length > 0) {
155+
List<String> csrfIgnoreList = new ArrayList<>(Arrays.asList(disableCSRFURIsArray));
156+
if (devAutoLoginEnabled) {
157+
csrfIgnoreList.add("/dev/**");
158+
}
159+
if (!csrfIgnoreList.isEmpty()) {
153160
http.csrf(csrf -> {
154-
csrf.ignoringRequestMatchers(disableCSRFURIsArray);
161+
csrf.ignoringRequestMatchers(csrfIgnoreList.toArray(new String[0]));
155162
});
156163
}
157164

@@ -257,6 +264,9 @@ private List<String> getUnprotectedURIsList() {
257264
unprotectedURIs.add(registrationSuccessURI);
258265
unprotectedURIs.add(forgotPasswordPendingURI);
259266
unprotectedURIs.add(forgotPasswordChangeURI);
267+
if (devAutoLoginEnabled) {
268+
unprotectedURIs.add("/dev/**");
269+
}
260270
unprotectedURIs.removeAll(Collections.emptyList());
261271
return unprotectedURIs;
262272
}

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
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package com.digitalsanctuary.spring.user.dev;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.ArgumentMatchers.any;
5+
import static org.mockito.Mockito.mock;
6+
import static org.mockito.Mockito.never;
7+
import static org.mockito.Mockito.verify;
8+
import static org.mockito.Mockito.when;
9+
import java.util.Arrays;
10+
import java.util.List;
11+
import org.junit.jupiter.api.BeforeEach;
12+
import org.junit.jupiter.api.DisplayName;
13+
import org.junit.jupiter.api.Test;
14+
import org.mockito.InjectMocks;
15+
import org.mockito.Mock;
16+
import org.springframework.http.HttpStatus;
17+
import org.springframework.http.ResponseEntity;
18+
import com.digitalsanctuary.spring.user.persistence.model.User;
19+
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
20+
import com.digitalsanctuary.spring.user.service.UserService;
21+
import com.digitalsanctuary.spring.user.test.annotations.ServiceTest;
22+
import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
23+
import com.digitalsanctuary.spring.user.util.JSONResponse;
24+
import jakarta.servlet.http.HttpServletResponse;
25+
26+
@ServiceTest
27+
class DevLoginControllerTest {
28+
29+
@Mock
30+
private UserService userService;
31+
32+
@Mock
33+
private UserRepository userRepository;
34+
35+
@Mock
36+
private DevLoginConfigProperties devLoginConfigProperties;
37+
38+
@InjectMocks
39+
private DevLoginController devLoginController;
40+
41+
private User enabledUser;
42+
private User disabledUser;
43+
44+
@BeforeEach
45+
void setUp() {
46+
enabledUser = UserTestDataBuilder.aUser().withEmail("dev@test.com").verified().build();
47+
disabledUser = UserTestDataBuilder.aUser().withEmail("disabled@test.com").disabled().build();
48+
}
49+
50+
@Test
51+
@DisplayName("loginAs - should authenticate and redirect for valid enabled user")
52+
void shouldAuthenticateAndRedirectWhenValidUser() throws Exception {
53+
// Given
54+
when(userService.findUserByEmail("dev@test.com")).thenReturn(enabledUser);
55+
when(devLoginConfigProperties.getLoginRedirectUrl()).thenReturn("/dashboard");
56+
HttpServletResponse response = mock(HttpServletResponse.class);
57+
58+
// When
59+
ResponseEntity<JSONResponse> result = devLoginController.loginAs("dev@test.com", response);
60+
61+
// Then
62+
verify(userService).authWithoutPassword(enabledUser);
63+
verify(response).sendRedirect("/dashboard");
64+
assertThat(result).isNull();
65+
}
66+
67+
@Test
68+
@DisplayName("loginAs - should return 404 for unknown user")
69+
void shouldReturn404WhenUserNotFound() throws Exception {
70+
// Given
71+
when(userService.findUserByEmail("unknown@test.com")).thenReturn(null);
72+
HttpServletResponse response = mock(HttpServletResponse.class);
73+
74+
// When
75+
ResponseEntity<JSONResponse> result = devLoginController.loginAs("unknown@test.com", response);
76+
77+
// Then
78+
assertThat(result).isNotNull();
79+
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
80+
assertThat(result.getBody()).isNotNull();
81+
assertThat(result.getBody().isSuccess()).isFalse();
82+
verify(userService, never()).authWithoutPassword(any());
83+
}
84+
85+
@Test
86+
@DisplayName("loginAs - should return 403 for disabled user")
87+
void shouldReturn403WhenUserDisabled() throws Exception {
88+
// Given
89+
when(userService.findUserByEmail("disabled@test.com")).thenReturn(disabledUser);
90+
HttpServletResponse response = mock(HttpServletResponse.class);
91+
92+
// When
93+
ResponseEntity<JSONResponse> result = devLoginController.loginAs("disabled@test.com", response);
94+
95+
// Then
96+
assertThat(result).isNotNull();
97+
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
98+
assertThat(result.getBody()).isNotNull();
99+
assertThat(result.getBody().isSuccess()).isFalse();
100+
verify(userService, never()).authWithoutPassword(any());
101+
}
102+
103+
@Test
104+
@DisplayName("listUsers - should return enabled user emails")
105+
void shouldReturnEnabledUserEmails() {
106+
// Given
107+
List<User> allUsers = Arrays.asList(enabledUser, disabledUser);
108+
when(userRepository.findAll()).thenReturn(allUsers);
109+
110+
// When
111+
ResponseEntity<JSONResponse> result = devLoginController.listUsers();
112+
113+
// Then
114+
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
115+
assertThat(result.getBody()).isNotNull();
116+
assertThat(result.getBody().isSuccess()).isTrue();
117+
@SuppressWarnings("unchecked")
118+
List<String> emails = (List<String>) result.getBody().getData();
119+
assertThat(emails).containsExactly("dev@test.com");
120+
}
121+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.digitalsanctuary.spring.user.dev;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
5+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
6+
import org.junit.jupiter.api.DisplayName;
7+
import org.junit.jupiter.api.Test;
8+
import org.springframework.beans.factory.annotation.Autowired;
9+
import org.springframework.context.ApplicationContext;
10+
import org.springframework.test.context.TestPropertySource;
11+
import org.springframework.test.web.servlet.MockMvc;
12+
import com.digitalsanctuary.spring.user.test.annotations.IntegrationTest;
13+
14+
@IntegrationTest
15+
@TestPropertySource(properties = "user.dev.auto-login-enabled=false")
16+
@DisplayName("Dev Login Disabled Integration Tests")
17+
class DevLoginDisabledTest {
18+
19+
@Autowired
20+
private ApplicationContext applicationContext;
21+
22+
@Autowired
23+
private MockMvc mockMvc;
24+
25+
@Test
26+
@DisplayName("should NOT register DevLoginController when disabled")
27+
void shouldNotRegisterDevLoginControllerBean() {
28+
assertThat(applicationContext.getBeanNamesForType(DevLoginController.class)).isEmpty();
29+
}
30+
31+
@Test
32+
@DisplayName("should NOT register DevLoginStartupWarning when disabled")
33+
void shouldNotRegisterDevLoginStartupWarningBean() {
34+
assertThat(applicationContext.getBeanNamesForType(DevLoginStartupWarning.class)).isEmpty();
35+
}
36+
37+
@Test
38+
@DisplayName("should not allow access to dev login endpoint when disabled")
39+
void shouldNotAllowAccessToDevLoginEndpoint() throws Exception {
40+
// When disabled, the /dev/** paths are not added to the unprotected list,
41+
// so with defaultAction=deny they require authentication (302 redirect to login)
42+
mockMvc.perform(get("/dev/login-as/user@test.com")).andExpect(status().is3xxRedirection());
43+
}
44+
45+
@Test
46+
@DisplayName("should not allow access to dev users endpoint when disabled")
47+
void shouldNotAllowAccessToDevUsersEndpoint() throws Exception {
48+
mockMvc.perform(get("/dev/users")).andExpect(status().is3xxRedirection());
49+
}
50+
}

0 commit comments

Comments
 (0)