1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 01:39:37 +02:00

feat: add user password constraints (#6945)

* feat: add user password length constraints

* add password length changes in locale files

* revert maximum password length changes

* add tests

* fix lint

* fix lint and test

* fix tests

* Revert "add password length changes in locale files"

This reverts commit eaaf63ba7c.

* Update PR

---------

Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
Shalabh Agarwal 2025-09-10 20:21:04 +05:30 committed by GitHub
parent eedfb8b0a2
commit efa32646ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 195 additions and 76 deletions

View file

@ -6,12 +6,12 @@ import { AuthService, Notifier, ScreenService, ServerService } from '@app/core'
import {
USER_CHANNEL_NAME_VALIDATOR,
USER_EMAIL_VALIDATOR,
USER_PASSWORD_OPTIONAL_VALIDATOR,
USER_PASSWORD_VALIDATOR,
USER_ROLE_VALIDATOR,
USER_USERNAME_VALIDATOR,
USER_VIDEO_QUOTA_DAILY_VALIDATOR,
USER_VIDEO_QUOTA_VALIDATOR
USER_VIDEO_QUOTA_VALIDATOR,
getUserNewPasswordOptionalValidator,
getUserNewPasswordValidator
} from '@app/shared/form-validators/user-validators'
import { AdminConfigService } from '@app/shared/shared-admin/admin-config.service'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
@ -76,11 +76,17 @@ export class UserCreateComponent extends UserEdit implements OnInit {
videoQuotaDaily: -1
}
const passwordConstraints = this.serverService.getHTMLConfig().fieldsConstraints.users.password
this.buildForm({
username: USER_USERNAME_VALIDATOR,
channelName: USER_CHANNEL_NAME_VALIDATOR,
email: USER_EMAIL_VALIDATOR,
password: this.isPasswordOptional() ? USER_PASSWORD_OPTIONAL_VALIDATOR : USER_PASSWORD_VALIDATOR,
password: this.isPasswordOptional()
? getUserNewPasswordOptionalValidator(passwordConstraints.minLength, passwordConstraints.maxLength)
: getUserNewPasswordValidator(passwordConstraints.minLength, passwordConstraints.maxLength),
role: USER_ROLE_VALIDATOR,
videoQuota: USER_VIDEO_QUOTA_VALIDATOR,
videoQuotaDaily: USER_VIDEO_QUOTA_DAILY_VALIDATOR,

View file

@ -1,12 +1,12 @@
import { NgClass, NgIf } from '@angular/common'
import { Component, OnInit, inject, input } from '@angular/core'
import { Notifier } from '@app/core'
import { USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Notifier, ServerService } from '@app/core'
import { getUserNewPasswordValidator } from '@app/shared/form-validators/user-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { UserUpdate } from '@peertube/peertube-models'
import { NgClass, NgIf } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
import { UserUpdate } from '@peertube/peertube-models'
@Component({
selector: 'my-user-password',
@ -18,6 +18,7 @@ export class UserPasswordComponent extends FormReactive implements OnInit {
protected formReactiveService = inject(FormReactiveService)
private notifier = inject(Notifier)
private userAdminService = inject(UserAdminService)
private serverService = inject(ServerService)
readonly userId = input<number>(undefined)
readonly username = input<string>(undefined)
@ -26,9 +27,9 @@ export class UserPasswordComponent extends FormReactive implements OnInit {
showPassword = false
ngOnInit () {
this.buildForm({
password: USER_PASSWORD_VALIDATOR
})
const { minLength, maxLength } = this.serverService.getHTMLConfig().fieldsConstraints.users.password
this.buildForm({ password: getUserNewPasswordValidator(minLength, maxLength) })
}
formValidated () {

View file

@ -1,11 +1,11 @@
import { NgIf } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { AuthService, Notifier, UserService } from '@app/core'
import { AuthService, Notifier, ServerService, UserService } from '@app/core'
import {
USER_CONFIRM_PASSWORD_VALIDATOR,
USER_EXISTING_PASSWORD_VALIDATOR,
USER_PASSWORD_VALIDATOR
getUserNewPasswordValidator
} from '@app/shared/form-validators/user-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
@ -25,14 +25,17 @@ export class MyAccountChangePasswordComponent extends FormReactive implements On
private notifier = inject(Notifier)
private authService = inject(AuthService)
private userService = inject(UserService)
private serverService = inject(ServerService)
error: string
user: User
ngOnInit () {
const { minLength, maxLength } = this.serverService.getHTMLConfig().fieldsConstraints.users.password
this.buildForm({
'current-password': USER_EXISTING_PASSWORD_VALIDATOR,
'new-password': USER_PASSWORD_VALIDATOR,
'new-password': getUserNewPasswordValidator(minLength, maxLength),
'new-confirmed-password': USER_CONFIRM_PASSWORD_VALIDATOR
})

View file

@ -1,12 +1,12 @@
import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import { Notifier, UserService } from '@app/core'
import { Notifier, ServerService, UserService } from '@app/core'
import { RESET_PASSWORD_CONFIRM_VALIDATOR } from '@app/shared/form-validators/reset-password-validators'
import { USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { getUserNewPasswordValidator } from '@app/shared/form-validators/user-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { InputTextComponent } from '../shared/shared-forms/input-text.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@Component({
templateUrl: './reset-password.component.html',
@ -16,6 +16,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
export class ResetPasswordComponent extends FormReactive implements OnInit {
protected formReactiveService = inject(FormReactiveService)
private userService = inject(UserService)
private serverService = inject(ServerService)
private notifier = inject(Notifier)
private router = inject(Router)
private route = inject(ActivatedRoute)
@ -24,8 +25,10 @@ export class ResetPasswordComponent extends FormReactive implements OnInit {
private verificationString: string
ngOnInit () {
const { minLength, maxLength } = this.serverService.getHTMLConfig().fieldsConstraints.users.password
this.buildForm({
'password': USER_PASSWORD_VALIDATOR,
'password': getUserNewPasswordValidator(minLength, maxLength),
'password-confirm': RESET_PASSWORD_CONFIRM_VALIDATOR
})

View file

@ -62,7 +62,7 @@
<div class="col-md-12 col-xl-6 form-group">
<label for="password" i18n>Password</label>
<div class="form-group-description">{{ getMinPasswordLengthMessage() }}</div>
<div class="form-group-description">{{ minPasswordLengthMessage }}</div>
<my-input-text
formControlName="password" inputId="password"

View file

@ -2,11 +2,12 @@ import { NgClass, NgIf } from '@angular/common'
import { Component, OnInit, inject, input, output } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { SignupService } from '@app/+signup/shared/signup.service'
import { ServerService } from '@app/core'
import {
USER_DISPLAY_NAME_REQUIRED_VALIDATOR,
USER_EMAIL_VALIDATOR,
USER_PASSWORD_VALIDATOR,
USER_USERNAME_VALIDATOR
USER_USERNAME_VALIDATOR,
getUserNewPasswordValidator
} from '@app/shared/form-validators/user-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
@ -24,21 +25,29 @@ import { InputTextComponent } from '../../../shared/shared-forms/input-text.comp
export class RegisterStepUserComponent extends FormReactive implements OnInit {
protected formReactiveService = inject(FormReactiveService)
private signupService = inject(SignupService)
private serverService = inject(ServerService)
readonly videoUploadDisabled = input(false)
readonly requiresEmailVerification = input(false)
readonly formBuilt = output<FormGroup>()
minPasswordLengthMessage: string
get instanceHost () {
return window.location.host
}
ngOnInit () {
const passwordConstraints = this.serverService.getHTMLConfig().fieldsConstraints.users.password
const passwordValidator = getUserNewPasswordValidator(passwordConstraints.minLength, passwordConstraints.maxLength)
this.minPasswordLengthMessage = passwordValidator.MESSAGES.minlength
this.buildForm({
displayName: USER_DISPLAY_NAME_REQUIRED_VALIDATOR,
username: USER_USERNAME_VALIDATOR,
password: USER_PASSWORD_VALIDATOR,
password: passwordValidator,
email: USER_EMAIL_VALIDATOR
})
@ -51,10 +60,6 @@ export class RegisterStepUserComponent extends FormReactive implements OnInit {
.subscribe(([ oldValue, newValue ]) => this.onDisplayNameChange(oldValue, newValue))
}
getMinPasswordLengthMessage () {
return USER_PASSWORD_VALIDATOR.MESSAGES.minlength
}
private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) {
const username = this.form.value['username'] || ''

View file

@ -1,4 +1,4 @@
import { Validators } from '@angular/forms'
import { ValidatorFn, Validators } from '@angular/forms'
import { BuildFormValidator } from './form-validator.model'
export const USER_USERNAME_REGEX_CHARACTERS = '[a-z0-9][a-z0-9._]'
@ -70,27 +70,33 @@ export const USER_OTP_TOKEN_VALIDATOR: BuildFormValidator = {
}
}
export const USER_PASSWORD_VALIDATOR = {
VALIDATORS: [
Validators.required,
Validators.minLength(6),
Validators.maxLength(255)
],
MESSAGES: {
required: $localize`Password is required.`,
minlength: $localize`Password must be at least 6 characters long.`,
maxlength: $localize`Password cannot be more than 255 characters long.`
export function getUserNewPasswordValidator (minLength: number, maxLength: number) {
const base = getUserNewPasswordOptionalValidator(minLength, maxLength)
return {
VALIDATORS: [
Validators.required,
...base.VALIDATORS
] as ValidatorFn[],
MESSAGES: {
required: $localize`Password is required.`,
...base.MESSAGES
}
}
}
export const USER_PASSWORD_OPTIONAL_VALIDATOR: BuildFormValidator = {
VALIDATORS: [
Validators.minLength(6),
Validators.maxLength(255)
],
MESSAGES: {
minlength: $localize`Password must be at least 6 characters long.`,
maxlength: $localize`Password cannot be more than 255 characters long.`
export function getUserNewPasswordOptionalValidator (minLength: number, maxLength: number) {
return {
VALIDATORS: [
Validators.minLength(minLength),
Validators.maxLength(maxLength)
] as ValidatorFn[],
MESSAGES: {
minlength: $localize`Password must be at least ${minLength} characters long.`,
maxlength: $localize`Password cannot be more than ${maxLength} characters long.`
}
}
}

View file

@ -567,6 +567,9 @@ user:
default_channel_name: 'Main $1 channel' # The placeholder $1 is used to represent the user's username
password_constraints:
min_length: 8
video_channels:
max_per_user: 20 # Allows each user to create up to 20 video channels.

View file

@ -141,3 +141,7 @@ video_studio:
transcoding:
keep_original_file: false
user:
password_constraints:
min_length: 6

View file

@ -577,6 +577,9 @@ user:
default_channel_name: 'Main $1 channel' # The placeholder $1 is used to represent the user's username
password_constraints:
min_length: 8
video_channels:
max_per_user: 20 # Allows each user to create up to 20 video channels.
@ -1196,5 +1199,5 @@ email:
video_comments:
# Accept or not comments from remote instances
# This setting is not retroactive: current remote comments of your instance will not be affected
# This setting is not retroactive: current comments from remote platforms will not be deleted
accept_remote_comments: true

View file

@ -172,3 +172,7 @@ search:
video_transcription:
model: 'tiny'
user:
password_constraints:
min_length: 6

View file

@ -439,6 +439,15 @@ export interface ServerConfig {
nsfwFlagsSettings: {
enabled: boolean
}
fieldsConstraints: {
users: {
password: {
minLength: number
maxLength: number
}
}
}
}
export type HTMLServerConfig = Omit<ServerConfig, 'signup'>

View file

@ -27,10 +27,10 @@ describe('Test registrations API validators', function () {
await setDefaultAccountAvatar([ server ])
await setDefaultChannelAvatar([ server ])
await server.config.enableSignup(false);
await server.config.enableSignup(false)
({ token: moderatorToken } = await server.users.generate('moderator', UserRole.MODERATOR));
({ token: userToken } = await server.users.generate('user', UserRole.USER))
;({ token: moderatorToken } = await server.users.generate('moderator', UserRole.MODERATOR))
;({ token: userToken } = await server.users.generate('user', UserRole.USER))
})
describe('Register', function () {
@ -46,7 +46,6 @@ describe('Test registrations API validators', function () {
}
describe('When registering a new user or requesting user registration', function () {
async function check (fields: any, expectedStatus: HttpStatusCodeType = HttpStatusCode.BAD_REQUEST_400) {
await server.config.enableSignup(false)
await makePostBodyRequest({ url: server.url, path: registrationPath, fields, expectedStatus })
@ -209,7 +208,6 @@ describe('Test registrations API validators', function () {
})
describe('On direct registration', function () {
it('Should succeed with the correct params', async function () {
await server.config.enableSignup(false)
@ -233,7 +231,6 @@ describe('Test registrations API validators', function () {
})
describe('On registration request', function () {
before(async function () {
this.timeout(60000)
@ -321,10 +318,10 @@ describe('Test registrations API validators', function () {
before(async function () {
this.timeout(60000)
await server.config.enableSignup(true);
await server.config.enableSignup(true)
({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_2', registrationReason: 'toto' }));
({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_3', registrationReason: 'toto' }))
;({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_2', registrationReason: 'toto' }))
;({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_3', registrationReason: 'toto' }))
})
it('Should fail to accept/reject registration without token', async function () {
@ -384,9 +381,9 @@ describe('Test registrations API validators', function () {
let id3: number
before(async function () {
({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_4', registrationReason: 'toto' }));
({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_5', registrationReason: 'toto' }));
({ id: id3 } = await server.registrations.requestRegistration({ username: 'request_6', registrationReason: 'toto' }))
;({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_4', registrationReason: 'toto' }))
;({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_5', registrationReason: 'toto' }))
;({ id: id3 } = await server.registrations.requestRegistration({ username: 'request_6', registrationReason: 'toto' }))
await server.registrations.accept({ id: id2, moderationResponse: 'tt' })
await server.registrations.reject({ id: id3, moderationResponse: 'tt' })

View file

@ -37,7 +37,6 @@ describe('Test abuses', function () {
})
describe('Video abuses', function () {
before(async function () {
this.timeout(50000)
@ -315,8 +314,8 @@ describe('Test abuses', function () {
const abuse = body.data.find(a => a.id === createRes.abuse.id)
expect(abuse.reason).to.equals(reason5)
expect(abuse.predefinedReasons).to.deep.equals(predefinedReasons5, 'predefined reasons do not match the one reported')
expect(abuse.video.startAt).to.equal(1, "starting timestamp doesn't match the one reported")
expect(abuse.video.endAt).to.equal(5, "ending timestamp doesn't match the one reported")
expect(abuse.video.startAt).to.equal(1, 'starting timestamp doesn\'t match the one reported')
expect(abuse.video.endAt).to.equal(5, 'ending timestamp doesn\'t match the one reported')
}
})
@ -374,7 +373,6 @@ describe('Test abuses', function () {
})
describe('Comment abuses', function () {
async function getComment (server: PeerTubeServer, videoIdArg: number | string) {
const videoId = typeof videoIdArg === 'string'
? await server.videos.getId({ uuid: videoIdArg })
@ -570,7 +568,6 @@ describe('Test abuses', function () {
})
describe('Account abuses', function () {
function getAccountFromServer (server: PeerTubeServer, targetName: string, targetServer: PeerTubeServer) {
return server.accounts.get({ accountName: targetName + '@' + targetServer.host })
}
@ -711,7 +708,6 @@ describe('Test abuses', function () {
})
describe('Common actions on abuses', function () {
it('Should update the state of an abuse', async function () {
await commands[0].update({ abuseId: abuseServer1.id, body: { state: AbuseState.REJECTED } })

View file

@ -1022,3 +1022,66 @@ describe('Test config', function () {
await cleanupTests([ server ])
})
})
describe('YAML config', function () {
let server: PeerTubeServer
let userToken: string
before(async function () {
this.timeout(30000)
server = await createSingleServer(1, {
user: {
password_constraints: {
min_length: 10
}
}
})
await setAccessTokensToServers([ server ])
})
it('Should update the minimum length of a password', async function () {
await server.users.create({ username: 'user10', password: 'short', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
await server.users.create({ username: 'user10', password: 's'.repeat(10) })
})
it('Should still be able to login with an old password', async function () {
})
it('Should update the minimum length of a password but still allow to login', async function () {
await server.kill()
await server.run({
user: {
password_constraints: {
min_length: 12
}
}
})
const res = await server.login.login({ user: { username: 'user10', password: 's'.repeat(10) } })
userToken = res.access_token
})
it('Should be able to change the password', async function () {
await server.users.updateMe({
token: userToken,
currentPassword: 's'.repeat(10),
password: 'password',
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
await server.users.updateMe({
token: userToken,
currentPassword: 's'.repeat(10),
password: 's'.repeat(12)
})
await server.login.login({ user: { username: 'user10', password: 's'.repeat(12) } })
})
after(async function () {
await cleanupTests([ server ])
})
})

View file

@ -1,7 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import { UserRegistrationState, UserRole } from '@peertube/peertube-models'
import {
cleanupTests,
@ -11,6 +9,8 @@ import {
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import { expect } from 'chai'
describe('Test registrations', function () {
let server: PeerTubeServer

View file

@ -20,6 +20,10 @@ function createPrivateAndPublicKeys () {
async function comparePassword (plainPassword: string, hashPassword: string) {
if (!plainPassword) return false
if (Buffer.byteLength(plainPassword, 'utf8') > 72) {
throw new Error('Cannot compare more than 72 bytes with bcrypt')
}
const { compare } = await import('bcrypt')
return compare(plainPassword, hashPassword)
@ -110,7 +114,6 @@ export {
comparePassword,
createPrivateAndPublicKeys,
cryptPassword,
encrypt,
decrypt
}

View file

@ -63,6 +63,7 @@ export function checkMissedConfig () {
'user.history.videos.enabled',
'user.video_quota',
'user.video_quota_daily',
'user.password_constraints.min_length',
'video_channels.max_per_user',
'csp.enabled',
'csp.report_only',

View file

@ -507,6 +507,7 @@ const CONFIG = {
get MINIMUM_AGE () {
return config.get<number>('signup.minimum_age')
},
FILTERS: {
CIDR: {
get WHITELIST () {
@ -534,6 +535,11 @@ const CONFIG = {
},
get DEFAULT_CHANNEL_NAME () {
return config.get<string>('user.default_channel_name')
},
PASSWORD_CONSTRAINTS: {
get MIN_LENGTH () {
return config.get<number>('user.password_constraints.min_length')
}
}
},
VIDEO_CHANNELS: {

View file

@ -366,7 +366,7 @@ export const CONSTRAINTS_FIELDS = {
NAME: { min: 1, max: 120 }, // Length
DESCRIPTION: { min: 3, max: 1000 }, // Length
USERNAME: { min: 1, max: 50 }, // Length
PASSWORD: { min: 6, max: 255 }, // Length
PASSWORD: { min: CONFIG.USER.PASSWORD_CONSTRAINTS.MIN_LENGTH, max: 50 }, // Length
VIDEO_QUOTA: { min: -1 },
VIDEO_QUOTA_DAILY: { min: -1 },
VIDEO_LANGUAGES: { max: 500 }, // Array length

View file

@ -397,6 +397,15 @@ class ServerConfigManager {
nsfwFlagsSettings: {
enabled: CONFIG.NSFW_FLAGS_SETTINGS.ENABLED
},
fieldsConstraints: {
users: {
password: {
minLength: CONSTRAINTS_FIELDS.USERS.PASSWORD.min,
maxLength: CONSTRAINTS_FIELDS.USERS.PASSWORD.max
}
}
}
}
}
@ -423,7 +432,7 @@ class ServerConfigManager {
minimumAge: CONFIG.SIGNUP.MINIMUM_AGE,
requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
}
} satisfies ServerConfig['signup']
const htmlConfig = await this.getHTMLServerConfig()

View file

@ -1,3 +1,4 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import { UserRegistration, UserRegistrationState, type UserRegistrationStateType } from '@peertube/peertube-models'
import {
isRegistrationModerationResponseValid,
@ -22,10 +23,9 @@ import {
Table,
UpdatedAt
} from 'sequelize-typescript'
import { isUserDisplayNameValid, isUserEmailVerifiedValid, isUserPasswordValid } from '../../helpers/custom-validators/users.js'
import { SequelizeModel, getSort, parseAggregateResult, throwIfNotValid } from '../shared/index.js'
import { isUserDisplayNameValid, isUserEmailVerifiedValid } from '../../helpers/custom-validators/users.js'
import { getSort, parseAggregateResult, SequelizeModel, throwIfNotValid } from '../shared/index.js'
import { UserModel } from './user.js'
import { forceNumber } from '@peertube/peertube-core-utils'
@Table({
tableName: 'userRegistration',
@ -65,7 +65,6 @@ export class UserRegistrationModel extends SequelizeModel<UserRegistrationModel>
declare moderationResponse: string
@AllowNull(true)
@Is('RegistrationPassword', value => throwIfNotValid(value, isUserPasswordValid, 'registration password', true))
@Column
declare password: string

View file

@ -57,7 +57,6 @@ import {
isUserNoModal,
isUserNSFWPolicyValid,
isUserP2PEnabledValid,
isUserPasswordValid,
isUserRoleValid,
isUserVideoLanguages,
isUserVideoQuotaDailyValid,
@ -290,7 +289,6 @@ type WhereUserIdScopeOptions = { whereUserId?: '$userId' | '"UserModel"."id"' }
})
export class UserModel extends SequelizeModel<UserModel> {
@AllowNull(true)
@Is('UserPassword', value => throwIfNotValid(value, isUserPasswordValid, 'user password', true))
@Column
declare password: string