Compare commits

..

2 Commits

Author SHA1 Message Date
jkunz fe9da65437 v1.18.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-20 08:27:35 +00:00
jkunz 28d30fe392 feat(reception): persist email action tokens and registration sessions for authentication and signup flows 2026-04-20 08:27:35 +00:00
13 changed files with 478 additions and 200 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # 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) ## 2026-04-20 - 1.17.1 - fix(docs)
refresh module readmes and add repository license file refresh module readmes and add repository license file
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@idp.global/idp.global", "name": "@idp.global/idp.global",
"version": "1.17.1", "version": "1.18.0",
"description": "An identity provider software managing user authentications, registrations, and sessions.", "description": "An identity provider software managing user authentications, registrations, and sessions.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts", "typings": "dist_ts/index.d.ts",
+79
View File
@@ -1,6 +1,8 @@
import { tap, expect } from '@git.zone/tstest/tapbundle'; 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 { LoginSession } from '../ts/reception/classes.loginsession.js';
import { RegistrationSession } from '../ts/reception/classes.registrationsession.js';
import { User } from '../ts/reception/classes.user.js'; import { User } from '../ts/reception/classes.user.js';
import * as plugins from '../ts/plugins.js'; import * as plugins from '../ts/plugins.js';
@@ -12,6 +14,42 @@ const createTestLoginSession = () => {
return loginSession; 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<void> }).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<void> }).save = async () => undefined;
(registrationSession as RegistrationSession & { delete: () => Promise<void> }).delete = async () => {
deleted = true;
};
return {
registrationSession,
wasDeleted: () => deleted,
};
};
tap.test('hashes passwords with argon2 and verifies them', async () => { tap.test('hashes passwords with argon2 and verifies them', async () => {
const passwordHash = await User.hashPassword('correct horse battery staple'); 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(); 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(); export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@idp.global/idp.global', name: '@idp.global/idp.global',
version: '1.17.1', version: '1.18.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.' description: 'An identity provider software managing user authentications, registrations, and sessions.'
} }
+49
View File
@@ -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;
}
}
+40
View File
@@ -34,6 +34,46 @@ export class ReceptionHousekeeping {
'2 * * * * *' '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(); this.taskmanager.start();
logger.log('info', 'housekeeping started'); logger.log('info', 'housekeeping started');
} }
+102 -47
View File
@@ -1,4 +1,5 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { EmailActionToken } from './classes.emailactiontoken.js';
import { LoginSession, type TRefreshTokenValidationResult } from './classes.loginsession.js'; import { LoginSession, type TRefreshTokenValidationResult } from './classes.loginsession.js';
import { Reception } from './classes.reception.js'; import { Reception } from './classes.reception.js';
import { logger } from './logging.js'; import { logger } from './logging.js';
@@ -10,18 +11,11 @@ export class LoginSessionManager {
return this.receptionRef.db.smartdataDb; return this.receptionRef.db.smartdataDb;
} }
public CEmailActionToken = plugins.smartdata.setDefaultManagerForDoc(this, EmailActionToken);
public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession); public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession);
public loginSessions = new plugins.lik.ObjectMap<LoginSession>();
public typedRouter = new plugins.typedrequest.TypedRouter(); public typedRouter = new plugins.typedrequest.TypedRouter();
public emailTokenMap = new plugins.lik.ObjectMap<{
email: string;
token: string;
action: 'emailLogin' | 'passwordReset';
}>();
constructor(receptionRefArg: Reception) { constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg; this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter); this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
@@ -55,7 +49,6 @@ export class LoginSessionManager {
} }
const loginSession = await LoginSession.createLoginSessionForUser(user); const loginSession = await LoginSession.createLoginSessionForUser(user);
this.loginSessions.add(loginSession);
const refreshToken = await loginSession.getRefreshToken(); const refreshToken = await loginSession.getRefreshToken();
if (!refreshToken) { if (!refreshToken) {
throw new plugins.typedrequest.TypedResponseError('Could not create login session'); throw new plugins.typedrequest.TypedResponseError('Could not create login session');
@@ -84,33 +77,21 @@ export class LoginSessionManager {
}); });
if (existingUser) { if (existingUser) {
logger.log('info', `loginWithEmail found user: ${existingUser.data.email}`); logger.log('info', `loginWithEmail found user: ${existingUser.data.email}`);
this.emailTokenMap.findOneAndRemoveSync( const loginEmailToken = await this.createEmailActionToken(
(itemArg) => itemArg.email === existingUser.data.email 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); this.receptionRef.receptionMailer.sendLoginWithEMailMail(existingUser, loginEmailToken);
return {
status: 'ok',
testOnlyToken: process.env.TEST_MODE ? loginEmailToken : undefined,
};
} else { } else {
logger.log('info', `loginWithEmail did not find user: ${requestDataArg.email}`); 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 { return {
status: 'ok', status: 'ok',
testOnlyToken, testOnlyToken: undefined,
}; };
} }
) )
@@ -120,9 +101,11 @@ export class LoginSessionManager {
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>( new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
'loginWithEmailAfterEmailTokenAquired', 'loginWithEmailAfterEmailTokenAquired',
async (requestArg) => { async (requestArg) => {
const tokenObject = this.emailTokenMap.findSync((itemArg) => { const tokenObject = await this.consumeEmailActionToken(
return itemArg.email === requestArg.email && itemArg.token === requestArg.token; requestArg.email,
}); requestArg.token,
'emailLogin'
);
if (tokenObject) { if (tokenObject) {
const user = await this.receptionRef.userManager.CUser.getInstance({ const user = await this.receptionRef.userManager.CUser.getInstance({
data: { data: {
@@ -133,7 +116,6 @@ export class LoginSessionManager {
throw new plugins.typedrequest.TypedResponseError('User not found'); throw new plugins.typedrequest.TypedResponseError('User not found');
} }
const loginSession = await LoginSession.createLoginSessionForUser(user); const loginSession = await LoginSession.createLoginSessionForUser(user);
this.loginSessions.add(loginSession);
const refreshToken = await loginSession.getRefreshToken(); const refreshToken = await loginSession.getRefreshToken();
if (!refreshToken) { if (!refreshToken) {
throw new plugins.typedrequest.TypedResponseError('Could not create login session'); throw new plugins.typedrequest.TypedResponseError('Could not create login session');
@@ -213,23 +195,13 @@ export class LoginSessionManager {
}, },
}); });
if (existingUser) { if (existingUser) {
this.emailTokenMap.findOneAndRemoveSync( const resetToken = await this.createEmailActionToken(
(itemArg) => itemArg.email === existingUser.data.email 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( this.receptionRef.receptionMailer.sendPasswordResetMail(
existingUser, existingUser,
this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email) resetToken
.token
); );
} }
// 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. // 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<plugins.idpInterfaces.request.IReq_SetNewPassword>( new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetNewPassword>(
'setNewPassword', 'setNewPassword',
async (requestData) => { 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 { return {
status: 'ok', status: 'ok',
}; };
@@ -393,4 +402,50 @@ export class LoginSessionManager {
const isValid = await loginSession.validateTransferToken(transferTokenArg); const isValid = await loginSession.validateTransferToken(transferTokenArg);
return isValid ? loginSession : null; 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;
}
} }
+119 -123
View File
@@ -5,191 +5,187 @@ import { logger } from './logging.js';
import { User } from './classes.user.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 { @plugins.smartdata.Manager()
// ====== export class RegistrationSession extends plugins.smartdata.SmartDataDbDoc<
// STATIC RegistrationSession,
// ====== plugins.idpInterfaces.data.IRegistrationSession,
RegistrationSessionManager
> {
public static hashToken(tokenArg: string) {
return plugins.smarthash.sha256FromStringSync(tokenArg);
}
public static async createRegistrationSessionForEmail( public static async createRegistrationSessionForEmail(
registrationSessionManageremailArg: RegistrationSessionManager,
emailArg: string emailArg: string
) { ) {
const newRegistrationSession = new RegistrationSession( const newRegistrationSession = new RegistrationSession();
registrationSessionManageremailArg, newRegistrationSession.id = plugins.smartunique.shortId();
emailArg newRegistrationSession.data.emailAddress = emailArg;
); newRegistrationSession.data.validUntil =
const emailValidationResult = await newRegistrationSession Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 });
.validateEMailAddress() newRegistrationSession.data.createdAt = Date.now();
.catch((error) => {
throw new plugins.typedrequest.TypedResponseError( const emailValidationResult = await newRegistrationSession.validateEMailAddress().catch(() => {
'Error occured during email provider & dns validation' throw new plugins.typedrequest.TypedResponseError(
); 'Error occured during email provider & dns validation'
}); );
});
if (!emailValidationResult?.valid) { if (!emailValidationResult?.valid) {
newRegistrationSession.destroy();
throw new plugins.typedrequest.TypedResponseError( throw new plugins.typedrequest.TypedResponseError(
'Email Address is not valid. Please use a correctly formated email address' 'Email Address is not valid. Please use a correctly formated email address'
); );
} }
if (emailValidationResult.disposable) { if (emailValidationResult.disposable) {
newRegistrationSession.destroy();
throw new plugins.typedrequest.TypedResponseError( throw new plugins.typedrequest.TypedResponseError(
'Email is disposable. Please use a non disposable email address.' 'Email is disposable. Please use a non disposable email address.'
); );
} }
console.log(
`${newRegistrationSession.emailAddress} is valid. Continuing registration process!` const validationToken = await newRegistrationSession.sendTokenValidationEmail();
); newRegistrationSession.unhashedEmailToken = validationToken;
await newRegistrationSession.sendTokenValidationEmail();
console.log(`Successfully sent email validation email`);
return newRegistrationSession; return newRegistrationSession;
} }
// ======== @plugins.smartdata.unI()
// INSTANCE public id: string;
// ========
public registrationSessionManagerRef: RegistrationSessionManager;
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 * only used during testing
*/ */
public unhashedEmailToken?: string; public unhashedEmailToken?: string;
public hashedEmailToken: string;
private smsvalidationCounter = 0;
public smsCode: string;
/** public get emailAddress() {
* the status of the registration. should progress in a linear fashion. return this.data.emailAddress;
*/ }
public status: 'announced' | 'emailValidated' | 'mobileVerified' | 'registered' | 'failed' =
'announced';
public collectedData: { public get status() {
userData: plugins.idpInterfaces.data.IUser['data']; return this.data.status;
} = { }
userData: {
username: null,
connectedOrgs: [],
email: null,
name: null,
status: null,
mobileNumber: null,
password: null,
passwordHash: null,
},
};
constructor( public set status(statusArg: plugins.idpInterfaces.data.TRegistrationSessionStatus) {
registrationSessionManagerRefArg: RegistrationSessionManager, this.data.status = statusArg;
emailAddressArg: string }
) {
this.registrationSessionManagerRef = registrationSessionManagerRefArg;
this.emailAddress = emailAddressArg;
this.registrationSessionManagerRef.registrationSessions.addToMap(this.emailAddress, this);
// lets destroy this after 10 minutes, public get collectedData() {
// works in unrefed mode so not blocking node exiting. return this.data.collectedData;
plugins.smartdelay.delayFor(600000, null, true).then(() => this.destroy()); }
public isExpired() {
return this.data.validUntil < Date.now();
} }
/** /**
* validates a token by comparing its hash against the stored hashed token * validates a token by comparing its hash against the stored hashed token
* @param tokenArg
*/ */
public validateEmailToken(tokenArg: string): boolean { public async validateEmailToken(tokenArg: string): Promise<boolean> {
const result = this.hashedEmailToken === plugins.smarthash.sha256FromStringSync(tokenArg); if (this.isExpired()) {
if (result && this.status === 'announced') { await this.destroy();
this.status = 'emailValidated'; return false;
this.collectedData.userData.email = this.emailAddress;
} }
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; return result;
} }
/** validates the sms code */ /** validates the sms code */
public validateSmsCode(smsCodeArg: string) { public async validateSmsCode(smsCodeArg: string) {
this.smsvalidationCounter++; this.data.smsvalidationCounter++;
const result = this.smsCode === smsCodeArg; const result = this.data.smsCodeHash === RegistrationSession.hashToken(smsCodeArg);
if (this.status === 'emailValidated' && result) { if (this.data.status === 'emailValidated' && result) {
this.status = 'mobileVerified'; this.data.status = 'mobileVerified';
await this.save();
return result; 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> { public async validateEMailAddress(): Promise<plugins.smartmail.IEmailValidationResult> {
console.log(`validating email ${this.emailAddress}`); const result = await new plugins.smartmail.EmailAddressValidator().validate(this.data.emailAddress);
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.emailAddress);
return result; return result;
} }
/**
* send the validation email
*/
public async sendTokenValidationEmail() { public async sendTokenValidationEmail() {
const uuidToSend = plugins.smartunique.uuid4(); const uuidToSend = plugins.smartunique.uuid4();
this.unhashedEmailToken = uuidToSend; this.data.hashedEmailToken = RegistrationSession.hashToken(uuidToSend);
this.hashedEmailToken = plugins.smarthash.sha256FromStringSync(uuidToSend); await this.save();
this.registrationSessionManagerRef.receptionRef.receptionMailer.sendRegistrationEmail( this.manager.receptionRef.receptionMailer.sendRegistrationEmail(this, uuidToSend);
this, logger.log('info', `sent a validation email with a verification code to ${this.data.emailAddress}`);
uuidToSend return uuidToSend;
);
logger.log('info', `sent a validation email with a verification code to ${this.emailAddress}`);
} }
/**
* validate the mobile number of someone
*/
public async sendValidationSms() { public async sendValidationSms() {
this.smsCode = const smsCode =
await this.registrationSessionManagerRef.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation( await this.manager.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation({
{ fromName: this.manager.receptionRef.options.name,
fromName: this.registrationSessionManagerRef.receptionRef.options.name, toNumber: parseInt(this.data.collectedData.userData.mobileNumber),
toNumber: parseInt(this.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> { public async manifestUserWithAccountData(): Promise<User> {
if (this.status !== 'mobileVerified') { if (this.data.status !== 'mobileVerified') {
throw new plugins.typedrequest.TypedResponseError( throw new plugins.typedrequest.TypedResponseError(
'You can only manifest user that have a validated email Address and Mobile Number' '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'); throw new Error('You have to set the accountdata first');
} }
const manifestedUser = const manifestedUser = await this.manager.receptionRef.userManager.CUser.createNewUserForUserData(
await this.registrationSessionManagerRef.receptionRef.userManager.CUser.createNewUserForUserData( this.data.collectedData.userData as plugins.idpInterfaces.data.IUser['data']
this.collectedData.userData );
); this.data.status = 'registered';
await this.save();
return manifestedUser; return manifestedUser;
} }
/** public async destroy() {
* destroys the registrationsession await this.delete();
*/
public destroy() {
this.registrationSessionManagerRef.registrationSessions.removeFromMap(this.emailAddress);
} }
} }
@@ -5,10 +5,14 @@ import { logger } from './logging.js';
export class RegistrationSessionManager { export class RegistrationSessionManager {
public receptionRef: Reception; public receptionRef: Reception;
public registrationSessions = new plugins.lik.FastMap<RegistrationSession>();
public typedRouter = new plugins.typedrequest.TypedRouter(); public typedRouter = new plugins.typedrequest.TypedRouter();
public get db() {
return this.receptionRef.db.smartdataDb;
}
public CRegistrationSession = plugins.smartdata.setDefaultManagerForDoc(this, RegistrationSession);
constructor(receptionRefArg: Reception) { constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg; this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter); this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
@@ -29,17 +33,16 @@ export class RegistrationSessionManager {
`We sent you an Email with more information.` `We sent you an Email with more information.`
); );
} }
// check for exiting SignupSession
const existingSession = this.registrationSessions.getByKey(requestData.email); const existingSessions = await this.CRegistrationSession.getInstances({
if (existingSession) { 'data.emailAddress': requestData.email,
});
for (const existingSession of existingSessions) {
logger.log('warn', `destroyed old signupSession for ${requestData.email}`); 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( const newSignupSession = await RegistrationSession.createRegistrationSessionForEmail(
this,
requestData.email requestData.email
).catch((e: plugins.typedrequest.TypedResponseError) => { ).catch((e: plugins.typedrequest.TypedResponseError) => {
console.log(e.errorText); console.log(e.errorText);
@@ -63,10 +66,7 @@ export class RegistrationSessionManager {
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>( new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
'afterRegistrationEmailClicked', 'afterRegistrationEmailClicked',
async (requestData) => { async (requestData) => {
console.log(requestData); const signupSession = await this.findRegistrationSessionByToken(requestData.token);
const signupSession = await this.registrationSessions.find(async (itemArg) =>
itemArg.validateEmailToken(requestData.token)
);
if (signupSession) { if (signupSession) {
return { return {
email: signupSession.emailAddress, email: signupSession.emailAddress,
@@ -86,9 +86,7 @@ export class RegistrationSessionManager {
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetDataForRegistration>( new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
'setDataForRegistration', 'setDataForRegistration',
async (requestData) => { async (requestData) => {
const registrationSession = await this.registrationSessions.find(async (itemArg) => const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
itemArg.validateEmailToken(requestData.token)
);
if (!registrationSession) { if (!registrationSession) {
throw new plugins.typedrequest.TypedResponseError( throw new plugins.typedrequest.TypedResponseError(
'could not find a matching signupsession' 'could not find a matching signupsession'
@@ -114,9 +112,7 @@ export class RegistrationSessionManager {
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>( new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
'mobileVerificationForRegistration', 'mobileVerificationForRegistration',
async (requestData) => { async (requestData) => {
const registrationSession = await this.registrationSessions.find(async (itemArg) => const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
itemArg.validateEmailToken(requestData.token)
);
if (!registrationSession) { if (!registrationSession) {
throw new plugins.typedrequest.TypedResponseError( throw new plugins.typedrequest.TypedResponseError(
'could not find a matching signupsession' 'could not find a matching signupsession'
@@ -131,17 +127,16 @@ export class RegistrationSessionManager {
} }
if (requestData.mobileNumber) { if (requestData.mobileNumber) {
registrationSession.status = 'emailValidated';
registrationSession.collectedData.userData.mobileNumber = requestData.mobileNumber; registrationSession.collectedData.userData.mobileNumber = requestData.mobileNumber;
await registrationSession.sendValidationSms(); const smsCode = await registrationSession.sendValidationSms();
return { return {
messageSent: true, messageSent: true,
testOnlySmsCode: process.env.TEST_MODE ? registrationSession.smsCode : null, testOnlySmsCode: process.env.TEST_MODE ? smsCode : null,
}; };
} }
if (requestData.verificationCode) { if (requestData.verificationCode) {
const validationResult = registrationSession.validateSmsCode( const validationResult = await registrationSession.validateSmsCode(
requestData.verificationCode requestData.verificationCode
); );
return { return {
@@ -160,9 +155,7 @@ export class RegistrationSessionManager {
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_FinishRegistration>( new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_FinishRegistration>(
'finishRegistration', 'finishRegistration',
async (requestData) => { async (requestData) => {
const registrationSession = await this.registrationSessions.find(async (itemArg) => const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
itemArg.validateEmailToken(requestData.token)
);
if (!registrationSession) { if (!registrationSession) {
throw new plugins.typedrequest.TypedResponseError( throw new plugins.typedrequest.TypedResponseError(
'could not find a matching signupsession' 'could not find a matching signupsession'
@@ -170,7 +163,7 @@ export class RegistrationSessionManager {
} }
const resultingUser = await registrationSession.manifestUserWithAccountData(); const resultingUser = await registrationSession.manifestUserWithAccountData();
registrationSession.destroy(); await registrationSession.destroy();
this.receptionRef.receptionMailer.sendWelcomeEMail(resultingUser); this.receptionRef.receptionMailer.sendWelcomeEMail(resultingUser);
return { return {
accountData: { 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;
}
} }
+2
View File
@@ -1,5 +1,6 @@
export * from './loint-reception.activity.js'; export * from './loint-reception.activity.js';
export * from './loint-reception.app.js'; export * from './loint-reception.app.js';
export * from './loint-reception.emailactiontoken.js';
export * from './loint-reception.oidc.js'; export * from './loint-reception.oidc.js';
export * from './loint-reception.appconnection.js'; export * from './loint-reception.appconnection.js';
export * from './loint-reception.billingplan.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.loginsession.js';
export * from './loint-reception.organization.js'; export * from './loint-reception.organization.js';
export * from './loint-reception.paddlecheckoutdata.js'; export * from './loint-reception.paddlecheckoutdata.js';
export * from './loint-reception.registrationsession.js';
export * from './loint-reception.role.js'; export * from './loint-reception.role.js';
export * from './loint-reception.user.js'; export * from './loint-reception.user.js';
export * from './loint-reception.userinvitation.js'; export * from './loint-reception.userinvitation.js';
@@ -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;
};
}
@@ -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;
};
};
};
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@idp.global/idp.global', name: '@idp.global/idp.global',
version: '1.17.1', version: '1.18.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.' description: 'An identity provider software managing user authentications, registrations, and sessions.'
} }