1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 09:49:20 +02:00

Better ask email verification flow

Allow user to resend the email verification link when changing the
current email
Fix success messages when validating a new email
This commit is contained in:
Chocobozzz 2025-04-15 09:19:12 +02:00
parent e19ee1ebc9
commit 986e71a1f7
No known key found for this signature in database
GPG key ID: 583A612D890159BE
29 changed files with 426 additions and 271 deletions

View file

@ -1,12 +1,12 @@
import { NgIf } from '@angular/common'
import { Component, OnDestroy, OnInit, inject, viewChild } from '@angular/core'
import { Component, inject, OnDestroy, OnInit, viewChild } from '@angular/core'
import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
import { Account } from '@app/shared/shared-main/account/account.model'
import { AccountService } from '@app/shared/shared-main/account/account.service'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { VideoFilters } from '@app/shared/shared-video-miniature/video-filters.model'
import { VideoSortField } from '@peertube/peertube-models'
import { first, Subscription } from 'rxjs'
import { Subscription } from 'rxjs'
import { VideosListComponent } from '../../shared/shared-video-miniature/videos-list.component'
@Component({

View file

@ -1,8 +1,16 @@
<my-alert *ngIf="error" type="danger">{{ error }}</my-alert>
<my-alert *ngIf="success" type="success">{{ success }}</my-alert>
<div i18n class="pending-email" *ngIf="user.pendingEmail">
<strong>{{ user.pendingEmail }}</strong> is awaiting email verification
<div class="pending-email" *ngIf="user.pendingEmail">
<div i18n>
<strong>{{ user.pendingEmail }}</strong> is awaiting email verification.
</div>
@if (verificationEmailSent) {
<div i18n>Email verification sent!</div>
} @else {
<button type="button" class="peertube-button-like-link" i18n (click)="resendVerificationEmail()">Resend your verification email</button>
}
</div>
<form class="change-email" (ngSubmit)="changeEmail()" [formGroup]="form">

View file

@ -23,6 +23,7 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
private userService = inject(UserService)
private serverService = inject(ServerService)
verificationEmailSent = false
error: string
success: string
user: User
@ -68,4 +69,16 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
}
})
}
resendVerificationEmail () {
this.userService.askSendVerifyEmail(this.user.pendingEmail).subscribe({
next: () => {
this.verificationEmailSent = true
},
error: err => {
this.error = err.message
}
})
}
}

View file

