Skip to content

Commit

Permalink
Merge pull request #44438 from nextcloud/feat/login-form-timeout
Browse files Browse the repository at this point in the history
feat(login): Clear login form (password) after IDLE timeout
  • Loading branch information
susnux authored Mar 25, 2024
2 parents 0d7bb0b + d224914 commit 7d51b6f
Show file tree
Hide file tree
Showing 10 changed files with 188 additions and 54 deletions.
8 changes: 8 additions & 0 deletions config/config.sample.php
Original file line number Diff line number Diff line change
Expand Up @@ -2307,6 +2307,14 @@

'login_form_autocomplete' => true,

/**
* Timeout for the login form, after this time the login form is reset.
* This prevents password leaks on public devices if the user forgots to clear the form.
*
* Default is 5 minutes (300 seconds), a value of 0 means no timeout.
*/
'login_form_timeout' => 300,

/**
* If your user is using an outdated or unsupported browser, a warning will be shown
* to offer some guidance to upgrade or switch and ensure a proper Nextcloud experience.
Expand Down
41 changes: 17 additions & 24 deletions core/Controller/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Defaults;
use OCP\IConfig;
use OCP\IInitialStateService;
use OCP\IL10N;
use OCP\IRequest;
use OCP\ISession;
Expand All @@ -81,7 +81,7 @@ public function __construct(
private IURLGenerator $urlGenerator,
private Defaults $defaults,
private IThrottler $throttler,
private IInitialStateService $initialStateService,
private IInitialState $initialState,
private WebAuthnManager $webAuthnManager,
private IManager $manager,
private IL10N $l10n,
Expand Down Expand Up @@ -148,32 +148,30 @@ public function showLoginForm(string $user = null, string $redirect_url = null):
}
if (is_array($loginMessages)) {
[$errors, $messages] = $loginMessages;
$this->initialStateService->provideInitialState('core', 'loginMessages', $messages);
$this->initialStateService->provideInitialState('core', 'loginErrors', $errors);
$this->initialState->provideInitialState('loginMessages', $messages);
$this->initialState->provideInitialState('loginErrors', $errors);
}
$this->session->remove('loginMessages');

if ($user !== null && $user !== '') {
$this->initialStateService->provideInitialState('core', 'loginUsername', $user);
$this->initialState->provideInitialState('loginUsername', $user);
} else {
$this->initialStateService->provideInitialState('core', 'loginUsername', '');
$this->initialState->provideInitialState('loginUsername', '');
}

$this->initialStateService->provideInitialState(
'core',
$this->initialState->provideInitialState(
'loginAutocomplete',
$this->config->getSystemValue('login_form_autocomplete', true) === true
);

if (!empty($redirect_url)) {
[$url, ] = explode('?', $redirect_url);
if ($url !== $this->urlGenerator->linkToRoute('core.login.logout')) {
$this->initialStateService->provideInitialState('core', 'loginRedirectUrl', $redirect_url);
$this->initialState->provideInitialState('loginRedirectUrl', $redirect_url);
}
}

$this->initialStateService->provideInitialState(
'core',
$this->initialState->provideInitialState(
'loginThrottleDelay',
$this->throttler->getDelay($this->request->getRemoteAddress())
);
Expand All @@ -182,9 +180,9 @@ public function showLoginForm(string $user = null, string $redirect_url = null):

$this->setEmailStates();

$this->initialStateService->provideInitialState('core', 'webauthn-available', $this->webAuthnManager->isWebAuthnAvailable());
$this->initialState->provideInitialState('webauthn-available', $this->webAuthnManager->isWebAuthnAvailable());

$this->initialStateService->provideInitialState('core', 'hideLoginForm', $this->config->getSystemValueBool('hide_login_form', false));
$this->initialState->provideInitialState('hideLoginForm', $this->config->getSystemValueBool('hide_login_form', false));

// OpenGraph Support: http://ogp.me/
Util::addHeader('meta', ['property' => 'og:title', 'content' => Util::sanitizeHTML($this->defaults->getName())]);
Expand All @@ -199,8 +197,9 @@ public function showLoginForm(string $user = null, string $redirect_url = null):
'pageTitle' => $this->l10n->t('Login'),
];

$this->initialStateService->provideInitialState('core', 'countAlternativeLogins', count($parameters['alt_login']));
$this->initialStateService->provideInitialState('core', 'alternativeLogins', $parameters['alt_login']);
$this->initialState->provideInitialState('countAlternativeLogins', count($parameters['alt_login']));
$this->initialState->provideInitialState('alternativeLogins', $parameters['alt_login']);
$this->initialState->provideInitialState('loginTimeout', $this->config->getSystemValueInt('login_form_timeout', 5 * 60));

return new TemplateResponse(
$this->appName,
Expand All @@ -224,14 +223,12 @@ private function setPasswordResetInitialState(?string $username): void {

$passwordLink = $this->config->getSystemValueString('lost_password_link', '');

$this->initialStateService->provideInitialState(
'core',
$this->initialState->provideInitialState(
'loginResetPasswordLink',
$passwordLink
);

$this->initialStateService->provideInitialState(
'core',
$this->initialState->provideInitialState(
'loginCanResetPassword',
$this->canResetPassword($passwordLink, $user)
);
Expand All @@ -255,11 +252,7 @@ private function setEmailStates(): void {
array_push($emailStates, $emailConfig->__get('ldapLoginFilterEmail'));
}
}
$this->initialStateService->
provideInitialState(
'core',
'emailStates',
$emailStates);
$this->initialState->provideInitialState('emailStates', $emailStates);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions core/src/components/login/LoginButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
</template>

<script>
import { translate as t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
Expand Down
72 changes: 72 additions & 0 deletions core/src/components/login/LoginForm.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import LoginForm from './LoginForm.vue'

describe('core: LoginForm', { testIsolation: true }, () => {
beforeEach(() => {
// Mock the required global state
cy.window().then(($window) => {
$window.OC = {
theme: {
name: 'J\'s cloud',
},
requestToken: 'request-token',
}
})
})

/**
* Ensure that characters like ' are not double HTML escaped.
* This was a bug in https://github.com/nextcloud/server/issues/34990
*/
it('does not double escape special characters in product name', () => {
cy.mount(LoginForm, {
propsData: {
username: 'test-user',
},
})

cy.get('h2').contains('J\'s cloud')
})

