feat(reception): persist email action tokens and registration sessions for authentication and signup flows

This commit is contained in:
2026-04-20 08:27:35 +00:00
parent 1532c9704b
commit 28d30fe392
12 changed files with 477 additions and 199 deletions
+119 -123
View File
@@ -5,191 +5,187 @@ import { logger } from './logging.js';
import { User } from './classes.user.js';
/**
* a RegistrationSession is a in memory session for signing up
* a RegistrationSession persists a sign up flow across restarts
*/
export class RegistrationSession {
// ======
// STATIC
// ======
@plugins.smartdata.Manager()
export class RegistrationSession extends plugins.smartdata.SmartDataDbDoc<
RegistrationSession,
plugins.idpInterfaces.data.IRegistrationSession,
RegistrationSessionManager
> {
public static hashToken(tokenArg: string) {
return plugins.smarthash.sha256FromStringSync(tokenArg);
}
public static async createRegistrationSessionForEmail(
registrationSessionManageremailArg: RegistrationSessionManager,
emailArg: string
) {
const newRegistrationSession = new RegistrationSession(
registrationSessionManageremailArg,
emailArg
);
const emailValidationResult = await newRegistrationSession
.validateEMailAddress()
.catch((error) => {
throw new plugins.typedrequest.TypedResponseError(
'Error occured during email provider & dns validation'
);
});
const newRegistrationSession = new RegistrationSession();
newRegistrationSession.id = plugins.smartunique.shortId();
newRegistrationSession.data.emailAddress = emailArg;
newRegistrationSession.data.validUntil =
Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 });
newRegistrationSession.data.createdAt = Date.now();
const emailValidationResult = await newRegistrationSession.validateEMailAddress().catch(() => {
throw new plugins.typedrequest.TypedResponseError(
'Error occured during email provider & dns validation'
);
});
if (!emailValidationResult?.valid) {
newRegistrationSession.destroy();
throw new plugins.typedrequest.TypedResponseError(
'Email Address is not valid. Please use a correctly formated email address'
);
}
if (emailValidationResult.disposable) {
newRegistrationSession.destroy();
throw new plugins.typedrequest.TypedResponseError(
'Email is disposable. Please use a non disposable email address.'
);
}
console.log(
`${newRegistrationSession.emailAddress} is valid. Continuing registration process!`
);
await newRegistrationSession.sendTokenValidationEmail();
console.log(`Successfully sent email validation email`);
const validationToken = await newRegistrationSession.sendTokenValidationEmail();
newRegistrationSession.unhashedEmailToken = validationToken;
return newRegistrationSession;
}
// ========
// INSTANCE
// ========
public registrationSessionManagerRef: RegistrationSessionManager;
@plugins.smartdata.unI()
public id: string;
public emailAddress: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IRegistrationSession['data'] = {
emailAddress: '',
hashedEmailToken: '',
smsCodeHash: null,
smsvalidationCounter: 0,
status: 'announced',
validUntil: 0,
createdAt: 0,
collectedData: {
userData: {
username: null,
connectedOrgs: [],
email: null,
name: null,
status: null,
mobileNumber: null,
password: null,
passwordHash: null,
},
},
};
/**
* only used during testing
*/
public unhashedEmailToken?: string;
public hashedEmailToken: string;
private smsvalidationCounter = 0;
public smsCode: string;
/**
* the status of the registration. should progress in a linear fashion.
*/
public status: 'announced' | 'emailValidated' | 'mobileVerified' | 'registered' | 'failed' =
'announced';
public get emailAddress() {
return this.data.emailAddress;
}
public collectedData: {
userData: plugins.idpInterfaces.data.IUser['data'];
} = {
userData: {
username: null,
connectedOrgs: [],
email: null,
name: null,
status: null,
mobileNumber: null,
password: null,
passwordHash: null,
},
};
public get status() {
return this.data.status;
}
constructor(
registrationSessionManagerRefArg: RegistrationSessionManager,
emailAddressArg: string
) {
this.registrationSessionManagerRef = registrationSessionManagerRefArg;
this.emailAddress = emailAddressArg;
this.registrationSessionManagerRef.registrationSessions.addToMap(this.emailAddress, this);
public set status(statusArg: plugins.idpInterfaces.data.TRegistrationSessionStatus) {
this.data.status = statusArg;
}
// lets destroy this after 10 minutes,
// works in unrefed mode so not blocking node exiting.
plugins.smartdelay.delayFor(600000, null, true).then(() => this.destroy());
public get collectedData() {
return this.data.collectedData;
}
public isExpired() {
return this.data.validUntil < Date.now();
}
/**
* validates a token by comparing its hash against the stored hashed token
* @param tokenArg
*/
public validateEmailToken(tokenArg: string): boolean {
const result = this.hashedEmailToken === plugins.smarthash.sha256FromStringSync(tokenArg);
if (result && this.status === 'announced') {
this.status = 'emailValidated';
this.collectedData.userData.email = this.emailAddress;
public async validateEmailToken(tokenArg: string): Promise<boolean> {
if (this.isExpired()) {
await this.destroy();
return false;
}
if (!result && this.status === 'announced') {
this.status = 'failed';
const result = this.data.hashedEmailToken === RegistrationSession.hashToken(tokenArg);
if (result && this.data.status === 'announced') {
this.data.status = 'emailValidated';
this.data.collectedData.userData.email = this.data.emailAddress;
await this.save();
}
if (!result && this.data.status === 'announced') {
this.data.status = 'failed';
await this.save();
}
return result;
}
/** validates the sms code */
public validateSmsCode(smsCodeArg: string) {
this.smsvalidationCounter++;
const result = this.smsCode === smsCodeArg;
if (this.status === 'emailValidated' && result) {
this.status = 'mobileVerified';
public async validateSmsCode(smsCodeArg: string) {
this.data.smsvalidationCounter++;
const result = this.data.smsCodeHash === RegistrationSession.hashToken(smsCodeArg);
if (this.data.status === 'emailValidated' && result) {
this.data.status = 'mobileVerified';
await this.save();
return result;
} else {
if (this.smsvalidationCounter === 5) {
this.destroy();
throw new plugins.typedrequest.TypedResponseError(
'Registration cancelled due to repeated wrong verification code submission'
);
}
return false;
}
if (this.data.smsvalidationCounter >= 5) {
await this.destroy();
throw new plugins.typedrequest.TypedResponseError(
'Registration cancelled due to repeated wrong verification code submission'
);
}
await this.save();
return false;
}
/**
* validate the email address with provider and dns sanity checks
* @returns
*/
public async validateEMailAddress(): Promise<plugins.smartmail.IEmailValidationResult> {
console.log(`validating email ${this.emailAddress}`);
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.emailAddress);
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.data.emailAddress);
return result;
}
/**
* send the validation email
*/
public async sendTokenValidationEmail() {
const uuidToSend = plugins.smartunique.uuid4();
this.unhashedEmailToken = uuidToSend;
this.hashedEmailToken = plugins.smarthash.sha256FromStringSync(uuidToSend);
this.registrationSessionManagerRef.receptionRef.receptionMailer.sendRegistrationEmail(
this,
uuidToSend
);
logger.log('info', `sent a validation email with a verification code to ${this.emailAddress}`);
this.data.hashedEmailToken = RegistrationSession.hashToken(uuidToSend);
await this.save();
this.manager.receptionRef.receptionMailer.sendRegistrationEmail(this, uuidToSend);
logger.log('info', `sent a validation email with a verification code to ${this.data.emailAddress}`);
return uuidToSend;
}
/**
* validate the mobile number of someone
*/
public async sendValidationSms() {
this.smsCode =
await this.registrationSessionManagerRef.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation(
{
fromName: this.registrationSessionManagerRef.receptionRef.options.name,
toNumber: parseInt(this.collectedData.userData.mobileNumber),
}
);
const smsCode =
await this.manager.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation({
fromName: this.manager.receptionRef.options.name,
toNumber: parseInt(this.data.collectedData.userData.mobileNumber),
});
this.data.smsCodeHash = RegistrationSession.hashToken(smsCode);
await this.save();
return smsCode;
}
/**
* this method can be called when this registrationsession is validated
* and all data has been set
*/
public async manifestUserWithAccountData(): Promise<User> {
if (this.status !== 'mobileVerified') {
if (this.data.status !== 'mobileVerified') {
throw new plugins.typedrequest.TypedResponseError(
'You can only manifest user that have a validated email Address and Mobile Number'
);
}
if (!this.collectedData) {
if (!this.data.collectedData) {
throw new Error('You have to set the accountdata first');
}
const manifestedUser =
await this.registrationSessionManagerRef.receptionRef.userManager.CUser.createNewUserForUserData(
this.collectedData.userData
);
const manifestedUser = await this.manager.receptionRef.userManager.CUser.createNewUserForUserData(
this.data.collectedData.userData as plugins.idpInterfaces.data.IUser['data']
);
this.data.status = 'registered';
await this.save();
return manifestedUser;
}
/**
* destroys the registrationsession
*/
public destroy() {
this.registrationSessionManagerRef.registrationSessions.removeFromMap(this.emailAddress);
public async destroy() {
await this.delete();
}
}