@ -1,5 +1,5 @@
import { Routes } from '@angular/router'
import { VerifyAccountAskSendEmailComponent } from './verify-account-ask-send-email/verify-account-ask-send-email.component'
import { VerifyNewAccountAskSendEmailComponent } from './verify-new-account-ask-send-email/verify-new-account-ask-send-email.component'
import { VerifyAccountEmailComponent } from './verify-account-email/verify-account-email.component'
import { SignupService } from '../shared/signup.service'
@ -19,7 +19,7 @@ export default [
},
{
path: 'ask-send-email',
component: VerifyAccountAskSendEmailComponent,
component: VerifyNewAccountAskSendEmailComponent,
data: {
meta: {
title: $localize`Ask to send an email to verify your account`

View file

@ -1,22 +0,0 @@
<div class="margin-content">
<h1 i18n class="title-page">Request email for account verification</h1>
<form *ngIf="requiresEmailVerification; else emailVerificationNotRequired" (ngSubmit)="askSendVerifyEmail()" [formGroup]="form">
<div class="form-group">
<label i18n for="verify-email-email">Email</label>
<input
type="email" id="verify-email-email" i18n-placeholder placeholder="Email address" required
formControlName="verify-email-email" class="form-control" [ngClass]="{ 'input-error': formErrors['verify-email-email'] }"
>
<div *ngIf="formErrors['verify-email-email']" class="form-error" role="alert">{{ formErrors['verify-email-email'] }}</div>
</div>
<input class="peertube-button primary-button" type="submit" i18n-value value="Send verification email" [disabled]="!form.valid">
</form>
<ng-template #emailVerificationNotRequired>
<div i18n>This instance does not require email verification.</div>
</ng-template>
</div>

View file

@ -1,46 +0,0 @@
import { Component, OnInit, inject } from '@angular/core'
import { SignupService } from '@app/+signup/shared/signup.service'
import { Notifier, RedirectService, ServerService } from '@app/core'
import { USER_EMAIL_VALIDATOR } 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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgIf, NgClass } from '@angular/common'
@Component({
selector: 'my-verify-account-ask-send-email',
templateUrl: './verify-account-ask-send-email.component.html',
styleUrls: [ './verify-account-ask-send-email.component.scss' ],
imports: [ NgIf, FormsModule, ReactiveFormsModule, NgClass ]
})
export class VerifyAccountAskSendEmailComponent extends FormReactive implements OnInit {
protected formReactiveService = inject(FormReactiveService)
private signupService = inject(SignupService)
private serverService = inject(ServerService)
private notifier = inject(Notifier)
private redirectService = inject(RedirectService)
requiresEmailVerification = false
ngOnInit () {
this.serverService.getConfig()
.subscribe(config => this.requiresEmailVerification = config.signup.requiresEmailVerification)
this.buildForm({
'verify-email-email': USER_EMAIL_VALIDATOR
})
}
askSendVerifyEmail () {
const email = this.form.value['verify-email-email']
this.signupService.askSendVerifyEmail(email)
.subscribe({
next: () => {
this.notifier.success($localize`An email with verification link will be sent to ${email}.`)
this.redirectService.redirectToHomepage()
},
error: err => this.notifier.error(err.message)
})
}
}

View file

@ -1,19 +1,20 @@
<div *ngIf="loaded" class="margin-content">
<h1 i18n class="title-page">Verify email</h1>
<my-signup-success-after-email
*ngIf="displaySignupSuccess()"
[requiresApproval]="isRegistrationRequest() && requiresApproval"
>
</my-signup-success-after-email>
@if (success) {
@if (this.isRegistration() || this.isRegistrationRequest()) {
<my-signup-success-after-email [requiresApproval]="requiresApproval"></my-signup-success-after-email>
} @else {
<my-alert type="success" i18n>Email updated.</my-alert>
}
<my-alert type="success" i18n *ngIf="!isRegistrationRequest() && isPendingEmail && success">Email updated.</my-alert>
<my-alert type="danger" *ngIf="failed">
} @else if (failed) {
<my-alert type="danger">
<span i18n>An error occurred.</span>
<a i18n class="ms-1 link-primary" routerLink="/verify-account/ask-send-email">
Request a new verification email
</a>
</my-alert>
}
</div>

View file

@ -2,7 +2,7 @@ import { NgIf } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { SignupService } from '@app/+signup/shared/signup.service'
import { AuthService, Notifier, ServerService } from '@app/core'
import { AuthService, Notifier, ServerService, UserService } from '@app/core'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { SignupSuccessAfterEmailComponent } from '../../shared/signup-success-after-email.component'
@ -13,6 +13,7 @@ import { SignupSuccessAfterEmailComponent } from '../../shared/signup-success-af
})
export class VerifyAccountEmailComponent implements OnInit {
private signupService = inject(SignupService)
private userService = inject(UserService)
private server = inject(ServerService)
private authService = inject(AuthService)
private notifier = inject(Notifier)
@ -44,9 +45,7 @@ export class VerifyAccountEmailComponent implements OnInit {
this.userId = queryParams['userId']
this.registrationId = queryParams['registrationId']
this.verificationString = queryParams['verificationString']
this.isPendingEmail = queryParams['isPendingEmail'] === 'true'
if (!this.verificationString) {
@ -62,15 +61,12 @@ export class VerifyAccountEmailComponent implements OnInit {
this.verifyEmail()
}
isRegistrationRequest () {
return !!this.registrationId
isRegistration () {
return !this.isPendingEmail
}
displaySignupSuccess () {
if (!this.success) return false
if (!this.isRegistrationRequest() && this.isPendingEmail) return false
return true
isRegistrationRequest () {
return !!this.registrationId
}
verifyEmail () {
@ -88,7 +84,7 @@ export class VerifyAccountEmailComponent implements OnInit {
isPendingEmail: this.isPendingEmail
}
this.signupService.verifyUserEmail(options)
this.userService.verifyUserEmail(options)
.subscribe({
next: () => {
if (this.authService.isLoggedIn()) {

View file

@ -0,0 +1,22 @@
<div class="margin-content">
<h1 i18n class="title-page">Request email for account verification</h1>
@if (requiresEmailVerification) {
<form (ngSubmit)="askSendVerifyEmail()" [formGroup]="form">
<div class="form-group">
<label i18n for="verify-email-email">Email</label>
<input
type="email" id="verify-email-email" i18n-placeholder placeholder="Email address" required
formControlName="verify-email-email" class="form-control" [ngClass]="{ 'input-error': formErrors['verify-email-email'] }"
>
<div *ngIf="formErrors['verify-email-email']" class="form-error" role="alert">{{ formErrors['verify-email-email'] }}</div>
</div>
<input class="peertube-button primary-button" type="submit" i18n-value value="Send verification email" [disabled]="!form.valid">
</form>
} @else {
<div i18n>{{ instanceName }} does not require email verification.</div>
}
</div>

View file

@ -0,0 +1,57 @@
import { NgClass, NgIf } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { SignupService } from '@app/+signup/shared/signup.service'
import { Notifier, RedirectService, ServerService, UserService } from '@app/core'
import { USER_EMAIL_VALIDATOR } 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 { forkJoin } from 'rxjs'
@Component({
selector: 'my-verify-new-account-ask-send-email',
templateUrl: './verify-new-account-ask-send-email.component.html',
styleUrls: [ './verify-new-account-ask-send-email.component.scss' ],
imports: [ NgIf, FormsModule, ReactiveFormsModule, NgClass ]
})
export class VerifyNewAccountAskSendEmailComponent extends FormReactive implements OnInit {
protected formReactiveService = inject(FormReactiveService)
private userService = inject(UserService)
private signupService = inject(SignupService)
private serverService = inject(ServerService)
private notifier = inject(Notifier)
private redirectService = inject(RedirectService)
requiresEmailVerification = false
get instanceName () {
return this.serverService.getHTMLConfig().instance.name
}
ngOnInit () {
this.serverService.getConfig()
.subscribe(config => {
this.requiresEmailVerification = config.signup.requiresEmailVerification
})
this.buildForm({
'verify-email-email': USER_EMAIL_VALIDATOR
})
}
askSendVerifyEmail () {
const email = this.form.value['verify-email-email']
forkJoin([
this.userService.askSendVerifyEmail(email),
this.signupService.askSendVerifyEmail(email)
]).subscribe({
next: () => {
this.notifier.success($localize`An email with verification link will be sent to ${email}.`)
this.redirectService.redirectToHomepage()
},
error: err => this.notifier.error(err.message)
})
}
}

View file

@ -3,19 +3,17 @@
</my-signup-step-title>
<my-alert type="primary">
<ng-container *ngIf="requiresApproval()">
@if (requiresApproval()) {
<p i18n>Your email has been verified and your account request has been sent!</p>
<p i18n>
A moderator will check your registration request soon and you'll receive an email when it is accepted or rejected.
</p>
</ng-container>
<ng-container *ngIf="!requiresApproval()">
} @else {
<p i18n>Your email has been verified and your account has been created!</p>
<p i18n>
If you need help using PeerTube, you can have a look at the <a class="link-primary" href="https://docs.joinpeertube.org/use/setup-account" target="_blank" rel="noopener noreferrer">documentation</a>.
</p>
</ng-container>
}
</my-alert>

View file

@ -1,4 +1,3 @@
import { NgIf } from '@angular/common'
import { Component, input } from '@angular/core'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { SignupStepTitleComponent } from './signup-step-title.component'
@ -7,7 +6,7 @@ import { SignupStepTitleComponent } from './signup-step-title.component'
selector: 'my-signup-success-after-email',
templateUrl: './signup-success-after-email.component.html',
styleUrls: [ './signup-success.component.scss' ],
imports: [ SignupStepTitleComponent, NgIf, AlertComponent ]
imports: [ SignupStepTitleComponent, AlertComponent ]
})
export class SignupSuccessAfterEmailComponent {
readonly requiresApproval = input<boolean>(undefined)

View file

@ -25,21 +25,11 @@ export class SignupService {
// ---------------------------------------------------------------------------
verifyUserEmail (options: {
userId: number
verificationString: string
isPendingEmail: boolean
}) {
const { userId, verificationString, isPendingEmail } = options
askSendVerifyEmail (email: string) {
const url = `${UserService.BASE_USERS_URL}registrations/ask-send-verify-email`
const url = `${UserService.BASE_USERS_URL}${userId}/verify-email`
const body = {
verificationString,
isPendingEmail
}
return this.authHttp.post(url, body)
.pipe(catchError(res => this.restExtractor.handleError(res)))
return this.authHttp.post(url, { email })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
verifyRegistrationEmail (options: {
@ -55,13 +45,6 @@ export class SignupService {
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
askSendVerifyEmail (email: string) {
const url = UserService.BASE_USERS_URL + 'ask-send-verify-email'
return this.authHttp.post(url, { email })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
// ---------------------------------------------------------------------------
getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) {

View file

@ -88,6 +88,8 @@ export class UserService {
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
// ---------------------------------------------------------------------------
changeEmail (password: string, newEmail: string) {
const url = UserService.BASE_USERS_URL + 'me'
const body: UserUpdateMe = {
@ -99,6 +101,32 @@ export class UserService {
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
askSendVerifyEmail (email: string) {
const url = UserService.BASE_USERS_URL + 'ask-send-verify-email'
return this.authHttp.post(url, { email })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
verifyUserEmail (options: {
userId: number
verificationString: string
isPendingEmail: boolean
}) {
const { userId, verificationString, isPendingEmail } = options
const url = `${UserService.BASE_USERS_URL}${userId}/verify-email`
const body = {
verificationString,
isPendingEmail
}
return this.authHttp.post(url, body)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
// ---------------------------------------------------------------------------
updateMyProfile (profile: UserUpdateMe) {
const url = UserService.BASE_USERS_URL + 'me'

View file

@ -5,8 +5,6 @@
input {
@include peertube-input-text(auto);
@include padding-left(15px !important);
@include padding-right(15px !important);
}
.btn,

View file

@ -5,13 +5,10 @@ block title
block content
if isRegistrationRequest
p You just requested an account on #[a(href=WEBSERVER.URL) #{instanceName}].
else
p You just created an account on #[a(href=WEBSERVER.URL) #{instanceName}].
if isRegistrationRequest
p You requested an account on #[a(href=WEBSERVER.URL) #{instanceName}].
p To complete your registration request you must verify your email first!
else
p You created an account on #[a(href=WEBSERVER.URL) #{instanceName}].
p To start using your account you must verify your email first!
p Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you.

View file

@ -0,0 +1,11 @@
extends ../common/greetings
block title
| Email verification
block content
p You requested to change your email.
p Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you.
p If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}]
p If you are not the person who initiated this request, please contact your administrator.

View file

@ -1,11 +1,12 @@
import express from 'express'
import { HttpStatusCode } from '@peertube/peertube-models'
import express from 'express'
import { CONFIG } from '../../../initializers/config.js'
import { sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user.js'
import { sendVerifyRegistrationEmail, sendVerifyRegistrationRequestEmail, sendVerifyUserChangeEmail } from '../../../lib/user.js'
import { asyncMiddleware, buildRateLimiter } from '../../../middlewares/index.js'
import {
registrationVerifyEmailValidator,
usersAskSendVerifyEmailValidator,
usersAskSendRegistrationVerifyEmailValidator,
usersAskSendUserVerifyEmailValidator,
usersVerifyEmailValidator
} from '../../../middlewares/validators/index.js'
@ -16,18 +17,24 @@ const askSendEmailLimiter = buildRateLimiter({
const emailVerificationRouter = express.Router()
emailVerificationRouter.post([ '/ask-send-verify-email', '/registrations/ask-send-verify-email' ],
emailVerificationRouter.post(
'/ask-send-verify-email',
askSendEmailLimiter,
asyncMiddleware(usersAskSendVerifyEmailValidator),
asyncMiddleware(reSendVerifyUserEmail)
asyncMiddleware(usersAskSendUserVerifyEmailValidator),
asyncMiddleware(reSendUserVerifyUserEmail)
)
emailVerificationRouter.post('/:id/verify-email',
asyncMiddleware(usersVerifyEmailValidator),
asyncMiddleware(verifyUserEmail)
emailVerificationRouter.post(
'/registrations/ask-send-verify-email',
askSendEmailLimiter,
asyncMiddleware(usersAskSendRegistrationVerifyEmailValidator),
asyncMiddleware(reSendRegistrationVerifyUserEmail)
)
emailVerificationRouter.post('/registrations/:registrationId/verify-email',
emailVerificationRouter.post('/:id/verify-email', asyncMiddleware(usersVerifyEmailValidator), asyncMiddleware(verifyUserEmail))
emailVerificationRouter.post(
'/registrations/:registrationId/verify-email',
asyncMiddleware(registrationVerifyEmailValidator),
asyncMiddleware(verifyRegistrationEmail)
)
@ -38,14 +45,20 @@ export {
emailVerificationRouter
}
async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
const user = res.locals.user
const registration = res.locals.userRegistration
async function reSendUserVerifyUserEmail (req: express.Request, res: express.Response) {
if (res.locals.userPendingEmail) { // User wants to change its current email
await sendVerifyUserChangeEmail(res.locals.userPendingEmail)
} else { // After an account creation
await sendVerifyRegistrationEmail(res.locals.userEmail)
}
if (user) await sendVerifyUserEmail(user)
else if (registration) await sendVerifyRegistrationEmail(registration)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
return res.status(HttpStatusCode.NO_CONTENT_204).end()
async function reSendRegistrationVerifyUserEmail (req: express.Request, res: express.Response) {
await sendVerifyRegistrationRequestEmail(res.locals.userRegistration)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function verifyUserEmail (req: express.Request, res: express.Response) {
@ -59,7 +72,7 @@ async function verifyUserEmail (req: express.Request, res: express.Response) {
await user.save()
return res.status(HttpStatusCode.NO_CONTENT_204).end()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function verifyRegistrationEmail (req: express.Request, res: express.Response) {
@ -68,5 +81,5 @@ async function verifyRegistrationEmail (req: express.Request, res: express.Respo
await registration.save()
return res.status(HttpStatusCode.NO_CONTENT_204).end()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View file

@ -22,7 +22,7 @@ import { MIMETYPES } from '../../../initializers/constants.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { sendUpdateActor } from '../../../lib/activitypub/send/index.js'
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../../lib/local-actor.js'
import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user.js'
import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserChangeEmail } from '../../../lib/user.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
@ -290,7 +290,7 @@ async function updateMe (req: express.Request, res: express.Response) {
})
if (sendVerificationEmail === true) {
await sendVerifyUserEmail(user, true)
await sendVerifyUserChangeEmail(user)
}
return res.status(HttpStatusCode.NO_CONTENT_204).end()

View file

@ -1,7 +1,3 @@
import express from 'express'
import { Emailer } from '@server/lib/emailer.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { UserRegistrationModel } from '@server/models/user/user-registration.js'
import { pick } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
@ -11,11 +7,20 @@ import {
UserRegistrationUpdateState,
UserRight
} from '@peertube/peertube-models'
import { Emailer } from '@server/lib/emailer.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { UserRegistrationModel } from '@server/models/user/user-registration.js'
import express from 'express'
import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger.js'
import { logger } from '../../../helpers/logger.js'
import { CONFIG } from '../../../initializers/config.js'
import { Notifier } from '../../../lib/notifier/index.js'
import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user.js'
import {
buildUser,
createUserAccountAndChannelAndPlaylist,
sendVerifyRegistrationEmail,
sendVerifyRegistrationRequestEmail
} from '../../../lib/user.js'
import {
acceptOrRejectRegistrationValidator,
asyncMiddleware,
@ -45,7 +50,8 @@ const registrationRateLimiter = buildRateLimiter({
const registrationsRouter = express.Router()
registrationsRouter.post('/registrations/request',
registrationsRouter.post(
'/registrations/request',
registrationRateLimiter,
asyncMiddleware(ensureUserRegistrationAllowedFactory('request-registration')),
ensureUserRegistrationAllowedForIP,
@ -53,27 +59,31 @@ registrationsRouter.post('/registrations/request',
asyncRetryTransactionMiddleware(requestRegistration)
)
registrationsRouter.post('/registrations/:registrationId/accept',
registrationsRouter.post(
'/registrations/:registrationId/accept',
authenticate,
ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
asyncMiddleware(acceptOrRejectRegistrationValidator),
asyncRetryTransactionMiddleware(acceptRegistration)
)
registrationsRouter.post('/registrations/:registrationId/reject',
registrationsRouter.post(
'/registrations/:registrationId/reject',
authenticate,
ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
asyncMiddleware(acceptOrRejectRegistrationValidator),
asyncRetryTransactionMiddleware(rejectRegistration)
)
registrationsRouter.delete('/registrations/:registrationId',
registrationsRouter.delete(
'/registrations/:registrationId',
authenticate,
ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
asyncMiddleware(getRegistrationValidator),
asyncRetryTransactionMiddleware(deleteRegistration)
)
registrationsRouter.get('/registrations',
registrationsRouter.get(
'/registrations',
authenticate,
ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
paginationValidator,
@ -84,7 +94,8 @@ registrationsRouter.get('/registrations',
asyncMiddleware(listRegistrations)
)
registrationsRouter.post('/register',
registrationsRouter.post(
'/register',
registrationRateLimiter,
asyncMiddleware(ensureUserRegistrationAllowedFactory('direct-registration')),
ensureUserRegistrationAllowedForIP,
@ -118,7 +129,7 @@ async function requestRegistration (req: express.Request, res: express.Response)
await registration.save()
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
await sendVerifyRegistrationEmail(registration)
await sendVerifyRegistrationRequestEmail(registration)
}
Notifier.Instance.notifyOnNewRegistrationRequest(registration)
@ -242,7 +253,7 @@ async function registerUser (req: express.Request, res: express.Response) {
logger.info('User %s with its channel and account registered.', body.username)
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
await sendVerifyUserEmail(user)
await sendVerifyRegistrationEmail(user)
}
Notifier.Instance.notifyOnNewDirectRegistration(user)

View file

@ -14,7 +14,7 @@ import { OAuthClientModel } from '../../models/oauth/oauth-client.js'
import { OAuthTokenModel } from '../../models/oauth/oauth-token.js'
import { UserModel } from '../../models/user/user.js'
import { findAvailableLocalActorName } from '../local-actor.js'
import { buildUser, createUserAccountAndChannelAndPlaylist, getUserByEmailPermissive } from '../user.js'
import { buildUser, createUserAccountAndChannelAndPlaylist, getByEmailPermissive } from '../user.js'
import { ExternalUser } from './external-auth.js'
import { TokensCache } from './tokens-cache.js'
@ -87,7 +87,7 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin
if (bypassLogin && bypassLogin.bypass === true) {
logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
let user = getUserByEmailPermissive(await UserModel.loadByEmailCaseInsensitive(bypassLogin.user.email), bypassLogin.user.email)
let user = getByEmailPermissive(await UserModel.loadByEmailCaseInsensitive(bypassLogin.user.email), bypassLogin.user.email)
if (!user) {
user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
@ -105,7 +105,9 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin
if (user.pluginAuth !== bypassLogin.pluginName) {
logger.info(
'Cannot bypass oauth login by plugin %s because %s has another plugin auth method (%s).',
bypassLogin.pluginName, bypassLogin.user.email, user.pluginAuth
bypassLogin.pluginName,
bypassLogin.user.email,
user.pluginAuth
)
return null
@ -123,7 +125,7 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin
let user: MUserDefault
if (usernameOrEmail.includes('@')) {
user = getUserByEmailPermissive(users, usernameOrEmail)
user = getByEmailPermissive(users, usernameOrEmail)
} else if (users.length === 1) {
user = users[0]
}

View file

@ -15,7 +15,6 @@ import { JobQueue } from './job-queue/index.js'
import { Hooks } from './plugins/hooks.js'
class Emailer {
private static instance: Emailer
private initialized = false
private transporter: Transporter
@ -89,7 +88,29 @@ class Emailer {
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
}
addVerifyEmailJob (options: {
addUserVerifyChangeEmailJob (options: {
username: string
to: string
verifyEmailUrl: string
}) {
const { username, to, verifyEmailUrl } = options
const emailPayload: EmailPayload = {
template: 'verify-user-change-email',
to: [ to ],
subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`,
locals: {
username,
verifyEmailUrl,
hideNotificationPreferencesLink: true
}
}
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
}
addRegistrationVerifyEmailJob (options: {
username: string
isRegistrationRequest: boolean
to: string
@ -98,7 +119,7 @@ class Emailer {
const { username, isRegistrationRequest, to, verifyEmailUrl } = options
const emailPayload: EmailPayload = {
template: 'verify-email',
template: 'verify-registration-email',
to: [ to ],
subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`,
locals: {

View file

@ -1,4 +1,3 @@
import { Transaction } from 'sequelize'
import {
ActivityPubActorType,
UserAdminFlag,
@ -12,6 +11,7 @@ import { logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { UserModel } from '@server/models/user/user.js'
import { MActorDefault } from '@server/types/models/actor/index.js'
import { Transaction } from 'sequelize'
import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants.js'
import { sequelizeTypescript } from '../initializers/database.js'
import { AccountModel } from '../models/account/account.js'
@ -29,7 +29,7 @@ import { createWatchLaterPlaylist } from './video-playlist.js'
type ChannelNames = { name: string, displayName: string }
function buildUser (options: {
export function buildUser (options: {
username: string
password: string
email: string
@ -80,7 +80,7 @@ function buildUser (options: {
// ---------------------------------------------------------------------------
async function createUserAccountAndChannelAndPlaylist (parameters: {
export async function createUserAccountAndChannelAndPlaylist (parameters: {
userToCreate: MUser
userDisplayName?: string
channelNames?: ChannelNames
@ -125,7 +125,7 @@ async function createUserAccountAndChannelAndPlaylist (parameters: {
return { user, account, videoChannel }
}
async function createLocalAccountWithoutKeys (parameters: {
export async function createLocalAccountWithoutKeys (parameters: {
name: string
displayName?: string
userId: number | null
@ -152,7 +152,7 @@ async function createLocalAccountWithoutKeys (parameters: {
return accountInstanceCreated
}
async function createApplicationActor (applicationId: number) {
export async function createApplicationActor (applicationId: number) {
const accountCreated = await createLocalAccountWithoutKeys({
name: SERVER_ACTOR_NAME,
userId: null,
@ -168,47 +168,64 @@ async function createApplicationActor (applicationId: number) {
// ---------------------------------------------------------------------------
async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
export async function buildUserVerifyEmail (user: MUser, isPendingEmail: boolean) {
const verificationString = await Redis.Instance.setUserVerifyEmailVerificationString(user.id)
let verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?userId=${user.id}&verificationString=${verificationString}`
if (isPendingEmail) verifyEmailUrl += '&isPendingEmail=true'
const verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?userId=${user.id}&verificationString=${verificationString}`
const to = isPendingEmail
? user.pendingEmail
: user.email
if (isPendingEmail) return verifyEmailUrl + '&isPendingEmail=true'
const username = user.username
Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: false })
return verifyEmailUrl
}
async function sendVerifyRegistrationEmail (registration: MRegistration) {
export async function buildRegistrationRequestVerifyEmail (registration: MRegistration) {
const verificationString = await Redis.Instance.setRegistrationVerifyEmailVerificationString(registration.id)
const verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?registrationId=${registration.id}&verificationString=${verificationString}`
const to = registration.email
const username = registration.username
return `${WEBSERVER.URL}/verify-account/email?registrationId=${registration.id}&verificationString=${verificationString}`
}
Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: true })
export async function sendVerifyUserChangeEmail (user: MUser) {
Emailer.Instance.addUserVerifyChangeEmailJob({
username: user.username,
to: user.pendingEmail,
verifyEmailUrl: await buildUserVerifyEmail(user, true)
})
}
export async function sendVerifyRegistrationRequestEmail (registration: MRegistration) {
Emailer.Instance.addRegistrationVerifyEmailJob({
username: registration.username,
to: registration.email,
verifyEmailUrl: await buildRegistrationRequestVerifyEmail(registration),
isRegistrationRequest: true
})
}
export async function sendVerifyRegistrationEmail (user: MUser) {
Emailer.Instance.addRegistrationVerifyEmailJob({
username: user.username,
to: user.email,
verifyEmailUrl: await buildUserVerifyEmail(user, false),
isRegistrationRequest: true
})
}
// ---------------------------------------------------------------------------
async function getOriginalVideoFileTotalFromUser (user: MUserId) {
export async function getOriginalVideoFileTotalFromUser (user: MUserId) {
const base = await UserModel.getUserQuota({ userId: user.id, daily: false })
return base + LiveQuotaStore.Instance.getLiveQuotaOfUser(user.id)
}
// Returns cumulative size of all video files uploaded in the last 24 hours.
async function getOriginalVideoFileTotalDailyFromUser (user: MUserId) {
export async function getOriginalVideoFileTotalDailyFromUser (user: MUserId) {
const base = await UserModel.getUserQuota({ userId: user.id, daily: true })
return base + LiveQuotaStore.Instance.getLiveQuotaOfUser(user.id)
}
async function isUserQuotaValid (options: {
export async function isUserQuotaValid (options: {
userId: number
uploadSize: number
checkDaily?: boolean // default true
@ -227,7 +244,8 @@ async function isUserQuotaValid (options: {
const uploadedDaily = uploadSize + totalBytesDaily
logger.debug(
'Check user %d quota to upload content.', userId,
'Check user %d quota to upload content.',
userId,
{ totalBytes, totalBytesDaily, videoQuota: user.videoQuota, videoQuotaDaily: user.videoQuotaDaily, uploadSize }
)
@ -237,29 +255,14 @@ async function isUserQuotaValid (options: {
return true
}
function getUserByEmailPermissive <T extends { email: string }> (users: T[], email: string): T {
export function getByEmailPermissive<T extends { email: string }> (users: T[], email: string, field: keyof T = 'email'): T {
if (users.length === 1) return users[0]
return users.find(r => r.email === email)
return users.find(r => r[field] === email)
}
// ---------------------------------------------------------------------------
export {
getOriginalVideoFileTotalFromUser,
getOriginalVideoFileTotalDailyFromUser,
createApplicationActor,
createUserAccountAndChannelAndPlaylist,
createLocalAccountWithoutKeys,
sendVerifyUserEmail,
sendVerifyRegistrationEmail,
isUserQuotaValid,
buildUser,
getUserByEmailPermissive
}
// Private
// ---------------------------------------------------------------------------
function createDefaultUserNotificationSettings (user: MUserId, t: Transaction | undefined) {

View file

@ -1,6 +1,6 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserRightType } from '@peertube/peertube-models'
import { getUserByEmailPermissive } from '@server/lib/user.js'
import { getByEmailPermissive } from '@server/lib/user.js'
import { ActorModel } from '@server/models/actor/actor.js'
import { UserModel } from '@server/models/user/user.js'
import { MAccountId, MUserAccountId, MUserDefault } from '@server/types/models/index.js'
@ -16,7 +16,19 @@ export function checkUserEmailExistPermissive (email: string, res: express.Respo
async () => {
const users = await UserModel.loadByEmailCaseInsensitive(email)
return getUserByEmailPermissive(users, email)
return getByEmailPermissive(users, email)
},
res,
abortResponse
)
}
export function checkUserPendingEmailExistPermissive (email: string, res: express.Response, abortResponse = true) {
return checkUserExist(
async () => {
const users = await UserModel.loadByPendingEmailCaseInsensitive(email)
return getByEmailPermissive(users, email)
},
res,
abortResponse

View file

@ -3,22 +3,26 @@ import { UserRegistrationModel } from '@server/models/user/user-registration.js'
import { MRegistration } from '@server/types/models/index.js'
import { forceNumber, pick } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import { getUserByEmailPermissive } from '@server/lib/user.js'
import { getByEmailPermissive } from '@server/lib/user.js'
function checkRegistrationIdExist (idArg: number | string, res: express.Response) {
export function checkRegistrationIdExist (idArg: number | string, res: express.Response) {
const id = forceNumber(idArg)
return checkRegistrationExist(() => UserRegistrationModel.load(id), res)
}
function checkRegistrationEmailExistPermissive (email: string, res: express.Response, abortResponse = true) {
return checkRegistrationExist(async () => {
export function checkRegistrationEmailExistPermissive (email: string, res: express.Response, abortResponse = true) {
return checkRegistrationExist(
async () => {
const registrations = await UserRegistrationModel.listByEmailCaseInsensitive(email)
return getUserByEmailPermissive(registrations, email)
}, res, abortResponse)
return getByEmailPermissive(registrations, email)
},
res,
abortResponse
)
}
async function checkRegistrationHandlesDoNotAlreadyExist (options: {
export async function checkRegistrationHandlesDoNotAlreadyExist (options: {
username: string
channelHandle: string
email: string
@ -41,7 +45,7 @@ async function checkRegistrationHandlesDoNotAlreadyExist (options: {
return true
}
async function checkRegistrationExist (finder: () => Promise<MRegistration>, res: express.Response, abortResponse = true) {
export async function checkRegistrationExist (finder: () => Promise<MRegistration>, res: express.Response, abortResponse = true) {
const registration = await finder()
if (!registration) {
@ -58,10 +62,3 @@ async function checkRegistrationExist (finder: () => Promise<MRegistration>, res
res.locals.userRegistration = registration
return true
}
export {
checkRegistrationIdExist,
checkRegistrationEmailExistPermissive,
checkRegistrationHandlesDoNotAlreadyExist,
checkRegistrationExist
}

View file

@ -1,14 +1,16 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { getByEmailPermissive } from '@server/lib/user.js'
import { UserModel } from '@server/models/user/user.js'
import express from 'express'
import { body, param } from 'express-validator'
import { logger } from '../../../helpers/logger.js'
import { Redis } from '../../../lib/redis.js'
import { areValidationErrors, checkUserEmailExistPermissive, checkUserIdExist } from '../shared/index.js'
import { areValidationErrors, checkUserIdExist } from '../shared/index.js'
import { checkRegistrationEmailExistPermissive, checkRegistrationIdExist } from './shared/user-registrations.js'
export const usersAskSendVerifyEmailValidator = [
export const usersAskSendUserVerifyEmailValidator = [
body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
@ -18,18 +20,31 @@ export const usersAskSendVerifyEmailValidator = [
email: req.body.email
}, 'filter:api.email-verification.ask-send-verify-email.body')
const [ userExists, registrationExists ] = await Promise.all([
checkUserEmailExistPermissive(email, res, false),
checkRegistrationEmailExistPermissive(email, res, false)
const [ userEmail, userPendingEmail ] = await Promise.all([
UserModel.loadByEmailCaseInsensitive(email).then(users => getByEmailPermissive(users, email)),
UserModel.loadByPendingEmailCaseInsensitive(email).then(users => getByEmailPermissive(users, email))
])
if (!userExists && !registrationExists) {
logger.debug('User or registration with email %s does not exist (asking verify email).', email)
if (userEmail && userPendingEmail) {
logger.error(`Found 2 users with email ${email} to send verification link.`)
// Do not leak our emails
return res.status(HttpStatusCode.NO_CONTENT_204).end()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
if (res.locals.user?.pluginAuth) {
if (!userEmail && !userPendingEmail) {
logger.debug(`User with email ${email} does not exist (asking verify email).`)
// Do not leak our emails
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
res.locals.userEmail = userEmail
res.locals.userPendingEmail = userPendingEmail
const user = userEmail || userPendingEmail
if (user.pluginAuth) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'Cannot ask verification email of a user that uses a plugin authentication.'
@ -40,6 +55,29 @@ export const usersAskSendVerifyEmailValidator = [
}
]
export const usersAskSendRegistrationVerifyEmailValidator = [
body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const { email } = await Hooks.wrapObject({
email: req.body.email
}, 'filter:api.email-verification.ask-send-verify-email.body')
const registrationExists = await checkRegistrationEmailExistPermissive(email, res, false)
if (!registrationExists) {
logger.debug(`Registration with email ${email} does not exist (asking verify email).`)
// Do not leak our emails
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
return next()
}
]
export const usersVerifyEmailValidator = [
param('id')
.isInt().not().isEmpty().withMessage('Should have a valid id'),

View file

@ -37,7 +37,8 @@ import {
HasOne,
Is,
IsEmail,
IsUUID, Scopes,
IsUUID,
Scopes,
Table,
UpdatedAt
} from 'sequelize-typescript'
@ -285,7 +286,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
@ -675,6 +675,18 @@ export class UserModel extends SequelizeModel<UserModel> {
return UserModel.findAll(query)
}
static loadByPendingEmailCaseInsensitive (pendingEmail: string): Promise<MUserDefault[]> {
const query = {
where: where(
fn('LOWER', col('pendingEmail')),
'=',
pendingEmail.toLowerCase()
)
}
return UserModel.findAll(query)
}
static loadByUsernameOrEmailCaseInsensitive (usernameOrEmail: string): Promise<MUserDefault[]> {
const query = {
where: {

View file

@ -197,6 +197,9 @@ declare module 'express' {
user?: MUserDefault
userRegistration?: MRegistration
// For verification links
userEmail?: MUserDefault
userPendingEmail?: MUserDefault
server?: MServer