it('fills username from props into form', () => {
cy.mount(LoginForm, {
propsData: {
username: 'test-user',
},
})

cy.get('input[name="user"]')
.should('exist')
.and('have.attr', 'id', 'user')

cy.get('input[name="user"]')
.should('have.value', 'test-user')
})

it('clears password after timeout', () => {
// mock timeout of 5 seconds
cy.window().then(($window) => {
const state = $window.document.createElement('input')
state.type = 'hidden'
state.id = 'initial-state-core-loginTimeout'
state.value = btoa(JSON.stringify(5))
$window.document.body.appendChild(state)
})

// mount forms
cy.mount(LoginForm)

cy.get('input[name="password"]')
.should('exist')
.type('MyPassword')

cy.get('input[name="password"]')
.should('have.value', 'MyPassword')

// Wait for timeout
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(5100)

cy.get('input[name="password"]')
.should('have.value', '')
})
})
61 changes: 54 additions & 7 deletions core/src/components/login/LoginForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@
<!-- the following div ensures that the spinner is always inside the #message div -->
<div style="clear: both;" />
</div>
<h2 class="login-form__headline" data-login-form-headline v-html="headline" />
<h2 class="login-form__headline" data-login-form-headline>
{{ headlineText }}
</h2>
<NcTextField id="user"
ref="user"
:label="loginText"
Expand Down Expand Up @@ -102,7 +104,7 @@
:value="timezoneOffset">
<input type="hidden"
name="requesttoken"
:value="OC.requestToken">
:value="requestToken">
<input v-if="directLogin"
type="hidden"
name="direct"
Expand All @@ -112,15 +114,17 @@
</template>

<script>
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { generateUrl, imagePath } from '@nextcloud/router'
import { debounce } from 'debounce'
import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import LoginButton from './LoginButton.vue'
import AuthMixin from '../../mixins/auth.js'
import LoginButton from './LoginButton.vue'
export default {
name: 'LoginForm',
Expand All @@ -131,6 +135,7 @@ export default {
NcTextField,
NcNoteCard,
},
mixins: [AuthMixin],
props: {
Expand Down Expand Up @@ -170,18 +175,43 @@ export default {
},
},
data() {
setup() {
// non reactive props
return {
loading: false,
t,
// Disable escape and sanitize to prevent special characters to be html escaped
// For example "J's cloud" would be escaped to "J&#39; cloud". But we do not need escaping as Vue does this in `v-text` automatically
headlineText: t('core', 'Log in to {productName}', { productName: OC.theme.name }, undefined, { sanitize: false, escape: false }),
loginTimeout: loadState('core', 'loginTimeout', 300),
requestToken: window.OC.requestToken,
timezone: (new Intl.DateTimeFormat())?.resolvedOptions()?.timeZone,
timezoneOffset: (-new Date().getTimezoneOffset() / 60),
headline: t('core', 'Log in to {productName}', { productName: OC.theme.name }),
}
},
data() {
return {
loading: false,
user: '',
password: '',
}
},
computed: {
/**
* Reset the login form after a long idle time (debounced)
*/
resetFormTimeout() {
// Infinite timeout, do nothing
if (this.loginTimeout <= 0) {
return () => {}
}
// Debounce for given timeout (in seconds so convert to milli seconds)
return debounce(this.handleResetForm, this.loginTimeout * 1000)
},
isError() {
return this.invalidPassword || this.userDisabled
|| this.throttleDelay > 5000
Expand Down Expand Up @@ -230,6 +260,15 @@ export default {
},
},
watch: {
/**
* Reset form reset after the password was changed
*/
password() {
this.resetFormTimeout()
},
},
mounted() {
if (this.username === '') {
this.$refs.user.$refs.inputField.$refs.input.focus()
Expand All @@ -240,6 +279,14 @@ export default {
},
methods: {
/**
* Handle reset of the login form after a long IDLE time
* This is recommended security behavior to prevent password leak on public devices
*/
handleResetForm() {
this.password = ''
},
updateUsername() {
this.$emit('update:username', this.user)
},
Expand Down
2 changes: 1 addition & 1 deletion cypress/support/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Cypress.Commands.add('mount', (component, optionsOrProps) => {
// eslint-disable-next-line
instance = this
if (oldMounted) {
oldMounted()
oldMounted.call(instance)
}
}

Expand Down
4 changes: 2 additions & 2 deletions dist/core-login.js

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions dist/core-login.js.LICENSE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,28 @@
*
*/

/**
* @copyright Copyright (c) 2024 Fon E. Noel NFEBE <opensource@nfebe.com>
*
* @author Fon E. Noel NFEBE <opensource@nfebe.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

/**
* Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com>
* Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
Expand Down
2 changes: 1 addition & 1 deletion dist/core-login.js.map

Large diffs are not rendered by default.

Loading

0 comments on commit 7d51b6f

Please sign in to comment.