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->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)) { if (!empty($redirect_url)) {
[$url, ] = explode('?', $redirect_url); [$url, ] = explode('?', $redirect_url);
if ($url !== $this->urlGenerator->linkToRoute('core.login.logout')) { if ($url !== $this->urlGenerator->linkToRoute('core.login.logout')) {
@ -287,6 +292,7 @@ class LoginController extends Controller {
ITrustedDomainHelper $trustedDomainHelper, ITrustedDomainHelper $trustedDomainHelper,
string $user = '', string $user = '',
string $password = '', string $password = '',
bool $rememberme = false,
?string $redirect_url = null, ?string $redirect_url = null,
string $timezone = '', string $timezone = '',
string $timezone_offset = '', string $timezone_offset = '',
@ -339,9 +345,10 @@ class LoginController extends Controller {
$this->request, $this->request,
$user, $user,
$password, $password,
$rememberme,
$redirect_url, $redirect_url,
$timezone, $timezone,
$timezone_offset $timezone_offset,
); );
$result = $loginChain->process($data); $result = $loginChain->process($data);
if (!$result->isSuccess()) { if (!$result->isSuccess()) {

@ -84,6 +84,17 @@
data-login-form-input-password data-login-form-input-password
required /> 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" /> <LoginButton data-login-form-submit :loading="loading" />
<input <input
@ -117,6 +128,7 @@ import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n' import { translate as t } from '@nextcloud/l10n'
import { generateUrl, imagePath } from '@nextcloud/router' import { generateUrl, imagePath } from '@nextcloud/router'
import debounce from 'debounce' import debounce from 'debounce'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcTextField from '@nextcloud/vue/components/NcTextField' import NcTextField from '@nextcloud/vue/components/NcTextField'
@ -128,6 +140,7 @@ export default {
components: { components: {
LoginButton, LoginButton,
NcCheckboxRadioSwitch,
NcPasswordField, NcPasswordField,
NcTextField, NcTextField,
NcNoteCard, NcNoteCard,
@ -166,6 +179,11 @@ export default {
default: true, default: true,
}, },
remembermeAllowed: {
type: Boolean,
default: true,
},
directLogin: { directLogin: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -200,6 +218,7 @@ export default {
loading: false, loading: false,
user: props.username, user: props.username,
password: '', password: '',
rememberme: [],
visible: false, visible: false,
} }
}, },

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

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

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

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

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