Skip to content

Commit 6ceecfa

Browse files
committed
feat(login): Clear login form (password) after IDLE timeout
For security reasons it is recommended to stop the login process at a defined time, this could prevent password leaks by e.g. user forgetting that they entered their password on public devices. Enforced e.g. by the BSI ORP.4.A13 rule. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 28de774 commit 6ceecfa

7 files changed

Lines changed: 167 additions & 53 deletions

File tree

config/config.sample.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2307,6 +2307,14 @@
23072307

23082308
'login_form_autocomplete' => true,
23092309

2310+
/**
2311+
* Timeout for the login form, after this time the login form is reset.
2312+
* This prevents password leaks on public devices if the user forgots to clear the form.
2313+
*
2314+
* Default is 5 minutes (300 seconds), a value of 0 means no timeout.
2315+
*/
2316+
'login_form_timeout' => 300,
2317+
23102318
/**
23112319
* If your user is using an outdated or unsupported browser, a warning will be shown
23122320
* to offer some guidance to upgrade or switch and ensure a proper Nextcloud experience.

core/Controller/LoginController.php

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@
5353
use OCP\AppFramework\Http\DataResponse;
5454
use OCP\AppFramework\Http\RedirectResponse;
5555
use OCP\AppFramework\Http\TemplateResponse;
56+
use OCP\AppFramework\Services\IInitialState;
5657
use OCP\Defaults;
5758
use OCP\IConfig;
58-
use OCP\IInitialStateService;
5959
use OCP\IL10N;
6060
use OCP\IRequest;
6161
use OCP\ISession;
@@ -81,7 +81,7 @@ public function __construct(
8181
private IURLGenerator $urlGenerator,
8282
private Defaults $defaults,
8383
private IThrottler $throttler,
84-
private IInitialStateService $initialStateService,
84+
private IInitialState $initialState,
8585
private WebAuthnManager $webAuthnManager,
8686
private IManager $manager,
8787
private IL10N $l10n,
@@ -148,32 +148,30 @@ public function showLoginForm(string $user = null, string $redirect_url = null):
148148
}
149149
if (is_array($loginMessages)) {
150150
[$errors, $messages] = $loginMessages;
151-
$this->initialStateService->provideInitialState('core', 'loginMessages', $messages);
152-
$this->initialStateService->provideInitialState('core', 'loginErrors', $errors);
151+
$this->initialState->provideInitialState('loginMessages', $messages);
152+
$this->initialState->provideInitialState('loginErrors', $errors);
153153
}
154154
$this->session->remove('loginMessages');
155155

156156
if ($user !== null && $user !== '') {
157-
$this->initialStateService->provideInitialState('core', 'loginUsername', $user);
157+
$this->initialState->provideInitialState('loginUsername', $user);
158158
} else {
159-
$this->initialStateService->provideInitialState('core', 'loginUsername', '');
159+
$this->initialState->provideInitialState('loginUsername', '');
160160
}
161161

162-
$this->initialStateService->provideInitialState(
163-
'core',
162+
$this->initialState->provideInitialState(
164163
'loginAutocomplete',
165164
$this->config->getSystemValue('login_form_autocomplete', true) === true
166165
);
167166

168167
if (!empty($redirect_url)) {
169168
[$url, ] = explode('?', $redirect_url);
170169
if ($url !== $this->urlGenerator->linkToRoute('core.login.logout')) {
171-
$this->initialStateService->provideInitialState('core', 'loginRedirectUrl', $redirect_url);
170+
$this->initialState->provideInitialState('loginRedirectUrl', $redirect_url);
172171
}
173172
}
174173

175-
$this->initialStateService->provideInitialState(
176-
'core',
174+
$this->initialState->provideInitialState(
177175
'loginThrottleDelay',
178176
$this->throttler->getDelay($this->request->getRemoteAddress())
179177
);
@@ -182,9 +180,9 @@ public function showLoginForm(string $user = null, string $redirect_url = null):
182180

183181
$this->setEmailStates();
184182

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

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

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

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

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

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

227-
$this->initialStateService->provideInitialState(
228-
'core',
226+
$this->initialState->provideInitialState(
229227
'loginResetPasswordLink',
230228
$passwordLink
231229
);
232230

233-
$this->initialStateService->provideInitialState(
234-
'core',
231+
$this->initialState->provideInitialState(
235232
'loginCanResetPassword',
236233
$this->canResetPassword($passwordLink, $user)
237234
);
@@ -255,11 +252,7 @@ private function setEmailStates(): void {
255252
array_push($emailStates, $emailConfig->__get('ldapLoginFilterEmail'));
256253
}
257254
}
258-
$this->initialStateService->
259-
provideInitialState(
260-
'core',
261-
'emailStates',
262-
$emailStates);
255+
$this->initialState->provideInitialState('emailStates', $emailStates);
263256
}
264257

265258
/**

core/src/components/login/LoginButton.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
</template>
3434

3535
<script>
36+
import { translate as t } from '@nextcloud/l10n'
37+
3638
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
3739
import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
3840
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import LoginForm from './LoginForm.vue'
2+
3+
describe('core: LoginForm', { testIsolation: true }, () => {
4+
beforeEach(() => {
5+
// Mock the required global state
6+
cy.window().then(($window) => {
7+
$window.OC = {
8+
theme: {
9+
name: 'J\'s cloud',
10+
},
11+
requestToken: 'request-token',
12+
}
13+
})
14+
})
15+
16+
/**
17+
* Ensure that characters like ' are not double HTML escaped.
18+
* This was a bug in https://github.com/nextcloud/server/issues/34990
19+
*/
20+
it('does not double escape special characters in product name', () => {
21+
cy.mount(LoginForm, {
22+
propsData: {
23+
username: 'test-user',
24+
},
25+
})
26+
27+
cy.get('h2').contains('J\'s cloud')
28+
})
29+
30+
it('fills username from props into form', () => {
31+
cy.mount(LoginForm, {
32+
propsData: {
33+
username: 'test-user',
34+
},
35+
})
36+
37+
cy.get('input[name="user"]')
38+
.should('exist')
39+
.and('have.attr', 'id', 'user')
40+
41+
cy.get('input[name="user"]')
42+
.should('have.value', 'test-user')
43+
})
44+
45+
it('clears password after timeout', () => {
46+
// mock timeout of 5 seconds
47+
cy.window().then(($window) => {
48+
const state = $window.document.createElement('input')
49+
state.type = 'hidden'
50+
state.id = 'initial-state-core-loginTimeout'
51+
state.value = btoa(JSON.stringify(5))
52+
$window.document.body.appendChild(state)
53+
$window.console.warn('MOUNTED')
54+
})
55+
56+
// mount forms
57+
cy.mount(LoginForm)
58+
59+
cy.get('input[name="password"]')
60+
.should('exist')
61+
.type('MyPassword')
62+
63+
cy.get('input[name="password"]')
64+
.should('have.value', 'MyPassword')
65+
66+
// Wait for timeout
67+
// eslint-disable-next-line cypress/no-unnecessary-waiting
68+
cy.wait(5100)
69+
70+
cy.get('input[name="password"]')
71+
.should('have.value', '')
72+
})
73+
})

