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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,9 @@ export FACEBOOK_CLIENT_SECRET=your-facebook-client-secret
# Security
export SPRING_SECURITY_BCRYPT_STRENGTH=12
export SPRING_SECURITY_FAILED_LOGIN_ATTEMPTS=5

# Remember-me token signing key (required by the prd profile; startup fails without it)
export REMEMBER_ME_KEY=a-long-random-value-from-your-secret-manager
```

### Important Security Settings
Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ repositories {

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

// WebAuthn support (Passkey authentication)
implementation 'org.springframework.security:spring-security-webauthn'
Expand Down
9 changes: 9 additions & 0 deletions playwright/src/pages/LoginPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export class LoginPage extends BasePage {
// Form elements
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly rememberMeCheckbox: Locator;
readonly submitButton: Locator;

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

/**
* Check the remember-me checkbox.
*/
async checkRememberMe(): Promise<void> {
await this.rememberMeCheckbox.check();
}

/**
* Submit the login form.
*/
Expand Down
137 changes: 137 additions & 0 deletions playwright/tests/auth/remember-me.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { test, expect, generateTestUser } from '../../src/fixtures';

/**
* Remember-me cookie flow (issue #79 / framework #351).
*
* The demo enables user.security.rememberMe with the framework defaults: the
* login form posts a "remember-me" checkbox and Spring Security issues a
* hash-based "remember-me" cookie only when that parameter is present.
*/
test.describe('Remember Me', () => {
test.describe('Cookie Issuance', () => {
test('should issue persistent remember-me cookie when checkbox is checked', async ({
page,
loginPage,
testApiClient,
cleanupEmails,
}) => {
const user = generateTestUser('remember-me-on');
cleanupEmails.push(user.email);

await testApiClient.createUser({
email: user.email,
password: user.password,
firstName: user.firstName,
lastName: user.lastName,
enabled: true,
});

await loginPage.goto();

// The checkbox label renders from the label.form.login-remember message key
await expect(page.locator('label[for="remember-me"]')).toHaveText('Remember me');

await loginPage.fillCredentials(user.email, user.password);
await loginPage.checkRememberMe();
await loginPage.submit();
await page.waitForURL((url) => !url.pathname.includes('login'), { timeout: 10000 });

const cookies = await page.context().cookies();
const rememberMeCookie = cookies.find((c) => c.name === 'remember-me');
expect(rememberMeCookie).toBeDefined();
expect(rememberMeCookie!.value.length).toBeGreaterThan(0);
expect(rememberMeCookie!.httpOnly).toBe(true);
// A persistent cookie has a future expiry; a session cookie reports expires === -1
expect(rememberMeCookie!.expires).toBeGreaterThan(Date.now() / 1000);
});

test('should not issue remember-me cookie when checkbox is unchecked', async ({
page,
loginPage,
testApiClient,
cleanupEmails,
}) => {
const user = generateTestUser('remember-me-off');
cleanupEmails.push(user.email);

await testApiClient.createUser({
email: user.email,
password: user.password,
firstName: user.firstName,
lastName: user.lastName,
enabled: true,
});

await loginPage.loginAndWait(user.email, user.password);

const cookies = await page.context().cookies();
expect(cookies.find((c) => c.name === 'remember-me')).toBeUndefined();
});
});

test.describe('Session Expiry', () => {
test('should auto-login from remember-me cookie after session cookie is gone', async ({
page,
loginPage,
protectedPage,
testApiClient,
cleanupEmails,
}) => {
const user = generateTestUser('remember-me-relogin');
cleanupEmails.push(user.email);

await testApiClient.createUser({
email: user.email,
password: user.password,
firstName: user.firstName,
lastName: user.lastName,
enabled: true,
});

await loginPage.goto();
await loginPage.fillCredentials(user.email, user.password);
await loginPage.checkRememberMe();
await loginPage.submit();
await page.waitForURL((url) => !url.pathname.includes('login'), { timeout: 10000 });

const cookies = await page.context().cookies();
const rememberMeCookie = cookies.find((c) => c.name === 'remember-me');
expect(rememberMeCookie).toBeDefined();
expect(rememberMeCookie!.expires).toBeGreaterThan(Date.now() / 1000);

// Drop the server session cookie, simulating an expired/closed session.
// The remember-me cookie survives and should re-authenticate the request.
await page.context().clearCookies({ name: 'JSESSIONID' });
Comment on lines +102 to +104

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Playwright has supported filtering in BrowserContext.clearCookies() since v1.43 (this repo uses ^1.58) — see https://playwright.dev/docs/api/class-browsercontext#browser-context-clear-cookies. The test passes locally and in this PR's Playwright E2E CI job, which exercises exactly this call.


await protectedPage.goto();
expect(page.url()).not.toContain('login');
expect(await protectedPage.isLoggedIn()).toBe(true);
});

test('should require fresh login without remember-me once session cookie is gone', async ({
page,
loginPage,
protectedPage,
testApiClient,
cleanupEmails,
}) => {
const user = generateTestUser('remember-me-baseline');
cleanupEmails.push(user.email);

await testApiClient.createUser({
email: user.email,
password: user.password,
firstName: user.firstName,
lastName: user.lastName,
enabled: true,
});

await loginPage.loginAndWait(user.email, user.password);

await page.context().clearCookies({ name: 'JSESSIONID' });

await protectedPage.goto();
await page.waitForURL('**/login**', { timeout: 10000 });
});
});
});
10 changes: 9 additions & 1 deletion src/main/resources/application-prd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,12 @@ user:
# NOTE: allowInitialPasswordSetWithoutStepUp is intentionally left at its secure default (false) here. In
# production, setting an initial password on a passkey-only account should require step-up (a StepUpService bean),
# not just an authenticated session (SUF-02).
disableCSRFdURIs: # No CSRF disabled URIs in production for better security
disableCSRFdURIs: # No CSRF disabled URIs in production for better security
rememberMe:
# No default on purpose: the demo signing key in application.yml must never reach production. Startup fails
# unless REMEMBER_ME_KEY is provided.
key: ${REMEMBER_ME_KEY}
# Force the Secure flag unconditionally, mirroring the session cookie above. Spring's default falls back to
# request.isSecure(), which is false behind a TLS-terminating proxy unless forwarded-header processing is
# configured - without this a ~14-day login token could be sent over plain HTTP.
useSecureCookie: true
9 changes: 9 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ user:
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.
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.
defaultAction: deny # The default action for all requests. This can be either deny or allow.
rememberMe:
enabled: true # Issue a remember-me cookie when the login form posts the remember-me parameter (the checkbox on login.html).
# Secret used to sign remember-me tokens. The fallback is random per start so the demo works out of the box
# without ever running on a publicly-known key - the cost is that remember-me cookies do not survive an app
# restart. Set REMEMBER_ME_KEY (or override this property) to a long random value from your secret manager
# to keep tokens valid across restarts/instances; the prd profile requires it (no fallback).
key: ${REMEMBER_ME_KEY:${random.uuid}}
# tokenValiditySeconds: 1209600 # How long a remember-me token stays valid. Default is 14 days.
# usePersistentTokens: true # Store tokens in the persistent_logins table (see framework db-scripts) so they can be revoked server-side.
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.
protectedURIs: /protected.html # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow.
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.
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/messages/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ label.form.login-link=Sign In
label.form.login-title=Log In
label.form.login-email=Email
label.form.login-pass=Password
label.form.login-remember=Remember me
label.form.login-signup=Sign up
label.form.update-user=Update Your Profile
label.form.delete-account=Delete Your Account
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/templates/user/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ <h5 th:utext="#{label.form.login-title}">Log in with</h5>
th:placeholder="#{label.form.login-pass}" aria-label="Password">
</div>
</div>
<div class="form-check text-start mb-3">
<input type="checkbox" id="remember-me" name="remember-me" class="form-check-input">
<label for="remember-me" class="form-check-label" th:utext="#{label.form.login-remember}">Remember me</label>
</div>
<div class="d-grid">
<button type="submit" th:utext="#{action.login}" class="btn btn-primary">
Log In
Expand Down
Loading