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'; export class LoginSessionManager { private readonly abuseProtectionConfigs = { passwordLogin: { maxAttempts: 5, windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }), }, emailLoginRequest: { maxAttempts: 5, windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), }, emailLoginToken: { maxAttempts: 5, windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }), }, passwordResetRequest: { maxAttempts: 5, windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), }, passwordResetCompletion: { maxAttempts: 5, windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }), }, }; // refs public receptionRef: Reception; public get db() { return this.receptionRef.db.smartdataDb; } public CEmailActionToken = plugins.smartdata.setDefaultManagerForDoc(this, EmailActionToken); public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession); public typedRouter = new plugins.typedrequest.TypedRouter(); constructor(receptionRefArg: Reception) { this.receptionRef = receptionRefArg; this.receptionRef.typedrouter.addTypedRouter(this.typedRouter); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'loginWithEmailOrUsernameAndPassword', async (requestData) => { const loginIdentifier = requestData.username; await this.receptionRef.abuseProtectionManager.consumeAttempt( 'passwordLogin', loginIdentifier, this.abuseProtectionConfigs.passwordLogin, 'Too many login attempts. Please wait before trying again.' ); let user = await this.receptionRef.userManager.CUser.getInstance({ data: { username: requestData.username, }, }); if (!user && requestData.username.includes('@')) { user = await this.receptionRef.userManager.CUser.getInstance({ data: { email: requestData.username, }, }); } if (user && (await this.receptionRef.userManager.CUser.verifyPassword( requestData.password, user.data.passwordHash ))) { if (this.receptionRef.userManager.CUser.shouldUpgradePasswordHash(user.data.passwordHash)) { user.data.passwordHash = await this.receptionRef.userManager.CUser.hashPassword( requestData.password ); await user.save(); } const loginSession = await LoginSession.createLoginSessionForUser(user); const refreshToken = await loginSession.getRefreshToken(); if (!refreshToken) { throw new plugins.typedrequest.TypedResponseError('Could not create login session'); } await this.receptionRef.abuseProtectionManager.clearAttempts( 'passwordLogin', loginIdentifier ); return { refreshToken, twoFaNeeded: false, }; } else { throw new plugins.typedrequest.TypedResponseError('User not found!'); } } ) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'loginWithEmail', async (requestDataArg) => { await this.receptionRef.abuseProtectionManager.consumeAttempt( 'emailLoginRequest', requestDataArg.email, this.abuseProtectionConfigs.emailLoginRequest, 'Too many magic link requests. Please wait before trying again.' ); logger.log('info', `loginWithEmail requested for: ${requestDataArg.email}`); const existingUser = await this.receptionRef.userManager.CUser.getInstance({ data: { email: requestDataArg.email, }, }); if (existingUser) { logger.log('info', `loginWithEmail found user: ${existingUser.data.email}`); const loginEmailToken = await this.createEmailActionToken( existingUser.data.email, 'emailLogin' ); 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}`); } return { status: 'ok', testOnlyToken: undefined, }; } ) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'loginWithEmailAfterEmailTokenAquired', async (requestArg) => { await this.receptionRef.abuseProtectionManager.consumeAttempt( 'emailLoginToken', requestArg.email, this.abuseProtectionConfigs.emailLoginToken, 'Too many magic link attempts. Please wait before trying again.' ); const tokenObject = await this.consumeEmailActionToken( requestArg.email, requestArg.token, 'emailLogin' ); if (tokenObject) { const user = await this.receptionRef.userManager.CUser.getInstance({ data: { email: requestArg.email, }, }); if (!user) { throw new plugins.typedrequest.TypedResponseError('User not found'); } const loginSession = await LoginSession.createLoginSessionForUser(user); const refreshToken = await loginSession.getRefreshToken(); if (!refreshToken) { throw new plugins.typedrequest.TypedResponseError('Could not create login session'); } await this.receptionRef.abuseProtectionManager.clearAttempts( 'emailLoginToken', requestArg.email ); return { refreshToken, }; } else { throw new plugins.typedrequest.TypedResponseError('Validation Token not found'); } } ) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler('logout', async (requestDataArg) => { const sessionLookup = await this.findLoginSessionByRefreshToken(requestDataArg.refreshToken); if (!sessionLookup || sessionLookup.validationStatus !== 'current') { throw new plugins.typedrequest.TypedResponseError('Invalid refresh token'); } await sessionLookup.loginSession.invalidate(); return {} }) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'exchangeRefreshTokenAndTransferToken', async (requestDataArg) => { switch (true) { case !!requestDataArg.refreshToken: { const sessionLookup = await this.findLoginSessionByRefreshToken( requestDataArg.refreshToken ); if (!sessionLookup || sessionLookup.validationStatus !== 'current') { if (sessionLookup?.validationStatus === 'reused') { await sessionLookup.loginSession.invalidate(); } throw new plugins.typedrequest.TypedResponseError('your refresh token is invalid'); } return { transferToken: await sessionLookup.loginSession.getTransferToken(), }; } case !!requestDataArg.transferToken: { const loginSession2 = await this.findLoginSessionByTransferToken( requestDataArg.transferToken ); if (!loginSession2) { throw new plugins.typedrequest.TypedResponseError( 'Your transfer token is not valid.' ); } const refreshToken = await loginSession2.getRefreshToken(); if (!refreshToken) { throw new plugins.typedrequest.TypedResponseError('Could not create login session'); } return { refreshToken, }; } default: throw new plugins.typedrequest.TypedResponseError('Invalid token exchange request'); } } ) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'resetPassword', async (requestDataArg) => { await this.receptionRef.abuseProtectionManager.consumeAttempt( 'passwordResetRequest', requestDataArg.email, this.abuseProtectionConfigs.passwordResetRequest, 'Too many password reset requests. Please wait before trying again.' ); const emailOfPasswordToReset = requestDataArg.email; const existingUser = await this.receptionRef.userManager.CUser.getInstance({ data: { email: emailOfPasswordToReset, }, }); if (existingUser) { const resetToken = await this.createEmailActionToken( existingUser.data.email, 'passwordReset' ); this.receptionRef.receptionMailer.sendPasswordResetMail( existingUser, 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. return { status: 'ok', }; } ) ); this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'setNewPassword', async (requestData) => { await this.receptionRef.abuseProtectionManager.consumeAttempt( 'passwordResetCompletion', requestData.email, this.abuseProtectionConfigs.passwordResetCompletion, 'Too many password change attempts. Please wait before trying again.' ); 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(); await this.receptionRef.abuseProtectionManager.clearAttempts( 'passwordResetCompletion', requestData.email ); return { status: 'ok', }; } ) ); /** * returns a device id by simply returning a uuid4 */ this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler('obtainDeviceId', async (reqData) => { reqData; return { deviceId: { id: plugins.smartunique.uuid4() } } }) ) this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler('attachDeviceId', async (reqData) => { // TODO: Blocked by proper JWT handling reqData.jwt; return { ok: false } }) ); // Get all sessions for the current user this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getUserSessions', async (requestArg) => { const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt); if (!jwt) { throw new plugins.typedrequest.TypedResponseError('Invalid JWT'); } const currentLoginSession = await jwt.getLoginSession(); // Get all sessions for this user const sessions = await this.CLoginSession.getInstances({ 'data.userId': jwt.data.userId, 'data.invalidated': false, }); return { sessions: sessions.map((session) => ({ id: session.id, deviceId: session.data.deviceId || 'unknown', deviceName: session.data.deviceInfo?.deviceName || 'Unknown Device', browser: session.data.deviceInfo?.browser || 'Unknown Browser', os: session.data.deviceInfo?.os || 'Unknown OS', ip: session.data.deviceInfo?.ip || 'Unknown', lastActive: session.data.lastActive || session.data.createdAt || Date.now(), createdAt: session.data.createdAt || Date.now(), isCurrent: session.id === currentLoginSession?.id, })), }; } ) ); // Revoke a specific session this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'revokeSession', async (requestArg) => { const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt); if (!jwt) { throw new plugins.typedrequest.TypedResponseError('Invalid JWT'); } // Get the session to revoke const sessionToRevoke = await this.CLoginSession.getInstance({ id: requestArg.sessionId, 'data.userId': jwt.data.userId, // Ensure user can only revoke their own sessions }); if (!sessionToRevoke) { throw new plugins.typedrequest.TypedResponseError('Session not found'); } const currentLoginSession = await jwt.getLoginSession(); // Don't allow revoking the current session via this method if (sessionToRevoke.id === currentLoginSession?.id) { throw new plugins.typedrequest.TypedResponseError( 'Cannot revoke current session. Use logout instead.' ); } await sessionToRevoke.invalidate(); // Log the activity await this.receptionRef.activityLogManager.logActivity( jwt.data.userId, 'session_revoked', `Revoked session on ${sessionToRevoke.data.deviceInfo?.deviceName || 'unknown device'}` ); return { success: true }; } ) ); } public async findLoginSessionByRefreshToken(refreshTokenArg: string): Promise<{ loginSession: LoginSession; validationStatus: TRefreshTokenValidationResult; } | null> { const directMatch = await this.CLoginSession.getLoginSessionByRefreshToken(refreshTokenArg); if (directMatch) { return { loginSession: directMatch, validationStatus: await directMatch.validateRefreshToken(refreshTokenArg), }; } const loginSessions = await this.CLoginSession.getInstances({}); for (const loginSession of loginSessions) { const validationStatus = await loginSession.validateRefreshToken(refreshTokenArg); if (validationStatus !== 'invalid') { return { loginSession, validationStatus, }; } } return null; } public async findLoginSessionByTransferToken(transferTokenArg: string) { const transferTokenHash = await LoginSession.hashSessionToken(transferTokenArg); const loginSession = await this.CLoginSession.getInstance({ 'data.transferTokenHash': transferTokenHash, }); if (!loginSession) { return null; } 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; } }