feat(login): Add rememberme checkbox

Only present if allowed by configuration.

Signed-off-by: Côme Chilliet <come.chilliet@nextcloud.com>
pull/56343/head
Côme Chilliet 2025-11-10 14:10:18 +07:00 committed by nextcloud-command
parent 28b48eec39
commit 4e83d20837
7 changed files with 85 additions and 22 deletions

@ -143,6 +143,11 @@ class LoginController extends Controller {
$this->config->getSystemValue('login_form_autocomplete', true) === true
);
$this->initialState->provideInitialState(
'loginCanRememberme',
$this->config->getSystemValueInt('remember_login_cookie_lifetime', 60 * 60 * 24 * 15) > 0
);
if (!empty($redirect_url)) {
[$url, ] = explode('?', $redirect_url);
if ($url !== $this->urlGenerator->linkToRoute('core.login.logout')) {
@ -287,6 +292,7 @@ class LoginController extends Controller {
ITrustedDomainHelper $trustedDomainHelper,
string $user = '',
string $password = '',
bool $rememberme = false,
?string $redirect_url = null,
string $timezone = '',
string $timezone_offset = '',
@ -339,9 +345,10 @@ class LoginController extends Controller {
$this->request,
$user,
$password,
$rememberme,
$redirect_url,
$timezone,
$timezone_offset
$timezone_offset,
);
$result = $loginChain->process($data);
if (!$result->isSuccess()) {

@ -84,6 +84,17 @@
data-login-form-input-password
required />
<NcCheckboxRadioSwitch
v-if="remembermeAllowed"
id="rememberme"
ref="rememberme"
name="rememberme"
value="1"
:checked.sync="rememberme"
data-login-form-input-rememberme>
{{ t('core', 'Remember me') }}
</NcCheckboxRadioSwitch>
<LoginButton data-login-form-submit :loading="loading" />
<input
@ -117,6 +128,7 @@ import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { generateUrl, imagePath } from '@nextcloud/router'
import debounce from 'debounce'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcTextField from '@nextcloud/vue/components/NcTextField'
@ -128,6 +140,7 @@ export default {
components: {
LoginButton,
NcCheckboxRadioSwitch,
NcPasswordField,
NcTextField,
NcNoteCard,
@ -166,6 +179,11 @@ export default {
default: true,
},
remembermeAllowed: {
type: Boolean,
default: true,
},
directLogin: {
type: Boolean,
default: false,
@ -200,6 +218,7 @@ export default {
loading: false,
user: props.username,
password: '',
rememberme: [],
visible: false,
}
},

@ -16,6 +16,7 @@
:errors="errors"
:throttle-delay="throttleDelay"
:auto-complete-allowed="autoCompleteAllowed"
:rememberme-allowed="remembermeAllowed"
:email-states="emailStates"
@submit="loading = true" />
<NcButton
@ -148,6 +149,7 @@ export default {
canResetPassword: loadState('core', 'loginCanResetPassword', false),
resetPasswordLink: loadState('core', 'loginResetPasswordLink', ''),
autoCompleteAllowed: loadState('core', 'loginAutocomplete', true),
remembermeAllowed: loadState('core', 'loginCanRememberme', true),
resetPasswordTarget: loadState('core', 'resetPasswordTarget', ''),
resetPasswordUser: loadState('core', 'resetPasswordUser', ''),
directLogin: query.direct === '1',

@ -26,9 +26,12 @@ class CreateSessionTokenCommand extends ALoginCommand {
}
public function process(LoginData $loginData): LoginResult {
$tokenType = IToken::REMEMBER;
if ($this->config->getSystemValueInt('remember_login_cookie_lifetime', 60 * 60 * 24 * 15) === 0) {
$loginData->setRememberLogin(false);
}
if ($loginData->isRememberLogin()) {
$tokenType = IToken::REMEMBER;
} else {
$tokenType = IToken::DO_NOT_REMEMBER;
}

@ -16,12 +16,11 @@ class LoginData {
/** @var IUser|false|null */
private $user = null;
private bool $rememberLogin = true;
public function __construct(
private IRequest $request,
private string $username,
private ?string $password,
private bool $rememberLogin = true,
private ?string $redirectUrl = null,
private string $timeZone = '',
private string $timeZoneOffset = '',

@ -31,6 +31,7 @@ use OCP\IUserManager;
use OCP\Notification\IManager;
use OCP\Security\Bruteforce\IThrottler;
use OCP\Security\ITrustedDomainHelper;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
@ -277,7 +278,7 @@ class LoginControllerTest extends TestCase {
'',
]
];
$this->initialState->expects($this->exactly(13))
$this->initialState->expects($this->exactly(14))
->method('provideInitialState')
->willReturnCallback(function () use (&$calls): void {
$expected = array_shift($calls);
@ -309,12 +310,16 @@ class LoginControllerTest extends TestCase {
'loginAutocomplete',
false
],
[
'loginCanRememberme',
false
],
[
'loginRedirectUrl',
'login/flow'
],
];
$this->initialState->expects($this->exactly(14))
$this->initialState->expects($this->exactly(15))
->method('provideInitialState')
->willReturnCallback(function () use (&$calls): void {
$expected = array_shift($calls);
@ -351,7 +356,7 @@ class LoginControllerTest extends TestCase {
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('passwordResetDataProvider')]
#[DataProvider('passwordResetDataProvider')]
public function testShowLoginFormWithPasswordResetOption($canChangePassword,
$expectedResult): void {
$this->userSession
@ -386,13 +391,13 @@ class LoginControllerTest extends TestCase {
'loginUsername',
'LdapUser'
],
[], [], [],
[], [], [], [],
[
'loginCanResetPassword',
$expectedResult
],
];
$this->initialState->expects($this->exactly(13))
$this->initialState->expects($this->exactly(14))
->method('provideInitialState')
->willReturnCallback(function () use (&$calls): void {
$expected = array_shift($calls);
@ -445,6 +450,10 @@ class LoginControllerTest extends TestCase {
'loginAutocomplete',
true
],
[
'loginCanRememberme',
false
],
[],
[
'loginResetPasswordLink',
@ -455,7 +464,7 @@ class LoginControllerTest extends TestCase {
false
],
];
$this->initialState->expects($this->exactly(13))
$this->initialState->expects($this->exactly(14))
->method('provideInitialState')
->willReturnCallback(function () use (&$calls): void {
$expected = array_shift($calls);
@ -476,7 +485,19 @@ class LoginControllerTest extends TestCase {
$this->assertEquals($expectedResponse, $this->loginController->showLoginForm('0', ''));
}
public function testLoginWithInvalidCredentials(): void {
public static function remembermeProvider(): array {
return [
[
true,
],
[
false,
],
];
}
#[DataProvider('remembermeProvider')]
public function testLoginWithInvalidCredentials(bool $rememberme): void {
$user = 'MyUserName';
$password = 'secret';
$loginPageUrl = '/login?redirect_url=/apps/files';
@ -491,6 +512,7 @@ class LoginControllerTest extends TestCase {
$this->request,
$user,
$password,
$rememberme,
'/apps/files'
);
$loginResult = LoginResult::failure($loginData, LoginController::LOGIN_MSG_INVALIDPASSWORD);
@ -509,12 +531,13 @@ class LoginControllerTest extends TestCase {
$expected = new RedirectResponse($loginPageUrl);
$expected->throttle(['user' => 'MyUserName']);
$response = $this->loginController->tryLogin($loginChain, $trustedDomainHelper, $user, $password, '/apps/files');
$response = $this->loginController->tryLogin($loginChain, $trustedDomainHelper, $user, $password, $rememberme, '/apps/files');
$this->assertEquals($expected, $response);
}
public function testLoginWithValidCredentials(): void {
#[DataProvider('remembermeProvider')]
public function testLoginWithValidCredentials(bool $rememberme): void {
$user = 'MyUserName';
$password = 'secret';
$loginChain = $this->createMock(LoginChain::class);
@ -527,7 +550,8 @@ class LoginControllerTest extends TestCase {
$loginData = new LoginData(
$this->request,
$user,
$password
$password,
$rememberme,
);
$loginResult = LoginResult::success($loginData);
$loginChain->expects($this->once())
@ -540,10 +564,11 @@ class LoginControllerTest extends TestCase {
->willReturn('/default/foo');
$expected = new RedirectResponse('/default/foo');
$this->assertEquals($expected, $this->loginController->tryLogin($loginChain, $trustedDomainHelper, $user, $password));
$this->assertEquals($expected, $this->loginController->tryLogin($loginChain, $trustedDomainHelper, $user, $password, $rememberme));
}
public function testLoginWithoutPassedCsrfCheckAndNotLoggedIn(): void {
#[DataProvider('remembermeProvider')]
public function testLoginWithoutPassedCsrfCheckAndNotLoggedIn(bool $rememberme): void {
/** @var IUser|MockObject $user */
$user = $this->createMock(IUser::class);
$user->expects($this->any())
@ -567,14 +592,15 @@ class LoginControllerTest extends TestCase {
$this->userSession->expects($this->never())
->method('createRememberMeToken');
$response = $this->loginController->tryLogin($loginChain, $trustedDomainHelper, 'Jane', $password, $originalUrl);
$response = $this->loginController->tryLogin($loginChain, $trustedDomainHelper, 'Jane', $password, $rememberme, $originalUrl);
$expected = new RedirectResponse('');
$expected->throttle(['user' => 'Jane']);
$this->assertEquals($expected, $response);
}
public function testLoginWithoutPassedCsrfCheckAndLoggedIn(): void {
#[DataProvider('remembermeProvider')]
public function testLoginWithoutPassedCsrfCheckAndLoggedIn(bool $rememberme): void {
/** @var IUser|MockObject $user */
$user = $this->createMock(IUser::class);
$user->expects($this->any())
@ -607,13 +633,14 @@ class LoginControllerTest extends TestCase {
->with('remember_login_cookie_lifetime')
->willReturn(1234);
$response = $this->loginController->tryLogin($loginChain, $trustedDomainHelper, 'Jane', $password, $originalUrl);
$response = $this->loginController->tryLogin($loginChain, $trustedDomainHelper, 'Jane', $password, $rememberme, $originalUrl);
$expected = new RedirectResponse($redirectUrl);
$this->assertEquals($expected, $response);
}
public function testLoginWithValidCredentialsAndRedirectUrl(): void {
#[DataProvider('remembermeProvider')]
public function testLoginWithValidCredentialsAndRedirectUrl(bool $rememberme): void {
$user = 'MyUserName';
$password = 'secret';
$redirectUrl = 'https://next.cloud/apps/mail';
@ -628,6 +655,7 @@ class LoginControllerTest extends TestCase {
$this->request,
$user,
$password,
$rememberme,
'/apps/mail'
);
$loginResult = LoginResult::success($loginData);
@ -644,12 +672,13 @@ class LoginControllerTest extends TestCase {
->willReturn($redirectUrl);
$expected = new RedirectResponse($redirectUrl);
$response = $this->loginController->tryLogin($loginChain, $trustedDomainHelper, $user, $password, '/apps/mail');
$response = $this->loginController->tryLogin($loginChain, $trustedDomainHelper, $user, $password, $rememberme, '/apps/mail');
$this->assertEquals($expected, $response);
}
public function testToNotLeakLoginName(): void {
#[DataProvider('remembermeProvider')]
public function testToNotLeakLoginName(bool $rememberme): void {
$loginChain = $this->createMock(LoginChain::class);
$trustedDomainHelper = $this->createMock(ITrustedDomainHelper::class);
$trustedDomainHelper->method('isTrustedUrl')->willReturn(true);
@ -662,6 +691,7 @@ class LoginControllerTest extends TestCase {
$this->request,
'john@doe.com',
'just wrong',
$rememberme,
'/apps/files'
);
$loginResult = LoginResult::failure($loginData, LoginController::LOGIN_MSG_INVALIDPASSWORD);
@ -688,6 +718,7 @@ class LoginControllerTest extends TestCase {
$trustedDomainHelper,
'john@doe.com',
'just wrong',
$rememberme,
'/apps/files'
);

@ -83,6 +83,7 @@ abstract class ALoginTestCommand extends TestCase {
$this->request,
$this->username,
$this->password,
true,
$this->redirectUrl
);
$data->setUser($this->user);
@ -94,6 +95,7 @@ abstract class ALoginTestCommand extends TestCase {
$this->request,
$this->username,
$this->password,
true,
null,
$this->timezone,
$this->timeZoneOffset