core/src/components/login/LoginForm.vue

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@
5757
<!-- the following div ensures that the spinner is always inside the #message div -->
5858
<div style="clear: both;" />
5959
</div>
60-
<h2 class="login-form__headline" data-login-form-headline v-html="headline" />
60+
<h2 class="login-form__headline" data-login-form-headline>
61+
{{ headlineText }}
62+
</h2>
6163
<NcTextField id="user"
6264
ref="user"
6365
:label="loginText"
@@ -102,7 +104,7 @@
102104
:value="timezoneOffset">
103105
<input type="hidden"
104106
name="requesttoken"
105-
:value="OC.requestToken">
107+
:value="requestToken">
106108
<input v-if="directLogin"
107109
type="hidden"
108110
name="direct"
@@ -112,15 +114,17 @@
112114
</template>
113115

114116
<script>
117+
import { loadState } from '@nextcloud/initial-state'
118+
import { translate as t } from '@nextcloud/l10n'
115119
import { generateUrl, imagePath } from '@nextcloud/router'
120+
import { debounce } from 'debounce'
116121
117122
import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
118123
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
119124
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
120125
121-
import LoginButton from './LoginButton.vue'
122-
123126
import AuthMixin from '../../mixins/auth.js'
127+
import LoginButton from './LoginButton.vue'
124128
125129
export default {
126130
name: 'LoginForm',
@@ -131,6 +135,7 @@ export default {
131135
NcTextField,
132136
NcNoteCard,
133137
},
138+
134139
mixins: [AuthMixin],
135140
136141
props: {
@@ -170,18 +175,44 @@ export default {
170175
},
171176
},
172177
173-
data() {
178+
setup() {
179+
// non reactive props
174180
return {
175-
loading: false,
181+
t,
182+
183+
// Disable escape and sanitize to prevent special characters to be html escaped
184+
// 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
185+
headlineText: t('core', 'Log in to {productName}', { productName: OC.theme.name }, undefined, { sanitize: false, escape: false }),
186+
187+
loginTimeout: loadState('core', 'loginTimeout', 300),
188+
requestToken: window.OC.requestToken,
176189
timezone: (new Intl.DateTimeFormat())?.resolvedOptions()?.timeZone,
177190
timezoneOffset: (-new Date().getTimezoneOffset() / 60),
178-
headline: t('core', 'Log in to {productName}', { productName: OC.theme.name }),
191+
}
192+
},
193+
194+
data() {
195+
return {
196+
loading: false,
179197
user: '',
180198
password: '',
181199
}
182200
},
183201
184202
computed: {
203+
/**
204+
* Reset the login form after a long idle time (debounced)
205+
*/
206+
resetFormTimeout() {
207+
console.warn('GOOT', this.loginTimeout)
208+
// Infinite timeout, do nothing
209+
if (this.loginTimeout <= 0) {
210+
return () => {}
211+
}
212+
// Debounce for given timeout (in seconds so convert to milli seconds)
213+
return debounce(this.handleResetForm, this.loginTimeout * 1000)
214+
},
215+
185216
isError() {
186217
return this.invalidPassword || this.userDisabled
187218
|| this.throttleDelay > 5000
@@ -230,6 +261,15 @@ export default {
230261
},
231262
},
232263
264+
watch: {
265+
/**
266+
* Reset form reset after the password was changed
267+
*/
268+
password() {
269+
this.resetFormTimeout()
270+
},
271+
},
272+
233273
mounted() {
234274
if (this.username === '') {
235275
this.$refs.user.$refs.inputField.$refs.input.focus()
@@ -240,6 +280,14 @@ export default {
240280
},
241281
242282
methods: {
283+
/**
284+
* Handle reset of the login form after a long IDLE time
285+
* This is recommended security behavior to prevent password leak on public devices
286+
*/
287+
handleResetForm() {
288+
this.password = ''
289+
},
290+
243291
updateUsername() {
244292
this.$emit('update:username', this.user)
245293
},

cypress/support/component.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Cypress.Commands.add('mount', (component, optionsOrProps) => {
3636
// eslint-disable-next-line
3737
instance = this
3838
if (oldMounted) {
39-
oldMounted()
39+
oldMounted.call(instance)
4040
}
4141
}
4242

0 commit comments

Comments
 (0)