Skip to content

Commit fec5f7b

Browse files
authored
feat: implement remember-me login (Feature 023) (#4134)
1 parent a3a6645 commit fec5f7b

37 files changed

Lines changed: 709 additions & 27 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ LOG_STDOUT=false
125125
# Session configuration
126126
SESSION_DRIVER=file
127127
SESSION_LIFETIME=120
128+
# Duration (in minutes) for the "Remember Me" cookie. Default: 40320 (4 weeks)
129+
# REMEMBER_LIFETIME=40320
128130

129131
# `sync` if jobs need to be executed live (default) or `database` if they can be deferred.
130132
QUEUE_CONNECTION=sync

app/Contracts/Http/Requests/RequestAttribute.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class RequestAttribute
4747
public const ALBUM_ASPECT_RATIO_ATTRIBUTE = 'album_aspect_ratio';
4848

4949
public const USERNAME_ATTRIBUTE = 'username';
50+
public const REMEMBER_ME_ATTRIBUTE = 'remember_me';
5051

5152
public const PASSWORD_ATTRIBUTE = 'password';
5253
public const OLD_PASSWORD_ATTRIBUTE = 'old_password';

app/Http/Controllers/AuthController.php

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,12 @@ public function login(LoginRequest $request): void
4545
$username = $request->username();
4646
$password = $request->password();
4747
$ip = $request->ip();
48+
$remember = $request->rememberMe();
4849

4950
try {
5051
// Try LDAP authentication first if enabled
51-
if ($this->isLdapEnabled($request) && $this->attemptLdapLogin($username, $password)) {
52-
Log::channel('login')->notice(__METHOD__ . ':' . __LINE__ . ' -- User (' . $username . ') has logged in via LDAP from ' . $ip);
52+
if ($this->isLdapEnabled($request) && $this->attemptLdapLogin($username, $password, $remember)) {
53+
Log::channel('login')->notice(__METHOD__ . ':' . __LINE__ . ' -- User (' . $username . ') has logged in via LDAP from ' . $ip . ' [remember=' . ($remember ? 'true' : 'false') . ']');
5354

5455
return;
5556
}
@@ -62,8 +63,8 @@ public function login(LoginRequest $request): void
6263
if (Auth::attempt([
6364
'username' => $username,
6465
'password' => $password,
65-
])) {
66-
Log::channel('login')->notice(__METHOD__ . ':' . __LINE__ . ' -- User (' . $username . ') has logged in from ' . $ip);
66+
], $remember)) {
67+
Log::channel('login')->notice(__METHOD__ . ':' . __LINE__ . ' -- User (' . $username . ') has logged in from ' . $ip . ' [remember=' . ($remember ? 'true' : 'false') . ']');
6768

6869
return;
6970
}
@@ -132,7 +133,7 @@ protected function isLdapEnabled(Request $request): bool
132133
*
133134
* @return bool True if authentication succeeded, false otherwise
134135
*/
135-
private function attemptLdapLogin(string $username, string $password): bool
136+
private function attemptLdapLogin(string $username, string $password, bool $remember = false): bool
136137
{
137138
try {
138139
// Create LDAP configuration and service
@@ -151,7 +152,7 @@ private function attemptLdapLogin(string $username, string $password): bool
151152
$user = $provision_action->do($ldap_user);
152153

153154
// Log the user in
154-
Auth::login($user);
155+
Auth::login($user, $remember);
155156

156157
return true;
157158
} catch (LdapConnectionException $e) {

app/Http/Requests/Session/LoginRequest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class LoginRequest extends BaseApiRequest implements HasUsername, HasPassword
2424
use HasUsernameTrait;
2525
use HasPasswordTrait;
2626

27+
protected bool $remember_me = false;
28+
2729
/**
2830
* {@inheritDoc}
2931
*/
@@ -40,6 +42,7 @@ public function rules(): array
4042
return [
4143
RequestAttribute::USERNAME_ATTRIBUTE => ['required', new UsernameRule()],
4244
RequestAttribute::PASSWORD_ATTRIBUTE => ['required', new PasswordRule(false)],
45+
RequestAttribute::REMEMBER_ME_ATTRIBUTE => ['sometimes', 'boolean'],
4346
];
4447
}
4548

@@ -50,5 +53,14 @@ protected function processValidatedValues(array $values, array $files): void
5053
{
5154
$this->username = $values[RequestAttribute::USERNAME_ATTRIBUTE];
5255
$this->password = $values[RequestAttribute::PASSWORD_ATTRIBUTE];
56+
$this->remember_me = $values[RequestAttribute::REMEMBER_ME_ATTRIBUTE] ?? false;
57+
}
58+
59+
/**
60+
* Returns whether the user wants to be remembered.
61+
*/
62+
public function rememberMe(): bool
63+
{
64+
return $this->remember_me;
5365
}
5466
}

config/auth.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
'lychee' => [
4242
'driver' => env('ENABLE_BEARER_TOKEN_AUTH', env('ENABLE_TOKEN_AUTH', true)) ? 'session-or-token' : 'session',
4343
'provider' => 'users',
44+
'remember' => (int) env('REMEMBER_LIFETIME', 40320), // 4 weeks = 28 days × 24 h × 60 min
4445
],
4546
],
4647

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# Feature Plan 023 – Remember Me Login
2+
3+
_Linked specification:_ `docs/specs/4-architecture/features/023-remember-me-login/spec.md`
4+
_Status:_ Draft
5+
_Last updated:_ 2026-02-28
6+
7+
> Guardrail: Keep this plan traceable back to the governing spec. Reference FR/NFR/Scenario IDs from `spec.md` where relevant, log any new high- or medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), and assume clarifications are resolved only when the spec's normative sections have been updated.
8+
9+
## Vision & Success Criteria
10+
11+
**User value:** Users can check a "Remember Me" box during login to stay authenticated across browser restarts and session expiry, eliminating the need to re-enter credentials on every visit to their Lychee instance.
12+
13+
**Success signals:**
14+
- Login with "Remember Me" checked sets a long-lived remember cookie that survives browser restart.
15+
- Login without "Remember Me" (or with absent field) behaves identically to the current implementation.
16+
- Logout properly invalidates the remember cookie and rotates the `remember_token`.
17+
- LDAP login with "Remember Me" works identically to local login.
18+
- All existing login tests pass without modification (backward compatibility).
19+
- Frontend checkbox visible only when basic auth is enabled.
20+
21+
## Scope Alignment
22+
23+
- **In scope:**
24+
- `LoginRequest` validation: add optional `remember_me` boolean field.
25+
- `RequestAttribute` constant: `REMEMBER_ME_ATTRIBUTE = 'remember_me'`.
26+
- `AuthController::login()`: pass `remember` flag to `Auth::attempt()` (local) and `Auth::login()` (LDAP).
27+
- `auth-service.ts`: send `remember_me` in POST body.
28+
- `LoginForm.vue`: add checkbox with `remember_me` ref, pass to login service.
29+
- Translation strings for "Remember me" in 22 languages.
30+
- Feature tests for remember cookie presence/absence.
31+
32+
- **Out of scope:**
33+
- Admin-configurable remember duration via settings UI (use `REMEMBER_LIFETIME` env variable; default 4 weeks).
34+
- WebAuthn/OAuth remember-me support.
35+
- Remember-me for the migration/setup authentication flow.
36+
- Session lifetime configuration changes.
37+
38+
## Dependencies & Interfaces
39+
40+
- **Laravel SessionGuard** — Provides the remember cookie mechanism. `SessionOrTokenGuard` extends this and already accepts `$remember` in `login()`.
41+
- **`remember_token` column** — Already exists in the `users` table (Laravel default migration). No new migration needed.
42+
- **`SessionOrTokenGuard::login()`** — Already accepts `$remember = false` parameter (line 274). Just needs to be called with `true`.
43+
- **PrimeVue Checkbox** — Use PrimeVue's `Checkbox` component for the login form.
44+
- **Translation system** — 22 language files under `lang/<locale>/`.
45+
46+
## Assumptions & Risks
47+
48+
- **Assumptions:**
49+
- The `remember_token` column in the `users` table is functional (Laravel default).
50+
- `SessionOrTokenGuard`'s `recaller()` method (inherited from Laravel's `SessionGuard`) correctly handles remember-me cookies.
51+
- The `remember` duration defaults to 4 weeks (40320 minutes), set via `config/auth.php` guard config with `REMEMBER_LIFETIME` env override (Q-023-01 resolved → Option C).
52+
53+
- **Risks / Mitigations:**
54+
- **R1: Remember cookie not set due to guard misconfiguration.** Mitigation: Write a feature test that asserts the cookie is present in the response.
55+
- **R2: LDAP users may not persist `remember_token` correctly.** Mitigation: LDAP users are provisioned as local `User` records which have the `remember_token` column. Test explicitly.
56+
- **R3: Checkbox accessibility.** Mitigation: Use PrimeVue's accessible `Checkbox` component with proper `aria-label` and `id`/`label` binding.
57+
58+
## Implementation Drift Gate
59+
60+
After each increment, verify:
61+
1. `make phpstan` — Zero errors
62+
2. `php artisan test` — All tests pass
63+
3. `vendor/bin/php-cs-fixer fix --dry-run` — Code style clean
64+
4. `npm run check` — TypeScript/Vue checks pass
65+
5. Check `tasks.md` checkboxes match actual progress
66+
67+
Record drift findings in this plan's Follow-ups section.
68+
69+
## Increment Map
70+
71+
### I1 – Backend: LoginRequest + AuthController + RequestAttribute (~45 min)
72+
73+
- _Goal:_ Wire the `remember_me` parameter through the backend login flow.
74+
- _Preconditions:_ Clean test suite, understanding of `SessionOrTokenGuard` remember behavior.
75+
- _Steps:_
76+
1. Write failing test: POST `/Auth::login` with `remember_me = true` → verify remember cookie in response.
77+
2. Write failing test: POST `/Auth::login` with `remember_me = false` → verify no remember cookie.
78+
3. Write failing test: POST `/Auth::login` without `remember_me` → verify no remember cookie (backward compat).
79+
4. Add `REMEMBER_ME_ATTRIBUTE = 'remember_me'` to `RequestAttribute`.
80+
5. Add `HasRememberMe` contract interface and `HasRememberMeTrait` trait (or add directly to `LoginRequest`), with validation rule: `'remember_me' => ['sometimes', 'boolean']` defaulting to `false`.
81+
6. Update `AuthController::login()`:
82+
- Read `$remember = $request->remember()` (or similar accessor).
83+
- Pass to `Auth::attempt([...], $remember)` for local auth.
84+
- Pass to `Auth::login($user, $remember)` for LDAP auth.
85+
7. Update login log messages to include remember flag.
86+
8. Run `make phpstan` and `php artisan test`.
87+
- _Commands:_ `make phpstan`, `XDEBUG_MODE=off php artisan test --no-coverage`
88+
- _Exit:_ Login with `remember_me = true` sets remember cookie. Login without or with `false` does not. All existing tests green.
89+
- _Refs:_ FR-023-01, FR-023-04, FR-023-05, FR-023-06, S-023-01 through S-023-08, S-023-11, NFR-023-02, NFR-023-03, NFR-023-04
90+
91+
### I2 – Frontend: Checkbox + Auth Service (~45 min)
92+
93+
- _Goal:_ Add the "Remember Me" checkbox to the login form and wire it to the API.
94+
- _Preconditions:_ I1 complete (backend accepts `remember_me`).
95+
- _Steps:_
96+
1. Update `auth-service.ts`: add `remember_me` parameter to `login()` method.
97+
2. Update `LoginForm.vue`:
98+
- Add a `remember_me` ref (default `false`).
99+
- Add PrimeVue `Checkbox` below the password field, bound to `remember_me`.
100+
- Pass `remember_me.value` to `AuthService.login()`.
101+
- Checkbox only rendered inside the `v-if="is_basic_auth_enabled"` block.
102+
3. Verify checkbox defaults to unchecked.
103+
4. Verify checkbox hidden when basic auth is not enabled.
104+
5. Run `npm run check` and `npm run format`.
105+
- _Commands:_ `npm run check`, `npm run format`
106+
- _Exit:_ Login form shows "Remember Me" checkbox. Checking it sends `remember_me: true` to the backend.
107+
- _Refs:_ FR-023-02, FR-023-03, S-023-09, S-023-10, UI-023-01, UI-023-02, UI-023-03
108+
109+
### I3 – Translations (~30 min)
110+
111+
- _Goal:_ Add translation strings for "Remember me" in all 22 supported languages.
112+
- _Preconditions:_ I2 complete (knows the translation key needed).
113+
- _Steps:_
114+
1. Add English translation: `'remember_me' => 'Remember me'` in the appropriate dialog/login section of `lang/en/lychee.php` (or equivalent).
115+
2. Add placeholder translations for all 21 other languages using the English string.
116+
3. Run `php artisan test` to verify translations don't break.
117+
- _Commands:_ `XDEBUG_MODE=off php artisan test --no-coverage`, `vendor/bin/php-cs-fixer fix --dry-run`
118+
- _Exit:_ Translation key available in all 22 languages.
119+
- _Refs:_ NFR-023-05
120+
121+
### I4 – Integration Tests & Cleanup (~30 min)
122+
123+
- _Goal:_ End-to-end verification and documentation updates.
124+
- _Preconditions:_ I1, I2, I3 complete.
125+
- _Steps:_
126+
1. End-to-end test: login with remember → close session → request with remember cookie → authenticated.
127+
2. End-to-end test: login with remember → logout → request with old remember cookie → not authenticated.
128+
3. Verify backward compatibility: existing login tests pass unchanged.
129+
4. Run full quality gate.
130+
5. Update knowledge map.
131+
6. Update roadmap.
132+
- _Commands:_ `make phpstan`, `XDEBUG_MODE=off php artisan test --no-coverage`, `vendor/bin/php-cs-fixer fix`, `npm run format`, `npm run check`
133+
- _Exit:_ All quality gates pass, feature complete.
134+
- _Refs:_ All scenarios
135+
136+
## Scenario Tracking
137+
138+
| Scenario ID | Increment / Task reference | Notes |
139+
|-------------|---------------------------|-------|
140+
| S-023-01 | I1 | Login without remember → session only |
141+
| S-023-02 | I1 | Login with remember → remember cookie set |
142+
| S-023-03 | I1 / I4 | Session expires, remember cookie re-authenticates |
143+
| S-023-04 | I1 / I4 | Logout invalidates remember cookie |
144+
| S-023-05 | I1 | Absent field → backward compatible |
145+
| S-023-06 | I1 | Invalid credentials + remember → no cookie |
146+
| S-023-07 | I1 | LDAP + remember |
147+
| S-023-08 | I1 | LDAP unreachable + remember fallback |
148+
| S-023-09 | I2 | Checkbox defaults to unchecked |
149+
| S-023-10 | I2 | Checkbox hidden without basic auth |
150+
| S-023-11 | I1 | Non-boolean remember → 422 |
151+
152+
## Analysis Gate
153+
154+
Not yet completed. Will be run after spec, plan, and tasks agree.
155+
156+
## Exit Criteria
157+
158+
- [ ] `LoginRequest` validates optional `remember_me` boolean (defaults to `false`)
159+
- [ ] `AuthController::login()` passes `remember` to `Auth::attempt()` and `Auth::login()`
160+
- [ ] LDAP login respects `remember` flag
161+
- [ ] Login log messages include remember flag
162+
- [ ] Frontend checkbox renders conditionally (basic auth only)
163+
- [ ] `AuthService.login()` sends `remember_me` parameter
164+
- [ ] Remember cookie set on `remember_me = true`, absent on `false`
165+
- [ ] Logout invalidates remember cookie
166+
- [ ] All existing login tests pass unchanged (backward compatibility)
167+
- [ ] Translation strings present in all 22 languages
168+
- [ ] PHPStan, php-cs-fixer, npm check/format all clean
169+
- [ ] Knowledge map and roadmap updated
170+
171+
## Follow-ups / Backlog
172+
173+
- **Admin-configurable remember duration via UI** — Consider adding a settings UI control for the remember cookie lifetime (currently configurable via `REMEMBER_LIFETIME` env variable, default 4 weeks).
174+
- **"Remember Me" for WebAuthn** — Investigate if WebAuthn sessions can benefit from a similar persistence mechanism.
175+
- **Session management UI** — Allow users to see and revoke active remember-me sessions (list of devices/tokens).
176+
177+
---
178+
179+
*Last updated: 2026-02-28*

0 commit comments

Comments
 (0)