Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ module.exports = {
jsdoc: {
mode: 'typescript',
},
'import/resolver': {
typescript: {}, // this loads <rootdir>/tsconfig.json to eslint
},
},
overrides: [
// Allow any in tests
Expand All @@ -43,6 +46,6 @@ module.exports = {
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
},
}
},
],
}
3 changes: 1 addition & 2 deletions apps/settings/lib/Settings/Personal/Security/WebAuthn.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ public function getForm() {
$this->mapper->findAllForUid($this->userId)
);

return new TemplateResponse('settings', 'settings/personal/security/webauthn', [
]);
return new TemplateResponse('settings', 'settings/personal/security/webauthn');
}

public function getSection(): ?string {
Expand Down
4 changes: 1 addition & 3 deletions apps/settings/lib/SetupChecks/PhpModules.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ class PhpModules implements ISetupCheck {
'zlib',
];
protected const RECOMMENDED_MODULES = [
'bcmath',
'exif',
'gmp',
'intl',
Expand All @@ -58,8 +57,7 @@ protected function getRecommendedModuleDescription(string $module): string {
return match($module) {
'intl' => $this->l10n->t('increases language translation performance and fixes sorting of non-ASCII characters'),
'sodium' => $this->l10n->t('for Argon2 for password hashing'),
'bcmath' => $this->l10n->t('for WebAuthn passwordless login'),
'gmp' => $this->l10n->t('for WebAuthn passwordless login, and SFTP storage'),
'gmp' => $this->l10n->t('required for SFTP storage and recommended for WebAuthn performance'),
'exif' => $this->l10n->t('for picture rotation in server and metadata extraction in the Photos app'),
default => '',
};
Expand Down
6 changes: 3 additions & 3 deletions apps/settings/src/service/WebAuthnRegistrationSerice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { RegistrationResponseJSON } from '@simplewebauthn/types'
import type { PublicKeyCredentialCreationOptionsJSON, RegistrationResponseJSON } from '@simplewebauthn/types'

import { translate as t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
Expand All @@ -21,9 +21,9 @@ export async function startRegistration() {

try {
logger.debug('Fetching webauthn registration data')
const { data } = await axios.get(url)
const { data } = await axios.get<PublicKeyCredentialCreationOptionsJSON>(url)
logger.debug('Start webauthn registration')
const attrs = await registerWebAuthn(data)
const attrs = await registerWebAuthn({ optionsJSON: data })
return attrs
} catch (e) {
logger.error(e as Error)
Expand Down
59 changes: 32 additions & 27 deletions core/src/components/login/PasswordLessLoginForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,27 @@
<template>
<form v-if="(isHttps || isLocalhost) && supportsWebauthn"
ref="loginForm"
aria-labelledby="password-less-login-form-title"
class="password-less-login-form"
method="post"
name="login"
@submit.prevent="submit">
<h2>{{ t('core', 'Log in with a device') }}</h2>
<fieldset>
<NcTextField required
:value="user"
:autocomplete="autoCompleteAllowed ? 'on' : 'off'"
:error="!validCredentials"
:label="t('core', 'Login or email')"
:placeholder="t('core', 'Login or email')"
:helper-text="!validCredentials ? t('core', 'Your account is not setup for passwordless login.') : ''"
@update:value="changeUsername" />
<h2 id="password-less-login-form-title">
{{ t('core', 'Log in with a device') }}
</h2>

<LoginButton v-if="validCredentials"
:loading="loading"
@click="authenticate" />
</fieldset>
<NcTextField required
:value="user"
:autocomplete="autoCompleteAllowed ? 'on' : 'off'"
:error="!validCredentials"
:label="t('core', 'Login or email')"
:placeholder="t('core', 'Login or email')"
:helper-text="!validCredentials ? t('core', 'Your account is not setup for passwordless login.') : ''"
@update:value="changeUsername" />

<LoginButton v-if="validCredentials"
:loading="loading"
@click="authenticate" />
</form>
<div v-else-if="!supportsWebauthn" class="update">
<InformationIcon size="70" />
Expand All @@ -40,9 +43,11 @@
</div>
</template>

<script>
<script type="ts">
import { browserSupportsWebAuthn } from '@simplewebauthn/browser'
import { defineComponent } from 'vue'
import {
NoValidCredentials,
startAuthentication,
finishAuthentication,
} from '../../services/WebAuthnAuthenticationService.ts'
Expand All @@ -52,7 +57,7 @@ import LockOpenIcon from 'vue-material-design-icons/LockOpen.vue'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import logger from '../../logger'

export default {
export default defineComponent({
name: 'PasswordLessLoginForm',
components: {
LoginButton,
Expand Down Expand Up @@ -138,21 +143,21 @@ export default {
// noop
},
},
}
})
</script>

<style lang="scss" scoped>
fieldset {
display: flex;
flex-direction: column;
gap: 0.5rem;
.password-less-login-form {
display: flex;
flex-direction: column;
gap: 0.5rem;

:deep(label) {
text-align: initial;
}
:deep(label) {
text-align: initial;
}
}

.update {
margin: 0 auto;
}
.update {
margin: 0 auto;
}
</style>
2 changes: 1 addition & 1 deletion core/src/services/WebAuthnAuthenticationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export async function startAuthentication(loginName: string) {
logger.error('No valid credentials returned for webauthn')
throw new NoValidCredentials()
}
return await startWebauthnAuthentication(data)
return await startWebauthnAuthentication({ optionsJSON: data })
}

/**
Expand Down
14 changes: 12 additions & 2 deletions cypress/dockerNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ export const startNextcloud = async function(branch: string = getCurrentGitBranc
Type: 'tmpfs',
ReadOnly: false,
}],
PortBindings: {
'80/tcp': [{
HostIP: '0.0.0.0',
HostPort: '8083',
}],
},
},
Env: [
`BRANCH=${branch}`,
Expand Down Expand Up @@ -242,11 +248,15 @@ export const getContainerIP = async function(
while (ip === '' && tries < 10) {
tries++

await container.inspect(function(err, data) {
container.inspect(function(err, data) {
if (err) {
throw err
}
ip = data?.NetworkSettings?.IPAddress || ''
if (data?.HostConfig.PortBindings?.['80/tcp']?.[0]?.HostPort) {
ip = `localhost:${data.HostConfig.PortBindings['80/tcp'][0].HostPort}`
} else {
ip = data?.NetworkSettings?.IPAddress || ''
}
})

if (ip !== '') {
Expand Down
41 changes: 16 additions & 25 deletions cypress/e2e/files/LivePhotosUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,34 +14,25 @@ type SetupInfo = {
}

/**
*
* @param user
* @param fileName
* @param domain
* @param requesttoken
* @param metadata
*/
function setMetadata(user: User, fileName: string, requesttoken: string, metadata: object) {
cy.url().then(url => {
const hostname = new URL(url).hostname
cy.request({
method: 'PROPPATCH',
url: `http://${hostname}/remote.php/dav/files/${user.userId}/${fileName}`,
auth: { user: user.userId, pass: user.password },
headers: {
requesttoken,
},
body: `<?xml version="1.0"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
<d:set>
<d:prop>
${Object.entries(metadata).map(([key, value]) => `<${key}>${value}</${key}>`).join('\n')}
</d:prop>
</d:set>
</d:propertyupdate>`,
})
const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?/, '')
cy.request({
method: 'PROPPATCH',
url: `${base}/remote.php/dav/files/${user.userId}/${fileName}`,
auth: { user: user.userId, pass: user.password },
headers: {
requesttoken,
},
body: `<?xml version="1.0"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
<d:set>
<d:prop>
${Object.entries(metadata).map(([key, value]) => `<${key}>${value}</${key}>`).join('\n')}
</d:prop>
</d:set>
</d:propertyupdate>`,
})

}

/**
Expand Down
59 changes: 38 additions & 21 deletions cypress/e2e/files_external/files-user-credentials.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@ describe('Files user credentials', { testIsolation: true }, () => {
let user2: User
let storageUser: User

beforeEach(() => {
})

before(() => {
cy.runOccCommand('app:enable files_external')

// Create some users
cy.createRandomUser().then((user) => user1 = user)
cy.createRandomUser().then((user) => user2 = user)
cy.createRandomUser().then((user) => {
user1 = user
})
cy.createRandomUser().then((user) => {
user2 = user
})

// This user will hold the webdav storage
cy.createRandomUser().then((user) => {
Expand All @@ -34,7 +35,7 @@ describe('Files user credentials', { testIsolation: true }, () => {

after(() => {
// Cleanup global storages
cy.runOccCommand(`files_external:list --output=json`).then(({stdout}) => {
cy.runOccCommand('files_external:list --output=json').then(({ stdout }) => {
const list = JSON.parse(stdout)
list.forEach((storage) => cy.runOccCommand(`files_external:delete --yes ${storage.mount_id}`), { failOnNonZeroExit: false })
})
Expand All @@ -43,8 +44,10 @@ describe('Files user credentials', { testIsolation: true }, () => {
})

it('Create a user storage with user credentials', () => {
const url = Cypress.config('baseUrl') + '/remote.php/dav/files/' + storageUser.userId
createStorageWithConfig(storageUser.userId, StorageBackend.DAV, AuthBackend.UserProvided, { host: url.replace('index.php/', ''), 'secure': 'false' })
// Its not the public server address but the address so the server itself can connect to it
const base = 'http://localhost'
const host = `${base}/remote.php/dav/files/${storageUser.userId}`
createStorageWithConfig(storageUser.userId, StorageBackend.DAV, AuthBackend.UserProvided, { host, secure: 'false' })

cy.login(user1)
cy.visit('/apps/files/extstoragemounts')
Expand Down Expand Up @@ -72,6 +75,7 @@ describe('Files user credentials', { testIsolation: true }, () => {

// Auth dialog should be closed and the set credentials button should be gone
authDialog.should('not.exist', { timeout: 2000 })

getActionEntryForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist')

// Finally, the storage should be accessible
Expand All @@ -81,8 +85,10 @@ describe('Files user credentials', { testIsolation: true }, () => {
})

it('Create a user storage with GLOBAL user credentials', () => {
const url = Cypress.config('baseUrl') + '/remote.php/dav/files/' + storageUser.userId
createStorageWithConfig('storage1', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host: url.replace('index.php/', ''), 'secure': 'false' })
// Its not the public server address but the address so the server itself can connect to it
const base = 'http://localhost'
const host = `${base}/remote.php/dav/files/${storageUser.userId}`
createStorageWithConfig('storage1', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host, secure: 'false' })

cy.login(user2)
cy.visit('/apps/files/extstoragemounts')
Expand All @@ -93,23 +99,32 @@ describe('Files user credentials', { testIsolation: true }, () => {
triggerInlineActionForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE)

// See credentials dialog
const storageDialog = cy.findByRole('dialog', { name: 'Storage credentials' })
storageDialog.should('be.visible')
storageDialog.findByRole('textbox', { name: 'Login' }).type(storageUser.userId)
storageDialog.get('input[type="password"]').type(storageUser.password)
storageDialog.get('button').contains('Confirm').click()
storageDialog.should('not.exist')
cy.findByRole('dialog', { name: 'Storage credentials' })
.as('storageDialog')
cy.get('@storageDialog')
.should('be.visible')
.findByRole('textbox', { name: 'Login' })
.type(storageUser.userId)
cy.get('@storageDialog')
.find('input[type="password"]')
.type(storageUser.password)
cy.get('@storageDialog')
.contains('button', 'Confirm')
.click()
cy.get('@storageDialog')
.should('not.exist')

// Storage dialog now closed, the user auth dialog should be visible
const authDialog = cy.findByRole('dialog', { name: 'Confirm your password' })
authDialog.should('be.visible')
cy.findByRole('dialog', { name: 'Confirm your password' })
.as('authDialog')
.should('be.visible')
handlePasswordConfirmation(user2.password)

// Wait for the credentials to be set
cy.wait('@setCredentials')

// Auth dialog should be closed and the set credentials button should be gone
authDialog.should('not.exist', { timeout: 2000 })
cy.get('@authDialog').should('not.exist', { timeout: 2000 })
getActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist')

// Finally, the storage should be accessible
Expand All @@ -119,8 +134,10 @@ describe('Files user credentials', { testIsolation: true }, () => {
})

it('Create another user storage while reusing GLOBAL user credentials', () => {
const url = Cypress.config('baseUrl') + '/remote.php/dav/files/' + storageUser.userId
createStorageWithConfig('storage2', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host: url.replace('index.php/', ''), 'secure': 'false' })
// Its not the public server address but the address so the server itself can connect to it
const base = 'http://localhost'
const host = `${base}/remote.php/dav/files/${storageUser.userId}`
createStorageWithConfig('storage2', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host, secure: 'false' })

cy.login(user2)
cy.visit('/apps/files/extstoragemounts')
Expand Down
Loading
Loading