From 28d30fe392771847c98f5414325a5746d69087d1 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 20 Apr 2026 08:27:35 +0000 Subject: [PATCH] feat(reception): persist email action tokens and registration sessions for authentication and signup flows --- changelog.md | 8 + test/test.auth.node.ts | 79 ++++++ ts/00_commitinfo_data.ts | 2 +- ts/reception/classes.emailactiontoken.ts | 49 ++++ ts/reception/classes.housekeeping.ts | 40 +++ ts/reception/classes.loginsessionmanager.ts | 149 +++++++---- ts/reception/classes.registrationsession.ts | 242 +++++++++--------- .../classes.registrationsessionmanager.ts | 60 +++-- ts_interfaces/data/index.ts | 2 + .../data/loint-reception.emailactiontoken.ts | 12 + .../loint-reception.registrationsession.ts | 31 +++ ts_web/00_commitinfo_data.ts | 2 +- 12 files changed, 477 insertions(+), 199 deletions(-) create mode 100644 ts/reception/classes.emailactiontoken.ts create mode 100644 ts_interfaces/data/loint-reception.emailactiontoken.ts create mode 100644 ts_interfaces/data/loint-reception.registrationsession.ts diff --git a/changelog.md b/changelog.md index 5530891..1eb08f0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-20 - 1.18.0 - feat(reception) +persist email action tokens and registration sessions for authentication and signup flows + +- add persisted email action tokens for email login and password reset with one-time consumption and expiry cleanup +- store registration sessions in the database so signup state, email validation, and SMS verification survive restarts +- enforce password changes through either a valid reset token or the current password +- add housekeeping jobs and tests for token/session expiry and state persistence + ## 2026-04-20 - 1.17.1 - fix(docs) refresh module readmes and add repository license file diff --git a/test/test.auth.node.ts b/test/test.auth.node.ts index 7ab66bb..a96d170 100644 --- a/test/test.auth.node.ts +++ b/test/test.auth.node.ts @@ -1,6 +1,8 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { EmailActionToken } from '../ts/reception/classes.emailactiontoken.js'; import { LoginSession } from '../ts/reception/classes.loginsession.js'; +import { RegistrationSession } from '../ts/reception/classes.registrationsession.js'; import { User } from '../ts/reception/classes.user.js'; import * as plugins from '../ts/plugins.js'; @@ -12,6 +14,42 @@ const createTestLoginSession = () => { return loginSession; }; +const createTestEmailActionToken = () => { + const emailActionToken = new EmailActionToken(); + emailActionToken.id = 'email-action-token'; + emailActionToken.data.email = 'user@example.com'; + emailActionToken.data.action = 'emailLogin'; + emailActionToken.data.validUntil = Date.now() + 60_000; + + let deleted = false; + (emailActionToken as EmailActionToken & { delete: () => Promise }).delete = async () => { + deleted = true; + }; + + return { + emailActionToken, + wasDeleted: () => deleted, + }; +}; + +const createTestRegistrationSession = () => { + const registrationSession = new RegistrationSession(); + registrationSession.id = 'registration-session'; + registrationSession.data.emailAddress = 'user@example.com'; + registrationSession.data.validUntil = Date.now() + 60_000; + + let deleted = false; + (registrationSession as RegistrationSession & { save: () => Promise }).save = async () => undefined; + (registrationSession as RegistrationSession & { delete: () => Promise }).delete = async () => { + deleted = true; + }; + + return { + registrationSession, + wasDeleted: () => deleted, + }; +}; + tap.test('hashes passwords with argon2 and verifies them', async () => { const passwordHash = await User.hashPassword('correct horse battery staple'); @@ -58,4 +96,45 @@ tap.test('persists transfer tokens as one-time hashes', async () => { expect(await loginSession.validateTransferToken(transferToken)).toBeFalse(); }); +tap.test('consumes email action tokens exactly once', async () => { + const { emailActionToken, wasDeleted } = createTestEmailActionToken(); + const plainToken = EmailActionToken.createOpaqueToken('emailLogin'); + emailActionToken.data.tokenHash = EmailActionToken.hashToken(plainToken); + + expect(await emailActionToken.consume(plainToken)).toBeTrue(); + expect(wasDeleted()).toBeTrue(); +}); + +tap.test('invalidates expired email action tokens', async () => { + const { emailActionToken, wasDeleted } = createTestEmailActionToken(); + emailActionToken.data.tokenHash = EmailActionToken.hashToken('expired-token'); + emailActionToken.data.validUntil = Date.now() - 1; + + expect(await emailActionToken.consume('expired-token')).toBeFalse(); + expect(wasDeleted()).toBeTrue(); +}); + +tap.test('persists registration token validation and sms verification state', async () => { + const { registrationSession } = createTestRegistrationSession(); + const emailToken = 'registration-token'; + registrationSession.data.hashedEmailToken = RegistrationSession.hashToken(emailToken); + + expect(await registrationSession.validateEmailToken(emailToken)).toBeTrue(); + expect(registrationSession.data.status).toEqual('emailValidated'); + expect(registrationSession.data.collectedData.userData.email).toEqual('user@example.com'); + + registrationSession.data.smsCodeHash = RegistrationSession.hashToken('123456'); + expect(await registrationSession.validateSmsCode('123456')).toBeTrue(); + expect(registrationSession.data.status).toEqual('mobileVerified'); +}); + +tap.test('removes expired registration sessions on token validation', async () => { + const { registrationSession, wasDeleted } = createTestRegistrationSession(); + registrationSession.data.hashedEmailToken = RegistrationSession.hashToken('expired-registration'); + registrationSession.data.validUntil = Date.now() - 1; + + expect(await registrationSession.validateEmailToken('expired-registration')).toBeFalse(); + expect(wasDeleted()).toBeTrue(); +}); + export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index dc7e0e3..e763823 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@idp.global/idp.global', - version: '1.17.1', + version: '1.18.0', description: 'An identity provider software managing user authentications, registrations, and sessions.' } diff --git a/ts/reception/classes.emailactiontoken.ts b/ts/reception/classes.emailactiontoken.ts new file mode 100644 index 0000000..f6b763e --- /dev/null +++ b/ts/reception/classes.emailactiontoken.ts @@ -0,0 +1,49 @@ +import * as plugins from '../plugins.js'; +import { LoginSessionManager } from './classes.loginsessionmanager.js'; + +@plugins.smartdata.Manager() +export class EmailActionToken extends plugins.smartdata.SmartDataDbDoc< + EmailActionToken, + plugins.idpInterfaces.data.IEmailActionToken, + LoginSessionManager +> { + public static hashToken(tokenArg: string) { + return plugins.smarthash.sha256FromStringSync(tokenArg); + } + + public static createOpaqueToken(actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction) { + return `${actionArg}_${plugins.crypto.randomBytes(32).toString('base64url')}`; + } + + @plugins.smartdata.unI() + public id: string; + + @plugins.smartdata.svDb() + public data: plugins.idpInterfaces.data.IEmailActionToken['data'] = { + email: '', + action: 'emailLogin', + tokenHash: '', + validUntil: 0, + createdAt: 0, + }; + + public isExpired() { + return this.data.validUntil < Date.now(); + } + + public matchesToken(tokenArg: string) { + return this.data.tokenHash === EmailActionToken.hashToken(tokenArg); + } + + public async consume(tokenArg: string) { + if (this.isExpired() || !this.matchesToken(tokenArg)) { + if (this.isExpired()) { + await this.delete(); + } + return false; + } + + await this.delete(); + return true; + } +} diff --git a/ts/reception/classes.housekeeping.ts b/ts/reception/classes.housekeeping.ts index 71fa774..12241b8 100644 --- a/ts/reception/classes.housekeeping.ts +++ b/ts/reception/classes.housekeeping.ts @@ -34,6 +34,46 @@ export class ReceptionHousekeeping { '2 * * * * *' ); + this.taskmanager.addAndScheduleTask( + new plugins.taskbuffer.Task({ + name: 'expiredEmailActionTokens', + taskFunction: async () => { + const expiredEmailActionTokens = + await this.receptionRef.loginSessionManager.CEmailActionToken.getInstances({ + data: { + validUntil: { + $lt: Date.now(), + } as any, + }, + }); + for (const emailActionToken of expiredEmailActionTokens) { + await emailActionToken.delete(); + } + }, + }), + '2 * * * * *' + ); + + this.taskmanager.addAndScheduleTask( + new plugins.taskbuffer.Task({ + name: 'expiredRegistrationSessions', + taskFunction: async () => { + const expiredRegistrationSessions = + await this.receptionRef.registrationSessionManager.CRegistrationSession.getInstances({ + data: { + validUntil: { + $lt: Date.now(), + } as any, + }, + }); + for (const registrationSession of expiredRegistrationSessions) { + await registrationSession.delete(); + } + }, + }), + '2 * * * * *' + ); + this.taskmanager.start(); logger.log('info', 'housekeeping started'); } diff --git a/ts/reception/classes.loginsessionmanager.ts b/ts/reception/classes.loginsessionmanager.ts index 9cb006d..c67a910 100644 --- a/ts/reception/classes.loginsessionmanager.ts +++ b/ts/reception/classes.loginsessionmanager.ts @@ -1,4 +1,5 @@ import * as plugins from '../plugins.js'; +import { EmailActionToken } from './classes.emailactiontoken.js'; import { LoginSession, type TRefreshTokenValidationResult } from './classes.loginsession.js'; import { Reception } from './classes.reception.js'; import { logger } from './logging.js'; @@ -10,18 +11,11 @@ export class LoginSessionManager { return this.receptionRef.db.smartdataDb; } + public CEmailActionToken = plugins.smartdata.setDefaultManagerForDoc(this, EmailActionToken); public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession); - public loginSessions = new plugins.lik.ObjectMap(); - public typedRouter = new plugins.typedrequest.TypedRouter(); - public emailTokenMap = new plugins.lik.ObjectMap<{ - email: string; - token: string; - action: 'emailLogin' | 'passwordReset'; - }>(); - constructor(receptionRefArg: Reception) { this.receptionRef = receptionRefArg; this.receptionRef.typedrouter.addTypedRouter(this.typedRouter); @@ -55,7 +49,6 @@ export class LoginSessionManager { } const loginSession = await LoginSession.createLoginSessionForUser(user); - this.loginSessions.add(loginSession); const refreshToken = await loginSession.getRefreshToken(); if (!refreshToken) { throw new plugins.typedrequest.TypedResponseError('Could not create login session'); @@ -84,33 +77,21 @@ export class LoginSessionManager { }); if (existingUser) { logger.log('info', `loginWithEmail found user: ${existingUser.data.email}`); - this.emailTokenMap.findOneAndRemoveSync( - (itemArg) => itemArg.email === existingUser.data.email + const loginEmailToken = await this.createEmailActionToken( + existingUser.data.email, + 'emailLogin' ); - const loginEmailToken = plugins.smartunique.uuid4(); - this.emailTokenMap.add({ - email: existingUser.data.email, - token: loginEmailToken, - action: 'emailLogin', - }); - // lets make sure its only valid for 10 minutes - plugins.smartdelay.delayFor(600000, null, true).then(() => { - this.emailTokenMap.findOneAndRemoveSync( - (itemArg) => itemArg.token === loginEmailToken - ); - }); this.receptionRef.receptionMailer.sendLoginWithEMailMail(existingUser, loginEmailToken); + return { + status: 'ok', + testOnlyToken: process.env.TEST_MODE ? loginEmailToken : undefined, + }; } else { logger.log('info', `loginWithEmail did not find user: ${requestDataArg.email}`); } - const testOnlyToken = - process.env.TEST_MODE && existingUser - ? this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email) - ?.token - : undefined; return { status: 'ok', - testOnlyToken, + testOnlyToken: undefined, }; } ) @@ -120,9 +101,11 @@ export class LoginSessionManager { new plugins.typedrequest.TypedHandler( 'loginWithEmailAfterEmailTokenAquired', async (requestArg) => { - const tokenObject = this.emailTokenMap.findSync((itemArg) => { - return itemArg.email === requestArg.email && itemArg.token === requestArg.token; - }); + const tokenObject = await this.consumeEmailActionToken( + requestArg.email, + requestArg.token, + 'emailLogin' + ); if (tokenObject) { const user = await this.receptionRef.userManager.CUser.getInstance({ data: { @@ -133,7 +116,6 @@ export class LoginSessionManager { throw new plugins.typedrequest.TypedResponseError('User not found'); } const loginSession = await LoginSession.createLoginSessionForUser(user); - this.loginSessions.add(loginSession); const refreshToken = await loginSession.getRefreshToken(); if (!refreshToken) { throw new plugins.typedrequest.TypedResponseError('Could not create login session'); @@ -213,23 +195,13 @@ export class LoginSessionManager { }, }); if (existingUser) { - this.emailTokenMap.findOneAndRemoveSync( - (itemArg) => itemArg.email === existingUser.data.email + const resetToken = await this.createEmailActionToken( + existingUser.data.email, + 'passwordReset' ); - this.emailTokenMap.add({ - email: existingUser.data.email, - token: plugins.smartunique.shortId(), - action: 'passwordReset', - }); - plugins.smartdelay.delayFor(600000, null, true).then(() => { - this.emailTokenMap.findOneAndRemoveSync( - (itemArg) => itemArg.email === existingUser.data.email - ); - }); this.receptionRef.receptionMailer.sendPasswordResetMail( existingUser, - this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email) - .token + resetToken ); } // note: we always return ok here, since we don't want to give any indication as to wether a user is already registered with us. @@ -244,6 +216,43 @@ export class LoginSessionManager { new plugins.typedrequest.TypedHandler( 'setNewPassword', async (requestData) => { + const user = await this.receptionRef.userManager.CUser.getInstance({ + data: { + email: requestData.email, + }, + }); + + if (!user) { + throw new plugins.typedrequest.TypedResponseError('User not found'); + } + + if (requestData.tokenArg) { + const tokenObject = await this.consumeEmailActionToken( + requestData.email, + requestData.tokenArg, + 'passwordReset' + ); + if (!tokenObject) { + throw new plugins.typedrequest.TypedResponseError('Password reset token invalid'); + } + } else if (requestData.oldPassword) { + const passwordOk = await this.receptionRef.userManager.CUser.verifyPassword( + requestData.oldPassword, + user.data.passwordHash + ); + if (!passwordOk) { + throw new plugins.typedrequest.TypedResponseError('Old password invalid'); + } + } else { + throw new plugins.typedrequest.TypedResponseError( + 'Either a reset token or the old password is required' + ); + } + + user.data.passwordHash = await this.receptionRef.userManager.CUser.hashPassword( + requestData.newPassword + ); + await user.save(); return { status: 'ok', }; @@ -393,4 +402,50 @@ export class LoginSessionManager { const isValid = await loginSession.validateTransferToken(transferTokenArg); return isValid ? loginSession : null; } + + public async createEmailActionToken( + emailArg: string, + actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction + ) { + const existingTokens = await this.CEmailActionToken.getInstances({ + 'data.email': emailArg, + 'data.action': actionArg, + }); + + for (const existingToken of existingTokens) { + await existingToken.delete(); + } + + const plainToken = EmailActionToken.createOpaqueToken(actionArg); + const emailActionToken = new EmailActionToken(); + emailActionToken.id = plugins.smartunique.shortId(); + emailActionToken.data = { + email: emailArg, + action: actionArg, + tokenHash: EmailActionToken.hashToken(plainToken), + validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 }), + createdAt: Date.now(), + }; + await emailActionToken.save(); + return plainToken; + } + + public async consumeEmailActionToken( + emailArg: string, + tokenArg: string, + actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction + ) { + const emailActionToken = await this.CEmailActionToken.getInstance({ + 'data.email': emailArg, + 'data.action': actionArg, + 'data.tokenHash': EmailActionToken.hashToken(tokenArg), + }); + + if (!emailActionToken) { + return null; + } + + const consumed = await emailActionToken.consume(tokenArg); + return consumed ? emailActionToken : null; + } } diff --git a/ts/reception/classes.registrationsession.ts b/ts/reception/classes.registrationsession.ts index 6b0f289..b3037e9 100644 --- a/ts/reception/classes.registrationsession.ts +++ b/ts/reception/classes.registrationsession.ts @@ -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 { + 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 { - 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 { - 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(); } } diff --git a/ts/reception/classes.registrationsessionmanager.ts b/ts/reception/classes.registrationsessionmanager.ts index 4d36b9b..a24cb71 100644 --- a/ts/reception/classes.registrationsessionmanager.ts +++ b/ts/reception/classes.registrationsessionmanager.ts @@ -5,10 +5,14 @@ import { logger } from './logging.js'; export class RegistrationSessionManager { public receptionRef: Reception; - - public registrationSessions = new plugins.lik.FastMap(); public typedRouter = new plugins.typedrequest.TypedRouter(); + public get db() { + return this.receptionRef.db.smartdataDb; + } + + public CRegistrationSession = plugins.smartdata.setDefaultManagerForDoc(this, RegistrationSession); + constructor(receptionRefArg: Reception) { this.receptionRef = receptionRefArg; this.receptionRef.typedrouter.addTypedRouter(this.typedRouter); @@ -29,17 +33,16 @@ export class RegistrationSessionManager { `We sent you an Email with more information.` ); } - // check for exiting SignupSession - const existingSession = this.registrationSessions.getByKey(requestData.email); - if (existingSession) { + + const existingSessions = await this.CRegistrationSession.getInstances({ + 'data.emailAddress': requestData.email, + }); + for (const existingSession of existingSessions) { logger.log('warn', `destroyed old signupSession for ${requestData.email}`); - existingSession.destroy(); + await existingSession.destroy(); } - // lets check the email before we create a signup session - const newSignupSession = await RegistrationSession.createRegistrationSessionForEmail( - this, requestData.email ).catch((e: plugins.typedrequest.TypedResponseError) => { console.log(e.errorText); @@ -63,10 +66,7 @@ export class RegistrationSessionManager { new plugins.typedrequest.TypedHandler( 'afterRegistrationEmailClicked', async (requestData) => { - console.log(requestData); - const signupSession = await this.registrationSessions.find(async (itemArg) => - itemArg.validateEmailToken(requestData.token) - ); + const signupSession = await this.findRegistrationSessionByToken(requestData.token); if (signupSession) { return { email: signupSession.emailAddress, @@ -86,9 +86,7 @@ export class RegistrationSessionManager { new plugins.typedrequest.TypedHandler( 'setDataForRegistration', async (requestData) => { - const registrationSession = await this.registrationSessions.find(async (itemArg) => - itemArg.validateEmailToken(requestData.token) - ); + const registrationSession = await this.findRegistrationSessionByToken(requestData.token); if (!registrationSession) { throw new plugins.typedrequest.TypedResponseError( 'could not find a matching signupsession' @@ -114,9 +112,7 @@ export class RegistrationSessionManager { new plugins.typedrequest.TypedHandler( 'mobileVerificationForRegistration', async (requestData) => { - const registrationSession = await this.registrationSessions.find(async (itemArg) => - itemArg.validateEmailToken(requestData.token) - ); + const registrationSession = await this.findRegistrationSessionByToken(requestData.token); if (!registrationSession) { throw new plugins.typedrequest.TypedResponseError( 'could not find a matching signupsession' @@ -131,17 +127,16 @@ export class RegistrationSessionManager { } if (requestData.mobileNumber) { - registrationSession.status = 'emailValidated'; registrationSession.collectedData.userData.mobileNumber = requestData.mobileNumber; - await registrationSession.sendValidationSms(); + const smsCode = await registrationSession.sendValidationSms(); return { messageSent: true, - testOnlySmsCode: process.env.TEST_MODE ? registrationSession.smsCode : null, + testOnlySmsCode: process.env.TEST_MODE ? smsCode : null, }; } if (requestData.verificationCode) { - const validationResult = registrationSession.validateSmsCode( + const validationResult = await registrationSession.validateSmsCode( requestData.verificationCode ); return { @@ -160,9 +155,7 @@ export class RegistrationSessionManager { new plugins.typedrequest.TypedHandler( 'finishRegistration', async (requestData) => { - const registrationSession = await this.registrationSessions.find(async (itemArg) => - itemArg.validateEmailToken(requestData.token) - ); + const registrationSession = await this.findRegistrationSessionByToken(requestData.token); if (!registrationSession) { throw new plugins.typedrequest.TypedResponseError( 'could not find a matching signupsession' @@ -170,7 +163,7 @@ export class RegistrationSessionManager { } const resultingUser = await registrationSession.manifestUserWithAccountData(); - registrationSession.destroy(); + await registrationSession.destroy(); this.receptionRef.receptionMailer.sendWelcomeEMail(resultingUser); return { accountData: { @@ -187,4 +180,17 @@ export class RegistrationSessionManager { ) ); } + + public async findRegistrationSessionByToken(tokenArg: string) { + const registrationSession = await this.CRegistrationSession.getInstance({ + 'data.hashedEmailToken': RegistrationSession.hashToken(tokenArg), + }); + + if (!registrationSession) { + return null; + } + + const isValid = await registrationSession.validateEmailToken(tokenArg); + return isValid ? registrationSession : null; + } } diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index 6d3217e..d255e21 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -1,5 +1,6 @@ export * from './loint-reception.activity.js'; export * from './loint-reception.app.js'; +export * from './loint-reception.emailactiontoken.js'; export * from './loint-reception.oidc.js'; export * from './loint-reception.appconnection.js'; export * from './loint-reception.billingplan.js'; @@ -8,6 +9,7 @@ export * from './loint-reception.jwt.js'; export * from './loint-reception.loginsession.js'; export * from './loint-reception.organization.js'; export * from './loint-reception.paddlecheckoutdata.js'; +export * from './loint-reception.registrationsession.js'; export * from './loint-reception.role.js'; export * from './loint-reception.user.js'; export * from './loint-reception.userinvitation.js'; diff --git a/ts_interfaces/data/loint-reception.emailactiontoken.ts b/ts_interfaces/data/loint-reception.emailactiontoken.ts new file mode 100644 index 0000000..b904610 --- /dev/null +++ b/ts_interfaces/data/loint-reception.emailactiontoken.ts @@ -0,0 +1,12 @@ +export type TEmailActionTokenAction = 'emailLogin' | 'passwordReset'; + +export interface IEmailActionToken { + id: string; + data: { + email: string; + action: TEmailActionTokenAction; + tokenHash: string; + validUntil: number; + createdAt: number; + }; +} diff --git a/ts_interfaces/data/loint-reception.registrationsession.ts b/ts_interfaces/data/loint-reception.registrationsession.ts new file mode 100644 index 0000000..edd4e4e --- /dev/null +++ b/ts_interfaces/data/loint-reception.registrationsession.ts @@ -0,0 +1,31 @@ +export type TRegistrationSessionStatus = + | 'announced' + | 'emailValidated' + | 'mobileVerified' + | 'registered' + | 'failed'; + +export interface IRegistrationSession { + id: string; + data: { + emailAddress: string; + hashedEmailToken: string; + smsCodeHash?: string | null; + smsvalidationCounter: number; + status: TRegistrationSessionStatus; + validUntil: number; + createdAt: number; + collectedData: { + userData: { + username?: string | null; + connectedOrgs: string[]; + email?: string | null; + name?: string | null; + status?: 'new' | 'active' | 'deleted' | 'suspended' | null; + mobileNumber?: string | null; + password?: string | null; + passwordHash?: string | null; + }; + }; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index dc7e0e3..e763823 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@idp.global/idp.global', - version: '1.17.1', + version: '1.18.0', description: 'An identity provider software managing user authentications, registrations, and sessions.' }