Files
app/ts/reception/classes.loginsessionmanager.ts
T

397 lines
14 KiB
TypeScript

import * as plugins from '../plugins.js';
import { LoginSession, type TRefreshTokenValidationResult } from './classes.loginsession.js';
import { Reception } from './classes.reception.js';
import { logger } from './logging.js';
export class LoginSessionManager {
// refs
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession);
public loginSessions = new plugins.lik.ObjectMap<LoginSession>();
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);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
'loginWithEmailOrUsernameAndPassword',
async (requestData) => {
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);
this.loginSessions.add(loginSession);
const refreshToken = await loginSession.getRefreshToken();
if (!refreshToken) {
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
}
return {
refreshToken,
twoFaNeeded: false,
};
} else {
throw new plugins.typedrequest.TypedResponseError('User not found!');
}
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
'loginWithEmail',
async (requestDataArg) => {
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}`);
this.emailTokenMap.findOneAndRemoveSync(
(itemArg) => itemArg.email === existingUser.data.email
);
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);
} 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,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
'loginWithEmailAfterEmailTokenAquired',
async (requestArg) => {
const tokenObject = this.emailTokenMap.findSync((itemArg) => {
return itemArg.email === requestArg.email && itemArg.token === requestArg.token;
});
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);
this.loginSessions.add(loginSession);
const refreshToken = await loginSession.getRefreshToken();
if (!refreshToken) {
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
}
return {
refreshToken,
};
} else {
throw new plugins.typedrequest.TypedResponseError('Validation Token not found');
}
}
)
);
this.typedRouter.addTypedHandler<plugins.idpInterfaces.request.ILogoutRequest>(
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<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
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<plugins.idpInterfaces.request.IReq_ResetPassword>(
'resetPassword',
async (requestDataArg) => {
const emailOfPasswordToReset = requestDataArg.email;
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
data: {
email: emailOfPasswordToReset,
},
});
if (existingUser) {
this.emailTokenMap.findOneAndRemoveSync(
(itemArg) => itemArg.email === existingUser.data.email
);
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
);
}
// 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<plugins.idpInterfaces.request.IReq_SetNewPassword>(
'setNewPassword',
async (requestData) => {
return {
status: 'ok',
};
}
)
);
/**
* returns a device id by simply returning a uuid4
*/
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ObtainDeviceId>('obtainDeviceId', async (reqData) => {
reqData;
return {
deviceId: {
id: plugins.smartunique.uuid4()
}
}
})
)
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AttachDeviceId>('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<plugins.idpInterfaces.request.IReq_GetUserSessions>(
'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<plugins.idpInterfaces.request.IReq_RevokeSession>(
'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;
}
}