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

feat(API): permissive email check in login, reset & verification (#6648)

* feat(API): permissive email check in reset & verification

In order to not force users to be case sensitive when asking for
password reset or resend email verification. When there's multiple
emails where the only difference in the local is the capitalized
letters, in those cases the users has to be case sensitive.

closes #6570

* feat(API/login): permissive email handling

Allow case insensitive email when there's no other candidate.

closes #6570

* code review changes

* Fix tests

* Add more duplicate email checks

---------

Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
kontrollanten 2025-01-28 14:16:43 +01:00 committed by GitHub
parent 9f64909fc7
commit a51fb3f35e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 247 additions and 92 deletions

View file

@ -161,6 +161,7 @@ export class UsersCommand extends AbstractCommand {
videoQuotaDaily?: number videoQuotaDaily?: number
role?: UserRoleType role?: UserRoleType
adminFlags?: UserAdminFlagType adminFlags?: UserAdminFlagType
email?: string
}) { }) {
const { const {
username, username,
@ -168,7 +169,8 @@ export class UsersCommand extends AbstractCommand {
password = 'password', password = 'password',
videoQuota, videoQuota,
videoQuotaDaily, videoQuotaDaily,
role = UserRole.USER role = UserRole.USER,
email = username + '@example.com'
} = options } = options
const path = '/api/v1/users' const path = '/api/v1/users'
@ -182,7 +184,7 @@ export class UsersCommand extends AbstractCommand {
password, password,
role, role,
adminFlags, adminFlags,
email: username + '@example.com', email,
videoQuota, videoQuota,
videoQuotaDaily videoQuotaDaily
}, },

View file

@ -60,10 +60,30 @@ describe('Test my user API validators', function () {
it('Should fail with an invalid email attribute', async function () { it('Should fail with an invalid email attribute', async function () {
const fields = { const fields = {
email: 'blabla' email: 'blabla',
currentPassword: 'password'
} }
await makePutBodyRequest({ url: server.url, path: path + 'me', token: server.accessToken, fields }) await makePutBodyRequest({ url: server.url, path: path + 'me', token: userToken, fields })
})
it('Should fail with an already existing email attribute', async function () {
const emails = [ 'moderator1@example.com', 'moderatoR1@example.com' ]
for (const email of emails) {
const fields = {
email,
currentPassword: 'password'
}
await makePutBodyRequest({
url: server.url,
path: path + 'me',
token: userToken,
fields,
expectedStatus: HttpStatusCode.CONFLICT_409
})
}
}) })
it('Should fail with a too small password', async function () { it('Should fail with a too small password', async function () {

View file

@ -110,9 +110,16 @@ describe('Test registrations API validators', function () {
}) })
it('Should fail if we register a user with the same email', async function () { it('Should fail if we register a user with the same email', async function () {
const fields = { ...baseCorrectParams, email: 'admin' + server.internalServerNumber + '@example.com' } const emails = [
'admin' + server.internalServerNumber + '@example.com',
'Admin' + server.internalServerNumber + '@example.com'
]
await check(fields, HttpStatusCode.CONFLICT_409) for (const email of emails) {
const fields = { ...baseCorrectParams, email }
await check(fields, HttpStatusCode.CONFLICT_409)
}
}) })
it('Should fail with a bad display name', async function () { it('Should fail with a bad display name', async function () {
@ -267,16 +274,18 @@ describe('Test registrations API validators', function () {
}) })
it('Should fail if the email is already awaiting registration approval', async function () { it('Should fail if the email is already awaiting registration approval', async function () {
await server.registrations.requestRegistration({ for (const email of [ 'user_request_2@example.com', 'user_requesT_2@example.com' ]) {
username: 'user42', await server.registrations.requestRegistration({
email: 'user_request_2@example.com', username: 'user42',
registrationReason: 'tt', email,
channel: { registrationReason: 'tt',
displayName: 'my user request 42 channel', channel: {
name: 'user_request_42_channel' displayName: 'my user request 42 channel',
}, name: 'user_request_42_channel'
expectedStatus: HttpStatusCode.CONFLICT_409 },
}) expectedStatus: HttpStatusCode.CONFLICT_409
})
}
}) })
it('Should fail if the channel is already awaiting registration approval', async function () { it('Should fail if the channel is already awaiting registration approval', async function () {

View file

@ -206,15 +206,22 @@ describe('Test users admin API validators', function () {
}) })
it('Should fail if we add a user with the same email', async function () { it('Should fail if we add a user with the same email', async function () {
const fields = { ...baseCorrectParams, email: 'user1@example.com' } const emails = [
'user1@example.com',
'uSer1@example.com'
]
await makePostBodyRequest({ for (const email of emails) {
url: server.url, const fields = { ...baseCorrectParams, email }
path,
token: server.accessToken, await makePostBodyRequest({
fields, url: server.url,
expectedStatus: HttpStatusCode.CONFLICT_409 path,
}) token: server.accessToken,
fields,
expectedStatus: HttpStatusCode.CONFLICT_409
})
}
}) })
it('Should fail with an invalid videoQuota', async function () { it('Should fail with an invalid videoQuota', async function () {
@ -333,6 +340,18 @@ describe('Test users admin API validators', function () {
await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields }) await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields })
}) })
it('Should fail with an existing email attribute', async function () {
const fields = { email: 'modeRator1@example.com' }
await makePutBodyRequest({
url: server.url,
path: path + userId,
token: server.accessToken,
fields,
expectedStatus: HttpStatusCode.CONFLICT_409
})
})
it('Should fail with an invalid emailVerified attribute', async function () { it('Should fail with an invalid emailVerified attribute', async function () {
const fields = { const fields = {
emailVerified: 'yes' emailVerified: 'yes'

View file

@ -1,7 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* 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 { HttpStatusCode } from '@peertube/peertube-models' import { HttpStatusCode } from '@peertube/peertube-models'
import { import {
cleanupTests, cleanupTests,
@ -11,6 +9,9 @@ import {
setAccessTokensToServers, setAccessTokensToServers,
waitJobs waitJobs
} from '@peertube/peertube-server-commands' } from '@peertube/peertube-server-commands'
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import { SQLCommand } from '@tests/shared/sql-command.js'
import { expect } from 'chai'
describe('Test emails', function () { describe('Test emails', function () {
let server: PeerTubeServer let server: PeerTubeServer
@ -32,6 +33,8 @@ describe('Test emails', function () {
password: 'super_password' password: 'super_password'
} }
const similarUsers = [ { username: 'lowercase_user_1' }, { username: 'lowercase_user__1' } ]
before(async function () { before(async function () {
this.timeout(120000) this.timeout(120000)
@ -41,6 +44,17 @@ describe('Test emails', function () {
await setAccessTokensToServers([ server ]) await setAccessTokensToServers([ server ])
await server.config.enableSignup(true) await server.config.enableSignup(true)
{
const sqlCommand = new SQLCommand(server)
for (const user of similarUsers) {
await server.users.create(user)
}
await sqlCommand.setUserEmail('lowercase_user__1', 'Lowercase_user_1@example.com')
await sqlCommand.cleanup()
}
{ {
const created = await server.users.create({ username: user.username, password: user.password }) const created = await server.users.create({ username: user.username, password: user.password })
userId = created.id userId = created.id
@ -101,6 +115,10 @@ describe('Test emails', function () {
}) })
}) })
it('Should fail with wrong capitalization when multiple users with similar email exists', async function () {
await server.users.askResetPassword({ email: similarUsers[0].username.toUpperCase(), expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should reset the password', async function () { it('Should reset the password', async function () {
await server.users.resetPassword({ userId, verificationString, password: 'super_password2' }) await server.users.resetPassword({ userId, verificationString, password: 'super_password2' })
}) })
@ -269,6 +287,13 @@ describe('Test emails', function () {
describe('When verifying a user email', function () { describe('When verifying a user email', function () {
it('Should fail with wrong capitalization when multiple users with similar email exists', async function () {
await server.users.askSendVerifyEmail({
email: similarUsers[0].username.toUpperCase(),
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should ask to send the verification email', async function () { it('Should ask to send the verification email', async function () {
await server.users.askSendVerifyEmail({ email: 'user_1@example.com' }) await server.users.askSendVerifyEmail({ email: 'user_1@example.com' })

View file

@ -30,6 +30,11 @@ describe('Test oauth', function () {
await setAccessTokensToServers([ server ]) await setAccessTokensToServers([ server ])
sqlCommand = new SQLCommand(server) sqlCommand = new SQLCommand(server)
await server.users.create({ username: 'user1', email: 'user@example.com' })
await server.users.create({ username: 'user2', password: 'AdvancedPassword' })
await sqlCommand.setUserEmail('user2', 'User@example.com')
}) })
describe('OAuth client', function () { describe('OAuth client', function () {
@ -87,6 +92,9 @@ describe('Test oauth', function () {
it('Should be able to login', async function () { it('Should be able to login', async function () {
await server.login.login({ expectedStatus: HttpStatusCode.OK_200 }) await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
const user = { username: 'User@example.com', password: 'AdvancedPassword' }
await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
}) })
it('Should be able to login with an insensitive username', async function () { it('Should be able to login with an insensitive username', async function () {
@ -99,6 +107,22 @@ describe('Test oauth', function () {
const user3 = { username: 'ROOt', password: server.store.user.password } const user3 = { username: 'ROOt', password: server.store.user.password }
await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 }) await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
}) })
it('Should be able to login with an insensitive email when no similar emails exist', async function () {
const user = { username: 'ADMIN' + server.internalServerNumber + '@example.com', password: server.store.user.password }
await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
const user2 = { username: 'admin' + server.internalServerNumber + '@example.com', password: server.store.user.password }
await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
})
it('Should not be able to login with an insensitive email when similar emails exist', async function () {
const user = { username: 'uSer@example.com', password: 'AdvancedPassword' }
await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
const user2 = { username: 'User@example.com', password: 'AdvancedPassword' }
await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
})
}) })
describe('Logout', function () { describe('Logout', function () {

View file

@ -81,6 +81,10 @@ export class SQLCommand {
await this.updateQuery(`UPDATE "userExport" SET storage = :storage WHERE "userId" = :userId`, { storage, userId }) await this.updateQuery(`UPDATE "userExport" SET storage = :storage WHERE "userId" = :userId`, { storage, userId })
} }
async setUserEmail (username: string, email: string) {
await this.updateQuery(`UPDATE "user" SET email = :email WHERE "username" = :username`, { email, username })
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
setPluginVersion (pluginName: string, newVersion: string) { setPluginVersion (pluginName: string, newVersion: string) {

View file

@ -14,7 +14,7 @@ import { OAuthClientModel } from '../../models/oauth/oauth-client.js'
import { OAuthTokenModel } from '../../models/oauth/oauth-token.js' import { OAuthTokenModel } from '../../models/oauth/oauth-token.js'
import { UserModel } from '../../models/user/user.js' import { UserModel } from '../../models/user/user.js'
import { findAvailableLocalActorName } from '../local-actor.js' import { findAvailableLocalActorName } from '../local-actor.js'
import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user.js' import { buildUser, createUserAccountAndChannelAndPlaylist, getUserByEmailPermissive } from '../user.js'
import { ExternalUser } from './external-auth.js' import { ExternalUser } from './external-auth.js'
import { TokensCache } from './tokens-cache.js' import { TokensCache } from './tokens-cache.js'
@ -87,7 +87,7 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin
if (bypassLogin && bypassLogin.bypass === true) { if (bypassLogin && bypassLogin.bypass === true) {
logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName) logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
let user = await UserModel.loadByEmail(bypassLogin.user.email) let user = getUserByEmailPermissive(await UserModel.loadByEmailCaseInsensitive(bypassLogin.user.email), bypassLogin.user.email)
if (!user) { if (!user) {
user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
@ -119,7 +119,14 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin
logger.debug('Getting User (username/email: ' + usernameOrEmail + ', password: ******).') logger.debug('Getting User (username/email: ' + usernameOrEmail + ', password: ******).')
const user = await UserModel.loadByUsernameOrEmail(usernameOrEmail) const users = await UserModel.loadByUsernameOrEmailCaseInsensitive(usernameOrEmail)
let user: MUserDefault
if (usernameOrEmail.includes('@')) {
user = getUserByEmailPermissive(users, usernameOrEmail)
} else if (users.length === 1) {
user = users[0]
}
// If we don't find the user, or if the user belongs to a plugin // If we don't find the user, or if the user belongs to a plugin
if (!user || user.pluginAuth !== null || !password) return null if (!user || user.pluginAuth !== null || !password) return null

View file

@ -154,12 +154,14 @@ async function handlePasswordGrant (options: {
const user = await getUser(usernameOrEmail, password, bypassLogin) const user = await getUser(usernameOrEmail, password, bypassLogin)
if (!user) { if (!user) {
const registration = await UserRegistrationModel.loadByEmailOrUsername(usernameOrEmail) const registrations = await UserRegistrationModel.listByEmailCaseInsensitiveOrUsername(usernameOrEmail)
if (registration?.state === UserRegistrationState.REJECTED) { if (registrations.length === 1) {
throw new RegistrationApprovalRejected('Registration approval for this account has been rejected') if (registrations[0].state === UserRegistrationState.REJECTED) {
} else if (registration?.state === UserRegistrationState.PENDING) { throw new RegistrationApprovalRejected('Registration approval for this account has been rejected')
throw new RegistrationWaitingForApproval('Registration for this account is awaiting approval') } else if (registrations[0].state === UserRegistrationState.PENDING) {
throw new RegistrationWaitingForApproval('Registration for this account is awaiting approval')
}
} }
throw new InvalidGrantError('Invalid grant: user credentials are invalid') throw new InvalidGrantError('Invalid grant: user credentials are invalid')

View file

@ -237,6 +237,12 @@ async function isUserQuotaValid (options: {
return true return true
} }
function getUserByEmailPermissive <T extends { email: string }> (users: T[], email: string): T {
if (users.length === 1) return users[0]
return users.find(r => r.email === email)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
@ -250,7 +256,8 @@ export {
sendVerifyRegistrationEmail, sendVerifyRegistrationEmail,
isUserQuotaValid, isUserQuotaValid,
buildUser buildUser,
getUserByEmailPermissive
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -1,5 +1,6 @@
import { forceNumber } from '@peertube/peertube-core-utils' import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserRightType } from '@peertube/peertube-models' import { HttpStatusCode, UserRightType } from '@peertube/peertube-models'
import { getUserByEmailPermissive } from '@server/lib/user.js'
import { ActorModel } from '@server/models/actor/actor.js' import { ActorModel } from '@server/models/actor/actor.js'
import { UserModel } from '@server/models/user/user.js' import { UserModel } from '@server/models/user/user.js'
import { MAccountId, MUserAccountId, MUserDefault } from '@server/types/models/index.js' import { MAccountId, MUserAccountId, MUserDefault } from '@server/types/models/index.js'
@ -10,14 +11,19 @@ export function checkUserIdExist (idArg: number | string, res: express.Response,
return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res) return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
} }
export function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { export function checkUserEmailExistPermissive (email: string, res: express.Response, abortResponse = true) {
return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) return checkUserExist(async () => {
const users = await UserModel.loadByEmailCaseInsensitive(email)
return getUserByEmailPermissive(users, email)
}, res, abortResponse)
} }
export async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: string, res: express.Response) { export async function checkUsernameOrEmailDoNotAlreadyExist (username: string, email: string, res: express.Response) {
const user = await UserModel.loadByUsernameOrEmail(username, email) const existingUser = await UserModel.loadByUsernameOrEmailCaseInsensitive(username)
const existingEmail = await UserModel.loadByUsernameOrEmailCaseInsensitive(email)
if (user) { if (existingUser.length > 0 || existingEmail.length > 0) {
res.fail({ res.fail({
status: HttpStatusCode.CONFLICT_409, status: HttpStatusCode.CONFLICT_409,
message: 'User with this username or email already exists.' message: 'User with this username or email already exists.'
@ -37,6 +43,20 @@ export async function checkUserNameOrEmailDoNotAlreadyExist (username: string, e
return true return true
} }
export async function checkEmailDoesNotAlreadyExist (email: string, res: express.Response) {
const user = await UserModel.loadByEmailCaseInsensitive(email)
if (user.length !== 0) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'User with this email already exists.'
})
return false
}
return true
}
export async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) { export async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) {
const user = await finder() const user = await finder()

View file

@ -3,14 +3,19 @@ import { UserRegistrationModel } from '@server/models/user/user-registration.js'
import { MRegistration } from '@server/types/models/index.js' import { MRegistration } from '@server/types/models/index.js'
import { forceNumber, pick } from '@peertube/peertube-core-utils' import { forceNumber, pick } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models' import { HttpStatusCode } from '@peertube/peertube-models'
import { getUserByEmailPermissive } from '@server/lib/user.js'
function checkRegistrationIdExist (idArg: number | string, res: express.Response) { function checkRegistrationIdExist (idArg: number | string, res: express.Response) {
const id = forceNumber(idArg) const id = forceNumber(idArg)
return checkRegistrationExist(() => UserRegistrationModel.load(id), res) return checkRegistrationExist(() => UserRegistrationModel.load(id), res)
} }
function checkRegistrationEmailExist (email: string, res: express.Response, abortResponse = true) { function checkRegistrationEmailExistPermissive (email: string, res: express.Response, abortResponse = true) {
return checkRegistrationExist(() => UserRegistrationModel.loadByEmail(email), res, abortResponse) return checkRegistrationExist(async () => {
const registrations = await UserRegistrationModel.listByEmailCaseInsensitive(email)
return getUserByEmailPermissive(registrations, email)
}, res, abortResponse)
} }
async function checkRegistrationHandlesDoNotAlreadyExist (options: { async function checkRegistrationHandlesDoNotAlreadyExist (options: {
@ -21,9 +26,11 @@ async function checkRegistrationHandlesDoNotAlreadyExist (options: {
}) { }) {
const { res } = options const { res } = options
const registration = await UserRegistrationModel.loadByEmailOrHandle(pick(options, [ 'username', 'email', 'channelHandle' ])) const registrations = await UserRegistrationModel.listByEmailCaseInsensitiveOrHandle(
pick(options, [ 'username', 'email', 'channelHandle' ])
)
if (registration) { if (registrations.length !== 0) {
res.fail({ res.fail({
status: HttpStatusCode.CONFLICT_409, status: HttpStatusCode.CONFLICT_409,
message: 'Registration with this username, channel name or email already exists.' message: 'Registration with this username, channel name or email already exists.'
@ -54,7 +61,7 @@ async function checkRegistrationExist (finder: () => Promise<MRegistration>, res
export { export {
checkRegistrationIdExist, checkRegistrationIdExist,
checkRegistrationEmailExist, checkRegistrationEmailExistPermissive,
checkRegistrationHandlesDoNotAlreadyExist, checkRegistrationHandlesDoNotAlreadyExist,
checkRegistrationExist checkRegistrationExist
} }

View file

@ -1,14 +1,14 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import express from 'express' import express from 'express'
import { body, param } from 'express-validator' import { body, param } from 'express-validator'
import { toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { HttpStatusCode } from '@peertube/peertube-models'
import { logger } from '../../../helpers/logger.js' import { logger } from '../../../helpers/logger.js'
import { Redis } from '../../../lib/redis.js' import { Redis } from '../../../lib/redis.js'
import { areValidationErrors, checkUserEmailExist, checkUserIdExist } from '../shared/index.js' import { areValidationErrors, checkUserEmailExistPermissive, checkUserIdExist } from '../shared/index.js'
import { checkRegistrationEmailExist, checkRegistrationIdExist } from './shared/user-registrations.js' import { checkRegistrationEmailExistPermissive, checkRegistrationIdExist } from './shared/user-registrations.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
const usersAskSendVerifyEmailValidator = [ export const usersAskSendVerifyEmailValidator = [
body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'), body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
@ -19,8 +19,8 @@ const usersAskSendVerifyEmailValidator = [
}, 'filter:api.email-verification.ask-send-verify-email.body') }, 'filter:api.email-verification.ask-send-verify-email.body')
const [ userExists, registrationExists ] = await Promise.all([ const [ userExists, registrationExists ] = await Promise.all([
checkUserEmailExist(email, res, false), checkUserEmailExistPermissive(email, res, false),
checkRegistrationEmailExist(email, res, false) checkRegistrationEmailExistPermissive(email, res, false)
]) ])
if (!userExists && !registrationExists) { if (!userExists && !registrationExists) {
@ -40,7 +40,7 @@ const usersAskSendVerifyEmailValidator = [
} }
] ]
const usersVerifyEmailValidator = [ export const usersVerifyEmailValidator = [
param('id') param('id')
.isInt().not().isEmpty().withMessage('Should have a valid id'), .isInt().not().isEmpty().withMessage('Should have a valid id'),
@ -67,7 +67,7 @@ const usersVerifyEmailValidator = [
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const registrationVerifyEmailValidator = [ export const registrationVerifyEmailValidator = [
param('registrationId') param('registrationId')
.isInt().not().isEmpty().withMessage('Should have a valid registrationId'), .isInt().not().isEmpty().withMessage('Should have a valid registrationId'),
@ -88,12 +88,3 @@ const registrationVerifyEmailValidator = [
return next() return next()
} }
] ]
// ---------------------------------------------------------------------------
export {
usersAskSendVerifyEmailValidator,
usersVerifyEmailValidator,
registrationVerifyEmailValidator
}

View file

@ -9,7 +9,7 @@ import { isUserDisplayNameValid, isUserPasswordValid, isUserUsernameValid } from
import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../../helpers/custom-validators/video-channels.js' import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../../helpers/custom-validators/video-channels.js'
import { isSignupAllowed, isSignupAllowedForCurrentIP, SignupMode } from '../../../lib/signup.js' import { isSignupAllowed, isSignupAllowedForCurrentIP, SignupMode } from '../../../lib/signup.js'
import { ActorModel } from '../../../models/actor/actor.js' import { ActorModel } from '../../../models/actor/actor.js'
import { areValidationErrors, checkUserNameOrEmailDoNotAlreadyExist } from '../shared/index.js' import { areValidationErrors, checkUsernameOrEmailDoNotAlreadyExist } from '../shared/index.js'
import { checkRegistrationHandlesDoNotAlreadyExist, checkRegistrationIdExist } from './shared/user-registrations.js' import { checkRegistrationHandlesDoNotAlreadyExist, checkRegistrationIdExist } from './shared/user-registrations.js'
const usersDirectRegistrationValidator = usersCommonRegistrationValidatorFactory() const usersDirectRegistrationValidator = usersCommonRegistrationValidatorFactory()
@ -182,7 +182,7 @@ function usersCommonRegistrationValidatorFactory (additionalValidationChain: Val
const body: UserRegister | UserRegistrationRequest = req.body const body: UserRegister | UserRegistrationRequest = req.body
if (!await checkUserNameOrEmailDoNotAlreadyExist(body.username, body.email, res)) return if (!await checkUsernameOrEmailDoNotAlreadyExist(body.username, body.email, res)) return
if (body.channel) { if (body.channel) {
if (!body.channel.name || !body.channel.displayName) { if (!body.channel.name || !body.channel.displayName) {

View file

@ -31,10 +31,11 @@ import { Redis } from '../../../lib/redis.js'
import { ActorModel } from '../../../models/actor/actor.js' import { ActorModel } from '../../../models/actor/actor.js'
import { import {
areValidationErrors, areValidationErrors,
checkEmailDoesNotAlreadyExist,
checkUserCanManageAccount, checkUserCanManageAccount,
checkUserEmailExist, checkUserEmailExistPermissive,
checkUserIdExist, checkUserIdExist,
checkUserNameOrEmailDoNotAlreadyExist, checkUsernameOrEmailDoNotAlreadyExist,
doesVideoChannelIdExist, doesVideoChannelIdExist,
doesVideoExist, doesVideoExist,
isValidVideoIdParam isValidVideoIdParam
@ -85,7 +86,7 @@ export const usersAddValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { omitBodyLog: true })) return if (areValidationErrors(req, res, { omitBodyLog: true })) return
if (!await checkUserNameOrEmailDoNotAlreadyExist(req.body.username, req.body.email, res)) return if (!await checkUsernameOrEmailDoNotAlreadyExist(req.body.username, req.body.email, res)) return
const authUser = res.locals.oauth.token.User const authUser = res.locals.oauth.token.User
if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) { if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) {
@ -199,6 +200,8 @@ export const usersUpdateValidator = [
return res.fail({ message: 'Cannot change root role.' }) return res.fail({ message: 'Cannot change root role.' })
} }
if (req.body.email && !await checkEmailDoesNotAlreadyExist(req.body.email, res)) return
return next() return next()
} }
] ]
@ -278,6 +281,8 @@ export const usersUpdateMeValidator = [
if (areValidationErrors(req, res, { omitBodyLog: true })) return if (areValidationErrors(req, res, { omitBodyLog: true })) return
if (req.body.email && !await checkEmailDoesNotAlreadyExist(req.body.email, res)) return
return next() return next()
} }
] ]
@ -339,7 +344,7 @@ export const usersAskResetPasswordValidator = [
email: req.body.email email: req.body.email
}, 'filter:api.users.ask-reset-password.body') }, 'filter:api.users.ask-reset-password.body')
const exists = await checkUserEmailExist(email, res, false) const exists = await checkUserEmailExistPermissive(email, res, false)
if (!exists) { if (!exists) {
logger.debug('User with email %s does not exist (asking reset password).', email) logger.debug('User with email %s does not exist (asking reset password).', email)
// Do not leak our emails // Do not leak our emails

View file

@ -8,7 +8,7 @@ import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validator
import { cryptPassword } from '@server/helpers/peertube-crypto.js' import { cryptPassword } from '@server/helpers/peertube-crypto.js'
import { USER_REGISTRATION_STATES } from '@server/initializers/constants.js' import { USER_REGISTRATION_STATES } from '@server/initializers/constants.js'
import { MRegistration, MRegistrationFormattable } from '@server/types/models/index.js' import { MRegistration, MRegistrationFormattable } from '@server/types/models/index.js'
import { FindOptions, Op, QueryTypes, WhereOptions } from 'sequelize' import { col, FindOptions, fn, Op, QueryTypes, where, WhereOptions } from 'sequelize'
import { import {
AllowNull, AllowNull,
BeforeCreate, BeforeCreate,
@ -129,36 +129,49 @@ export class UserRegistrationModel extends SequelizeModel<UserRegistrationModel>
return UserRegistrationModel.findByPk(id) return UserRegistrationModel.findByPk(id)
} }
static loadByEmail (email: string): Promise<MRegistration> { static listByEmailCaseInsensitive (email: string): Promise<MRegistration[]> {
const query = { const query = {
where: { email } where: where(
fn('LOWER', col('email')),
'=',
email.toLowerCase()
)
} }
return UserRegistrationModel.findOne(query) return UserRegistrationModel.findAll(query)
} }
static loadByEmailOrUsername (emailOrUsername: string): Promise<MRegistration> { static listByEmailCaseInsensitiveOrUsername (emailOrUsername: string): Promise<MRegistration[]> {
const query = { const query = {
where: { where: {
[Op.or]: [ [Op.or]: [
{ email: emailOrUsername }, where(
fn('LOWER', col('email')),
'=',
emailOrUsername.toLowerCase()
),
{ username: emailOrUsername } { username: emailOrUsername }
] ]
} }
} }
return UserRegistrationModel.findOne(query) return UserRegistrationModel.findAll(query)
} }
static loadByEmailOrHandle (options: { static listByEmailCaseInsensitiveOrHandle (options: {
email: string email: string
username: string username: string
channelHandle?: string channelHandle?: string
}): Promise<MRegistration> { }): Promise<MRegistration[]> {
const { email, username, channelHandle } = options const { email, username, channelHandle } = options
let or: WhereOptions = [ let or: WhereOptions = [
{ email }, where(
fn('LOWER', col('email')),
'=',
email.toLowerCase()
),
{ channelHandle: username }, { channelHandle: username },
{ username } { username }
] ]
@ -176,7 +189,7 @@ export class UserRegistrationModel extends SequelizeModel<UserRegistrationModel>
} }
} }
return UserRegistrationModel.findOne(query) return UserRegistrationModel.findAll(query)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -663,30 +663,30 @@ export class UserModel extends SequelizeModel<UserModel> {
return UserModel.scope(ScopeNames.FOR_ME_API).findOne(query) return UserModel.scope(ScopeNames.FOR_ME_API).findOne(query)
} }
static loadByEmail (email: string): Promise<MUserDefault> { static loadByEmailCaseInsensitive (email: string): Promise<MUserDefault[]> {
const query = { const query = {
where: { where: where(
email fn('LOWER', col('email')),
} '=',
email.toLowerCase()
)
} }
return UserModel.findOne(query) return UserModel.findAll(query)
} }
static loadByUsernameOrEmail (username: string, email?: string): Promise<MUserDefault> { static loadByUsernameOrEmailCaseInsensitive (usernameOrEmail: string): Promise<MUserDefault[]> {
if (!email) email = username
const query = { const query = {
where: { where: {
[Op.or]: [ [Op.or]: [
where(fn('lower', col('username')), fn('lower', username) as any), where(fn('lower', col('username')), fn('lower', usernameOrEmail) as any),
{ email } where(fn('lower', col('email')), fn('lower', usernameOrEmail) as any)
] ]
} }
} }
return UserModel.findOne(query) return UserModel.findAll(query)
} }
static loadByVideoId (videoId: number): Promise<MUserDefault> { static loadByVideoId (videoId: number): Promise<MUserDefault> {