diff --git a/README.md b/README.md index 966ea60..1b2ae9e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.gradle b/build.gradle index 69f8867..6bb2621 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/playwright/src/pages/LoginPage.ts b/playwright/src/pages/LoginPage.ts index 4b880bf..4816341 100644 --- a/playwright/src/pages/LoginPage.ts +++ b/playwright/src/pages/LoginPage.ts @@ -10,6 +10,7 @@ export class LoginPage extends BasePage { // Form elements readonly emailInput: Locator; readonly passwordInput: Locator; + readonly rememberMeCheckbox: Locator; readonly submitButton: Locator; // Links @@ -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 @@ -47,6 +49,13 @@ export class LoginPage extends BasePage { await this.passwordInput.fill(password); } + /** + * Check the remember-me checkbox. + */ + async checkRememberMe(): Promise { + await this.rememberMeCheckbox.check(); + } + /** * Submit the login form. */ diff --git a/playwright/tests/auth/remember-me.spec.ts b/playwright/tests/auth/remember-me.spec.ts new file mode 100644 index 0000000..f7d0c9d --- /dev/null +++ b/playwright/tests/auth/remember-me.spec.ts @@ -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' }); + + 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 }); + }); + }); +}); diff --git a/src/main/resources/application-prd.yml b/src/main/resources/application-prd.yml index 8ce600b..7c74024 100644 --- a/src/main/resources/application-prd.yml +++ b/src/main/resources/application-prd.yml @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 93c5597..a388087 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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. diff --git a/src/main/resources/messages/messages.properties b/src/main/resources/messages/messages.properties index 4fb1897..201ccd1 100644 --- a/src/main/resources/messages/messages.properties +++ b/src/main/resources/messages/messages.properties @@ -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 diff --git a/src/main/resources/templates/user/login.html b/src/main/resources/templates/user/login.html index ca1505c..94ed104 100644 --- a/src/main/resources/templates/user/login.html +++ b/src/main/resources/templates/user/login.html @@ -74,6 +74,10 @@
Log in with
th:placeholder="#{label.form.login-pass}" aria-label="Password"> +
+ + +