Skip to content

Commit e4a12e2

Browse files
authored
Add remember-me support to the reference login form (#79) (#80)
* chore: adopt ds-spring-user-framework 5.2.0 Brings in completed remember-me support (devondragon/SpringUserFramework#351): configurable token validity, parameter/cookie names, secure-cookie flag, and an optional persistent token store with server-side revocation. Refs #79 * feat(auth): add remember-me checkbox to login form and enable remember-me Add the remember-me checkbox to the reference login form so the framework's remember-me support is reachable from the documented login path. The parameter name matches the framework default (remember-me); without it AbstractRememberMeServices never issues a cookie. Enable user.security.rememberMe in the demo config with an env-var-backed signing key (demo-only default so a fresh clone works out of the box). The prd profile requires REMEMBER_ME_KEY with no fallback (fail-fast) and forces useSecureCookie so the ~14-day token is never sent over plain HTTP behind a TLS-terminating proxy. Refs #79 * test(e2e): cover the remember-me cookie flow Extend LoginPage with a remember-me checkbox locator and add a spec covering: persistent cookie issued when checked (httpOnly, future expiry), no cookie when unchecked, auto-login after the session cookie is dropped, and the fresh-login baseline without remember-me. Refs #79 * docs: document REMEMBER_ME_KEY in the production env vars list Refs #79 * fix(config): use a random per-start fallback for the remember-me key A fixed fallback means any deployment that skips the prd profile and never sets REMEMBER_ME_KEY signs tokens with a publicly-known constant. A random per-start default keeps the demo working out of the box without that footgun; cookies just don't survive restarts unless the env var is set. Refs #79
1 parent 8a44342 commit e4a12e2

8 files changed

Lines changed: 173 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,9 @@ export FACEBOOK_CLIENT_SECRET=your-facebook-client-secret
483483
# Security
484484
export SPRING_SECURITY_BCRYPT_STRENGTH=12
485485
export SPRING_SECURITY_FAILED_LOGIN_ATTEMPTS=5
486+
487+
# Remember-me token signing key (required by the prd profile; startup fails without it)
488+
export REMEMBER_ME_KEY=a-long-random-value-from-your-secret-manager
486489
```
487490
488491
### Important Security Settings

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ repositories {
3939

4040
dependencies {
4141
// DigitalSanctuary Spring User Framework
42-
implementation 'com.digitalsanctuary:ds-spring-user-framework:5.1.1'
42+
implementation 'com.digitalsanctuary:ds-spring-user-framework:5.2.0'
4343

4444
// WebAuthn support (Passkey authentication)
4545
implementation 'org.springframework.security:spring-security-webauthn'

playwright/src/pages/LoginPage.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export class LoginPage extends BasePage {
1010
// Form elements
1111
readonly emailInput: Locator;
1212
readonly passwordInput: Locator;
13+
readonly rememberMeCheckbox: Locator;
1314
readonly submitButton: Locator;
1415

1516
// Links
@@ -28,6 +29,7 @@ export class LoginPage extends BasePage {
2829
super(page);
2930
this.emailInput = page.locator('#username');
3031
this.passwordInput = page.locator('#password');
32+
this.rememberMeCheckbox = page.locator('#remember-me');
3133
// Use specific button text to avoid matching other buttons
3234
this.submitButton = page.getByRole('button', { name: 'Log In' });
3335
// Use specific link text to avoid matching dropdown menu items
@@ -47,6 +49,13 @@ export class LoginPage extends BasePage {
4749
await this.passwordInput.fill(password);
4850
}
4951

52+
/**
53+
* Check the remember-me checkbox.
54+
*/
55+
async checkRememberMe(): Promise<void> {
56+
await this.rememberMeCheckbox.check();
57+
}
58+
5059
/**
5160
* Submit the login form.
5261
*/
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { test, expect, generateTestUser } from '../../src/fixtures';
2+
3+
/**
4+
* Remember-me cookie flow (issue #79 / framework #351).
5+
*
6+
* The demo enables user.security.rememberMe with the framework defaults: the
7+
* login form posts a "remember-me" checkbox and Spring Security issues a
8+
* hash-based "remember-me" cookie only when that parameter is present.
9+
*/
10+
test.describe('Remember Me', () => {
11+
test.describe('Cookie Issuance', () => {
12+
test('should issue persistent remember-me cookie when checkbox is checked', async ({
13+
page,
14+
loginPage,
15+
testApiClient,
16+
cleanupEmails,
17+
}) => {
18+
const user = generateTestUser('remember-me-on');
19+
cleanupEmails.push(user.email);
20+
21+
await testApiClient.createUser({
22+
email: user.email,
23+
password: user.password,
24+
firstName: user.firstName,
25+
lastName: user.lastName,
26+
enabled: true,
27+
});
28+
29+
await loginPage.goto();
30+
31+
// The checkbox label renders from the label.form.login-remember message key
32+
await expect(page.locator('label[for="remember-me"]')).toHaveText('Remember me');
33+
34+
await loginPage.fillCredentials(user.email, user.password);
35+
await loginPage.checkRememberMe();
36+
await loginPage.submit();
37+
await page.waitForURL((url) => !url.pathname.includes('login'), { timeout: 10000 });
38+
39+
const cookies = await page.context().cookies();
40+
const rememberMeCookie = cookies.find((c) => c.name === 'remember-me');
41+
expect(rememberMeCookie).toBeDefined();
42+
expect(rememberMeCookie!.value.length).toBeGreaterThan(0);
43+
expect(rememberMeCookie!.httpOnly).toBe(true);
44+
// A persistent cookie has a future expiry; a session cookie reports expires === -1
45+
expect(rememberMeCookie!.expires).toBeGreaterThan(Date.now() / 1000);
46+
});
47+
48+
test('should not issue remember-me cookie when checkbox is unchecked', async ({
49+
page,
50+
loginPage,
51+
testApiClient,
52+
cleanupEmails,
53+
}) => {
54+
const user = generateTestUser('remember-me-off');
55+
cleanupEmails.push(user.email);
56+
57+
await testApiClient.createUser({
58+
email: user.email,
59+
password: user.password,
60+
firstName: user.firstName,
61+
lastName: user.lastName,
62+
enabled: true,
63+
});
64+
65+
await loginPage.loginAndWait(user.email, user.password);
66+
67+
const cookies = await page.context().cookies();
68+
expect(cookies.find((c) => c.name === 'remember-me')).toBeUndefined();
69+
});
70+
});
71+
72+
test.describe('Session Expiry', () => {
73+
test('should auto-login from remember-me cookie after session cookie is gone', async ({
74+
page,
75+
loginPage,
76+
protectedPage,
77+
testApiClient,
78+
cleanupEmails,
79+
}) => {
80+
const user = generateTestUser('remember-me-relogin');
81+
cleanupEmails.push(user.email);
82+
83+
await testApiClient.createUser({
84+
email: user.email,
85+
password: user.password,
86+
firstName: user.firstName,
87+
lastName: user.lastName,
88+
enabled: true,
89+
});
90+
91+
await loginPage.goto();
92+
await loginPage.fillCredentials(user.email, user.password);
93+
await loginPage.checkRememberMe();
94+
await loginPage.submit();
95+
await page.waitForURL((url) => !url.pathname.includes('login'), { timeout: 10000 });
96+
97+
const cookies = await page.context().cookies();
98+
const rememberMeCookie = cookies.find((c) => c.name === 'remember-me');
99+
expect(rememberMeCookie).toBeDefined();
100+
expect(rememberMeCookie!.expires).toBeGreaterThan(Date.now() / 1000);
101+
102+
// Drop the server session cookie, simulating an expired/closed session.
103+
// The remember-me cookie survives and should re-authenticate the request.
104+
await page.context().clearCookies({ name: 'JSESSIONID' });
105+
106+
await protectedPage.goto();
107+
expect(page.url()).not.toContain('login');
108+
expect(await protectedPage.isLoggedIn()).toBe(true);
109+
});
110+
111+
test('should require fresh login without remember-me once session cookie is gone', async ({
112+
page,
113+
loginPage,
114+
protectedPage,
115+
testApiClient,
116+
cleanupEmails,
117+
}) => {
118+
const user = generateTestUser('remember-me-baseline');
119+
cleanupEmails.push(user.email);
120+
121+
await testApiClient.createUser({
122+
email: user.email,
123+
password: user.password,
124+
firstName: user.firstName,
125+
lastName: user.lastName,
126+
enabled: true,
127+
});
128+
129+
await loginPage.loginAndWait(user.email, user.password);
130+
131+
await page.context().clearCookies({ name: 'JSESSIONID' });
132+
133+
await protectedPage.goto();
134+
await page.waitForURL('**/login**', { timeout: 10000 });
135+
});
136+
});
137+
});

src/main/resources/application-prd.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,12 @@ user:
5050
# NOTE: allowInitialPasswordSetWithoutStepUp is intentionally left at its secure default (false) here. In
5151
# production, setting an initial password on a passkey-only account should require step-up (a StepUpService bean),
5252
# not just an authenticated session (SUF-02).
53-
disableCSRFdURIs: # No CSRF disabled URIs in production for better security
53+
disableCSRFdURIs: # No CSRF disabled URIs in production for better security
54+
rememberMe:
55+
# No default on purpose: the demo signing key in application.yml must never reach production. Startup fails
56+
# unless REMEMBER_ME_KEY is provided.
57+
key: ${REMEMBER_ME_KEY}
58+
# Force the Secure flag unconditionally, mirroring the session cookie above. Spring's default falls back to
59+
# request.isSecure(), which is false behind a TLS-terminating proxy unless forwarded-header processing is
60+
# configured - without this a ~14-day login token could be sent over plain HTTP.
61+
useSecureCookie: true

src/main/resources/application.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@ user:
140140
bcryptStrength: 12 # The bcrypt strength to use for password hashing. The higher the number, the longer it takes to hash the password. The default is 12. The minimum is 4. The maximum is 31.
141141
testHashTime: true # If true, the test hash time will be logged to the console on startup. This is useful for determining the optimal bcryptStrength value.
142142
defaultAction: deny # The default action for all requests. This can be either deny or allow.
143+
rememberMe:
144+
enabled: true # Issue a remember-me cookie when the login form posts the remember-me parameter (the checkbox on login.html).
145+
# Secret used to sign remember-me tokens. The fallback is random per start so the demo works out of the box
146+
# without ever running on a publicly-known key - the cost is that remember-me cookies do not survive an app
147+
# restart. Set REMEMBER_ME_KEY (or override this property) to a long random value from your secret manager
148+
# to keep tokens valid across restarts/instances; the prd profile requires it (no fallback).
149+
key: ${REMEMBER_ME_KEY:${random.uuid}}
150+
# tokenValiditySeconds: 1209600 # How long a remember-me token stays valid. Default is 14 days.
151+
# usePersistentTokens: true # Store tokens in the persistent_logins table (see framework db-scripts) so they can be revoked server-side.
143152
unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny.
144153
protectedURIs: /protected.html # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow.
145154
disableCSRFdURIs: /no-csrf-test # A comma delimited list of URIs that should not be protected by CSRF protection. This may include API endpoints that need to be called without a CSRF token.

src/main/resources/messages/messages.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ label.form.login-link=Sign In
3636
label.form.login-title=Log In
3737
label.form.login-email=Email
3838
label.form.login-pass=Password
39+
label.form.login-remember=Remember me
3940
label.form.login-signup=Sign up
4041
label.form.update-user=Update Your Profile
4142
label.form.delete-account=Delete Your Account

src/main/resources/templates/user/login.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ <h5 th:utext="#{label.form.login-title}">Log in with</h5>
7474
th:placeholder="#{label.form.login-pass}" aria-label="Password">
7575
</div>
7676
</div>
77+
<div class="form-check text-start mb-3">
78+
<input type="checkbox" id="remember-me" name="remember-me" class="form-check-input">
79+
<label for="remember-me" class="form-check-label" th:utext="#{label.form.login-remember}">Remember me</label>
80+
</div>
7781
<div class="d-grid">
7882
<button type="submit" th:utext="#{action.login}" class="btn btn-primary">
7983
Log In

0 commit comments

Comments
 (0)