Skip to content
Open
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
13 changes: 12 additions & 1 deletion apps/files_sharing/src/services/ConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,25 @@
import { getCapabilities } from '@nextcloud/capabilities'
import { loadState } from '@nextcloud/initial-state'

type PasswordPolicyCapabilities = {
type PasswordPolicySettings = {
enforceNonCommonPassword: boolean
enforceNumericCharacters: boolean
enforceSpecialCharacters: boolean
enforceUpperLowerCase: boolean
minLength: number
}

type PasswordPolicyCapabilities = PasswordPolicySettings & {
api?: {
generate: string
validate: string
}
policies?: {
account?: PasswordPolicySettings
sharing?: PasswordPolicySettings
}
}

type FileSharingCapabilities = {
api_enabled: boolean
public: {
Expand Down
83 changes: 83 additions & 0 deletions apps/files_sharing/src/utils/GeneratePassword.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { beforeEach, describe, expect, it, vi } from 'vitest'

const axiosGet = vi.hoisted(() => vi.fn())
vi.mock('@nextcloud/axios', () => ({ default: { get: axiosGet } }))

const getCapabilities = vi.hoisted(() => vi.fn())
vi.mock('@nextcloud/capabilities', () => ({ getCapabilities }))

vi.mock('@nextcloud/dialogs', () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
}))

describe('GeneratePassword', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.resetModules()
})

it('should pass context=sharing to the API', async () => {
getCapabilities.mockReturnValue({
password_policy: {
api: { generate: 'https://example.com/api/generate' },
},
})
axiosGet.mockResolvedValue({
data: { ocs: { data: { password: 'generated-password' } } },
})

const { default: generatePassword } = await import('./GeneratePassword.ts')
const password = await generatePassword()

expect(axiosGet).toHaveBeenCalledWith(
'https://example.com/api/generate',
{ params: { context: 'sharing' } },
)
expect(password).toBe('generated-password')
})

it('should use sharing policy minLength in fallback', async () => {
getCapabilities.mockReturnValue({
password_policy: {
policies: {
sharing: { minLength: 15, enforceSpecialCharacters: false },
},
},
})

const { default: generatePassword } = await import('./GeneratePassword.ts')
const password = await generatePassword()

expect(password.length).toBeGreaterThanOrEqual(15)
})

it('should include special characters when policy requires it', async () => {
getCapabilities.mockReturnValue({
password_policy: {
policies: {
sharing: { minLength: 10, enforceSpecialCharacters: true },
},
},
})

const { default: generatePassword } = await import('./GeneratePassword.ts')
const password = await generatePassword()

expect(password).toMatch(/[!@#$%^&*]/)
})

it('should fallback to default 10 chars when no policy', async () => {
getCapabilities.mockReturnValue({})

const { default: generatePassword } = await import('./GeneratePassword.ts')
const password = await generatePassword()

expect(password.length).toBeGreaterThanOrEqual(10)
})
})
76 changes: 68 additions & 8 deletions apps/files_sharing/src/utils/GeneratePassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ import Config from '../services/ConfigService.ts'
import logger from '../services/logger.ts'

const config = new Config()
// note: some chars removed on purpose to make them human friendly when read out
const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789'
// Character sets for password generation
const CHARS_LOWER = 'abcdefgijkmnopqrstwxyz'
const CHARS_UPPER = 'ABCDEFGHJKLMNPQRSTWXYZ'
const CHARS_DIGITS = '23456789'
const CHARS_SPECIAL = '!@#$%^&*'
const CHARS_HUMAN_READABLE = CHARS_LOWER + CHARS_UPPER + CHARS_DIGITS

/**
* Generate a valid policy password or request a valid password if password_policy is enabled
Expand All @@ -22,7 +26,9 @@ export default async function(verbose = false): Promise<string> {
// password policy is enabled, let's request a pass
if (config.passwordPolicy.api && config.passwordPolicy.api.generate) {
try {
const request = await axios.get(config.passwordPolicy.api.generate)
const request = await axios.get(config.passwordPolicy.api.generate, {
params: { context: 'sharing' },
})
if (request.data.ocs.data.password) {
if (verbose) {
showSuccess(t('files_sharing', 'Password created successfully'))
Expand All @@ -37,14 +43,39 @@ export default async function(verbose = false): Promise<string> {
}
}

const array = new Uint8Array(10)
const ratio = passwordSet.length / 255
getRandomValues(array)
// Fallback: generate password based on sharing policy from capabilities
const sharingPolicy = config.passwordPolicy?.policies?.sharing
const minLength = Math.max(sharingPolicy?.minLength ?? config.passwordPolicy?.minLength ?? 10, 8)
const needsSpecialChars = sharingPolicy?.enforceSpecialCharacters ?? config.passwordPolicy?.enforceSpecialCharacters ?? false
const needsUpperLower = sharingPolicy?.enforceUpperLowerCase ?? config.passwordPolicy?.enforceUpperLowerCase ?? false
const needsNumeric = sharingPolicy?.enforceNumericCharacters ?? config.passwordPolicy?.enforceNumericCharacters ?? false

let password = ''
let chars = CHARS_HUMAN_READABLE

// Add required character types
if (needsUpperLower) {
password += getRandomChar(CHARS_UPPER)
password += getRandomChar(CHARS_LOWER)
}
if (needsNumeric) {
password += getRandomChar(CHARS_DIGITS)
}
if (needsSpecialChars) {
password += getRandomChar(CHARS_SPECIAL)
chars += CHARS_SPECIAL
}

// Fill remaining length
const remainingLength = Math.max(minLength - password.length, 0)
const array = new Uint8Array(remainingLength)
getRandomValues(array)
for (let i = 0; i < array.length; i++) {
password += passwordSet.charAt(array[i] * ratio)
password += chars.charAt(Math.floor(array[i] / 256 * chars.length))
}
return password

// Shuffle to randomize character positions
return shuffleString(password)
}

/**
Expand All @@ -65,3 +96,32 @@ function getRandomValues(array: Uint8Array): void {
array[len] = Math.floor(Math.random() * 256)
}
}

/**
* Get a random character from the given character set.
*
* @param chars - The character set to choose from.
*/
function getRandomChar(chars: string): string {
const array = new Uint8Array(1)
getRandomValues(array)
return chars.charAt(Math.floor(array[0] / 256 * chars.length))
}

/**
* Shuffle a string randomly using Fisher-Yates algorithm.
*
* @param str - The string to shuffle.
*/
function shuffleString(str: string): string {
const arr = str.split('')
for (let i = arr.length - 1; i > 0; i--) {
const array = new Uint8Array(1)
getRandomValues(array)
const j = Math.floor(array[0] / 256 * (i + 1))
const temp = arr[i]
arr[i] = arr[j]
arr[j] = temp
}
return arr.join('')
}
Loading