Skip to content

Commit

Permalink
feat: Allow configure different policies per password context in the UI
Browse files Browse the repository at this point in the history
* Add support for multiple password policies (per context)
* Split Vue files into components
* Use Typescript (also for the script part of Vue files)

Signed-off-by: Ferdinand Thiessen <[email protected]>
  • Loading branch information
susnux committed Jan 30, 2025
1 parent 41ea4e8 commit e3b2958
Show file tree
Hide file tree
Showing 15 changed files with 456 additions and 182 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"appName": true
},
"extends": [
"@nextcloud"
"@nextcloud/eslint-config/typescript"
]
}
10 changes: 9 additions & 1 deletion REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@ SPDX-PackageName = "external"
SPDX-PackageSupplier = "Nextcloud <[email protected]>"
SPDX-PackageDownloadLocation = "https://github.com/nextcloud/external"

# Dependency management
[[annotations]]
path = ["package-lock.json", "package.json", ".l10nignore", "composer.json", "composer.lock", "vendor-bin/**/composer.json", "vendor-bin/**/composer.lock", ".tx/config", "js/vendor.LICENSE.txt", ".github/CODEOWNERS", ".eslintrc.json", "tests/psalm-baseline.xml"]
path = ["package-lock.json", "package.json", "composer.json", "composer.lock", "vendor-bin/**/composer.json", "vendor-bin/**/composer.lock", "js/vendor.LICENSE.txt"]
precedence = "aggregate"
SPDX-FileCopyrightText = "none"
SPDX-License-Identifier = "CC0-1.0"

# Build config files
[[annotations]]
path = [".l10nignore", "tsconfig.json", ".tx/config", ".github/CODEOWNERS", ".eslintrc.json", "tests/psalm-baseline.xml"]
precedence = "aggregate"
SPDX-FileCopyrightText = "none"
SPDX-License-Identifier = "CC0-1.0"
Expand Down
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@
</dependencies>

<settings>
<admin>OCA\Password_Policy\Settings</admin>
<admin>OCA\Password_Policy\Settings\Settings</admin>
</settings>
</info>
11 changes: 3 additions & 8 deletions lib/Settings.php → lib/Settings/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Password_Policy;
namespace OCA\Password_Policy\Settings;

use OCA\Password_Policy\PasswordPolicyConfig;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Settings\ISettings;
Expand All @@ -26,13 +27,7 @@ public function getForm(): TemplateResponse {
Util::addStyle($this->appName, 'password_policy-settings');
Util::addScript($this->appName, 'password_policy-settings');

$this->initialStateService->provideInitialState('config', [
'minLength' => $this->config->getMinLength(),
'enforceNonCommonPassword' => $this->config->getEnforceNonCommonPassword(),
'enforceUpperLowerCase' => $this->config->getEnforceUpperLowerCase(),
'enforceNumericCharacters' => $this->config->getEnforceNumericCharacters(),
'enforceSpecialCharacters' => $this->config->getEnforceSpecialCharacters(),
'enforceHaveIBeenPwned' => $this->config->getEnforceHaveIBeenPwned(),
$this->initialStateService->provideInitialState('loginConfig', [
'historySize' => $this->config->getHistorySize(),
'expiration' => $this->config->getExpiryInDays(),
'maximumLoginAttempts' => $this->config->getMaximumLoginAttempts(),
Expand Down
14 changes: 9 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@
"stylelint:fix": "stylelint src/**/*.scss src/**/*.vue --fix"
},
"dependencies": {
"@nextcloud/dialogs": "^6.0.0",
"@nextcloud/initial-state": "^2.1.0",
"@nextcloud/capabilities": "^1.2.0",
"@nextcloud/dialogs": "^6.1.1",
"@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/vue": "^8.17.1",
"vue": "^2.7.16"
"@nextcloud/vue": "^8.22.0",
"vue": "^2.7.16",
"vue-material-design-icons": "^5.3.1"
},
"engines": {
"node": "^20.0.0",
Expand All @@ -38,8 +40,10 @@
"@nextcloud/browserslist-config": "^3.0.1",
"@nextcloud/eslint-config": "^8.4.1",
"@nextcloud/stylelint-config": "^3.0.1",
"@nextcloud/vite-config": "^1.5.0",
"@nextcloud/vite-config": "^1.5.1",
"@vue/tsconfig": "^0.5.1",
"sass": "^1.83.4",
"typescript": "^5.7.3",
"vite": "^5.4.9"
}
}
240 changes: 79 additions & 161 deletions src/AdminSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,180 +3,98 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<NcSettingsSection :name="t('password_policy', 'Password policy')">
<ul class="password-policy__settings-list">
<li>
<input id="password-policy__settings__min-length"
v-model="config.minLength"
min="0"
type="number"
@change="updateNumberSetting('minLength')">
<label for="password-policy__settings__min-length">
{{ t('password_policy', 'Minimum password length') }}
</label>
</li>
<li>
<input id="password-policy-history-size"
v-model="config.historySize"
min="0"
type="number"
@change="updateNumberSetting('historySize')">
<label for="password-policy-history-size">
{{ t('password_policy', 'User password history') }}
</label>
</li>
<li>
<input id="password-policy_failed-login"
v-model="config.maximumLoginAttempts"
min="0"
type="number"
@change="updateNumberSetting('maximumLoginAttempts')">
<label for="password-policy_failed-login">
{{ t('password_policy', 'Number of login attempts before the user account will be disabled until manual action is taken. (0 for no limit)') }}
</label>
<p class="havibeenpwned-hint">
{{ t('password_policy', 'Please note, this option is meant to protect attacked accounts. Disabled accounts have to be re-enabled manually by administration. Attackers that try to guess passwords of accounts will have their IP address blocked by the bruteforce protection independent from this setting.') }}
</p>
</li>
<li>
<input id="password-policy-expiration"
v-model="config.expiration"
min="0"
type="number"
@change="updateNumberSetting('expiration')">
<label for="password-policy-expiration">
{{ t('password_policy', 'Number of days until user password expires') }}
</label>
<p class="havibeenpwned-hint">
{{ t('password_policy', 'Warning: enabling password expiration is nowadays considered a security risk by several security agencies.') }}
</p>
</li>
</ul>

<ul class="password-policy__settings-list">
<li>
<NcCheckboxRadioSwitch :checked.sync="config.enforceNonCommonPassword"
type="switch"
@update:checked="updateBoolSetting('enforceNonCommonPassword')">
{{ t('password_policy', 'Forbid common passwords') }}
</NcCheckboxRadioSwitch>
</li>
<li>
<NcCheckboxRadioSwitch :checked.sync="config.enforceUpperLowerCase"
type="switch"
@update:checked="updateBoolSetting('enforceUpperLowerCase')">
{{ t('password_policy', 'Enforce upper and lower case characters') }}
</NcCheckboxRadioSwitch>
</li>
<li>
<NcCheckboxRadioSwitch :checked.sync="config.enforceNumericCharacters"
type="switch"
@update:checked="updateBoolSetting('enforceNumericCharacters')">
{{ t('password_policy', 'Enforce numeric characters') }}
</NcCheckboxRadioSwitch>
</li>
<li>
<NcCheckboxRadioSwitch :checked.sync="config.enforceSpecialCharacters"
type="switch"
@update:checked="updateBoolSetting('enforceSpecialCharacters')">
{{ t('password_policy', 'Enforce special characters') }}
</NcCheckboxRadioSwitch>
</li>
<li>
<NcCheckboxRadioSwitch :checked.sync="config.enforceHaveIBeenPwned"
type="switch"
@update:checked="updateBoolSetting('enforceHaveIBeenPwned')">
{{ t('password_policy', 'Check password against the list of breached passwords from haveibeenpwned.com') }}
</NcCheckboxRadioSwitch>
<p class="havibeenpwned-hint">
{{ t('password_policy', 'This check creates a hash of the password and sends the first 5 characters of this hash to the haveibeenpwned.com API to retrieve a list of all hashes that start with those. Then it checks on the Nextcloud instance if the password hash is in the result set.') }}
</p>
</li>
</ul>
</NcSettingsSection>
</template>
<script setup lang="ts">
import { getCapabilities } from '@nextcloud/capabilities'
import { t } from '@nextcloud/l10n'
import Vue, { computed, ref } from 'vue'
import { DefaultPolicyValues, PolicyHeadings } from './constants'

<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'

import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
import PasswordPolicy from './components/PasswordPolicy.vue'
import ComplianceConfig from './components/ComplianceConfig.vue'
import AddPolicyButton from './components/AddPolicyButton.vue'
import type { IPasswordPolicy } from './types'

const policies = ref(getCapabilities().password_policy.policies)
const configuredPolicies = computed(() => Object.keys(policies.value))

export default {
name: 'AdminSettings',
components: {
NcCheckboxRadioSwitch,
NcSettingsSection,
},
/**
* Update a password policy
* @param context The password context the policy is for
* @param policy The updated policy
*/
function onUpdatePolicy(context: string, policy: IPasswordPolicy): void {
console.debug(`Update password policy ${context}`, policy)

data() {
return {
config: loadState('password_policy', 'config'),
for (const [key, value] of Object.entries(policy)) {
if (value !== policies.value[context]?.[key]) {
const update = typeof value === 'boolean' ? (value ? '1' : '0') : String(value)
window.OCP.AppConfig.setValue('password_policy', context === 'account' ? key : `${key}_${context}`, update)
}
},
}

Vue.set(policies.value, context, policy)
}

methods: {
async updateBoolSetting(setting) {
await this.setValue(setting, this.config[setting] ? '1' : '0')
},
async updateNumberSetting(setting) {
// If value not only (positive) numbers
if (!/^\d+$/.test(this.config[setting])) {
let message = t('password_policy', 'Unknown error')
switch (setting) {
case 'minLength':
message = t('password_policy', 'Minimal length has to be a non negative number')
break
case 'historySize':
message = t('password_policy', 'History size has to be a non negative number')
break
case 'expiration':
message = t('password_policy', 'Expiration days have to be a non negative number')
break
case 'maximumLoginAttempts':
message = t('password_policy', 'Maximum login attempts have to be a non negative number')
break
}
showError(message)
return
}
/**
* Create a new policy for specified password context
* @param context The password context
*/
function onAddPolicy(context: string): void {
if (context in policies.value) {
console.warn(`Password context "${context}" already registered`)
return
}

// Otherwise store Value
await this.setValue(setting, this.config[setting])
},
const passwordContexts = [...Object.keys(policies.value), context]
window.OCP.AppConfig.setValue('password_policy', 'passwordContexts', JSON.stringify(passwordContexts))
Vue.set(policies.value, context, { ...DefaultPolicyValues })
}

/**
* Save the provided setting and value
*
* @param {string} setting the app config key
* @param {string} value the app config value
*/
async setValue(setting, value) {
OCP.AppConfig.setValue('password_policy', setting, value, {
success: () => showSuccess(t('password_policy', 'Settings saved')),
error: () => showError(t('password_policy', 'Error while saving settings')),
})
},
},
/**
* Remove a policy configuration
* @param context The password context to remove the policy for
*/
function onRemovePolicy(context: string): void {
console.debug(`Remove password policy ${context}`)
const passwordContexts = Object.keys(policies.value).filter((key) => key !== context)
window.OCP.AppConfig.setValue('password_policy', 'passwordContexts', JSON.stringify(passwordContexts))
Vue.delete(policies.value, context)
}
</script>

<style lang="scss" scoped>
.password-policy {
&__settings-list li input[type='number'] {
width: 75px;
}
<template>
<NcSettingsSection :name="t('password_policy', 'Password policy')">
<ComplianceConfig />

// Little spacing between two lists (used between number/checkbox inputs)
&__settings-list + &__settings-list {
margin-top: 8px;
}
<div :class="$style.policyWrapper">
<PasswordPolicy v-for="policyName in configuredPolicies"
:key="policyName"
:can-remove="policyName !== 'account'"
:heading="configuredPolicies.length === 1 ? t('password_policy', 'General password policies') : PolicyHeadings[policyName]"
:model-value="policies[policyName]"
@update:modelValue="onUpdatePolicy(policyName, $event)"
@remove="onRemovePolicy(policyName)" />

<AddPolicyButton v-if="configuredPolicies.length < Object.keys(PolicyHeadings).length"
:policies="policies"
@add-policy="onAddPolicy" />
</div>
</NcSettingsSection>
</template>

<style module>
.policyWrapper {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

.havibeenpwned-hint {
opacity: 0.7;
padding-left: 28px;
.policyWrapper > * {
min-width: 446px;
max-width: 446px;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-container);
}
</style>
16 changes: 16 additions & 0 deletions src/capabilities.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IPasswordPolicies } from './types.d.ts'

interface ICapabilities {
password_policy: {
policies: IPasswordPolicies
}
}

declare module '@nextcloud/capabilities' {
function getCapabilities(): ICapabilities;
}
Loading

0 comments on commit e3b2958

Please sign in to comment.