diff --git a/.gitignore b/.gitignore index f018c76..283814c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ **/*.env **/*venv **/node_modules +**/dump.rdb diff --git a/api/README.md b/api/README.md index c6bd019..d78e723 100644 --- a/api/README.md +++ b/api/README.md @@ -95,6 +95,8 @@ field until rotations are implemented. **`AUTH_TOKEN_REFRESH_EXPIRY_SECONDS`:** The time in seconds for the refresh token expiry (how long it lives). The refresh token is used to refresh/create new access tokens. +**`AUTH_TOKEN_MFA_EXPIRY_SECONDS`:** The time in seconds for the MFA token to expire (how long it lives). This should be a very short duration, typically around 5 minutes. + **`MONGODB_URL`:** The MongoDB connection URL **`REDIS_URL`:** The Redis cache server URL diff --git a/api/package-lock.json b/api/package-lock.json index 3ef2473..1ef0819 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "authenticator": "^1.1.5", + "big-integer": "^1.6.52", "cookie-parser": "^1.4.7", "dotenv": "^16.4.7", "express": "^4.21.2", @@ -2208,6 +2209,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", diff --git a/api/package.json b/api/package.json index 96a5129..7603c7d 100644 --- a/api/package.json +++ b/api/package.json @@ -6,6 +6,9 @@ "start": "node --no-assertions dist/index.js", "build": "tsc", "dev": "nodemon --exec ts-node src/index.ts", + "dev:configure-db": "ts-node src/configure-db.ts", + "configure-db": "node --no-assertions dist/configure-db.js", + "generate-jwe-key": "ts-node src/generate-jwe-encryption-key.ts", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", diff --git a/api/src/configure-db.ts b/api/src/configure-db.ts new file mode 100644 index 0000000..0a78bb9 --- /dev/null +++ b/api/src/configure-db.ts @@ -0,0 +1,19 @@ +import dotenv from 'dotenv'; +// eslint-disable-next-line no-restricted-imports +import { DatabaseService } from './services/db/db'; +dotenv.config(); + +async function run() { + const db = new DatabaseService(); + + await db.configureCollections(); +} + +run() + .then(() => { + console.log('Database collections configured'); + process.exit(0); + }) + .catch((e) => { + throw e; + }); diff --git a/api/src/controllers/auth.ts b/api/src/controllers/auth.ts new file mode 100644 index 0000000..b5f3ad7 --- /dev/null +++ b/api/src/controllers/auth.ts @@ -0,0 +1,522 @@ +import { Response } from 'express'; +import { + AuthenticatedRequest, + AuthSessionError, + IncorrectPasswordError, + MFACode, + mfaCodeSchema, + MFAError, + MFAErrorType, +} from '../schemas/auth/auth'; +import { TokenService } from '../services/auth/token'; +import { tryAPIController, validateSchema } from '../util'; +import { + Email, + emailSchema, + InvalidUserError, + InvalidUserType, + loginDataSchema, + registerDataSchema, +} from '../schemas/auth/user'; +import { AuthService } from '../services/auth/auth'; +import { MFAService } from '../services/auth/mfa'; +import { InvalidTokenError, MFATokenPayload } from '../schemas/auth/tokens'; + +const MFA_TOKEN_COOKIE_NAME = 'mfa-token'; +const ACCESS_TOKEN_COOKIE_NAME = 'access-token'; +const REFRESH_TOKEN_COOKIE_NAME = 'refresh-token'; +const ID_TOKEN_COOKIE_NAME = 'id-token'; + +export class AuthController { + /** + * Write the auth tokens to the response's cookies + * @param res - The response + * @param accessToken - The access token + * @param refreshToken - The refresh token + * @param idToken - The id token + */ + public static writeAuthCookies( + res: Response, + accessToken: string, + refreshToken?: string, + idToken?: string, + ) { + res.cookie(ACCESS_TOKEN_COOKIE_NAME, accessToken, { + httpOnly: true, + secure: false, + expires: new Date( + Date.now() + TokenService.ACCESS_TOKEN_LIFESPAN_SECONDS * 1000, + ), + }); + if (refreshToken) { + res.cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken, { + httpOnly: true, + secure: false, + expires: new Date( + Date.now() + TokenService.REFRESH_TOKEN_LIFESPAN_SECONDS * 1000, + ), + }); + } + if (idToken) { + res.cookie(ID_TOKEN_COOKIE_NAME, idToken, { + httpOnly: false, + secure: false, + expires: new Date( + Date.now() + TokenService.ACCESS_TOKEN_LIFESPAN_SECONDS * 1000, + ), + }); + } + } + + /** + * Write the MFA token to the response's cookies + * @param res - The response + * @param mfaToken - The MFA token + */ + public static writeMFACookie(res: Response, mfaToken: string) { + res.cookie(MFA_TOKEN_COOKIE_NAME, mfaToken, { + httpOnly: true, + secure: false, + sameSite: 'none', + expires: new Date( + Date.now() + TokenService.MFA_TOKEN_LIFESPAN_SECONDS * 1000, + ), + }); + } + + /** + * Delete all auth cookies from the response (auth and MFA) + * @param res - The response + */ + public static deleteAllAuthCookies(res: Response) { + this.deleteMFACookie(res); + this.deleteAuthCookies(res); + } + + /** + * Delete the auth token cookies from the response + * @param res - The response + */ + public static deleteAuthCookies(res: Response) { + res.clearCookie(ACCESS_TOKEN_COOKIE_NAME); + res.clearCookie(REFRESH_TOKEN_COOKIE_NAME); + res.clearCookie(ID_TOKEN_COOKIE_NAME); + } + + /** + * Delete the MFA token cookie from the response + * @param res - The response + */ + public static deleteMFACookie(res: Response) { + res.clearCookie(MFA_TOKEN_COOKIE_NAME); + } + + public static register(req: AuthenticatedRequest, res: Response) { + tryAPIController( + res, + async () => { + // check if any user is already logged in + if (req.user) { + res.status(400).send({ error: 'Already logged in' }); + return; + } + + // get data from body + const data = validateSchema(res, registerDataSchema, req.body); + if (!data) { + console.log('data invalid'); + return; + } + + const as = new AuthService(); + const { userId, formattedKey } = await as.registerUser(data); + const mfa = new MFAService(); + const mfaQRUri = mfa.generateMFAUri(formattedKey, data.email); + const ts = new TokenService(); + const mfaToken = await ts.createMFAToken( + userId.toString(), + req.deviceId!, + formattedKey, + ); + this.writeMFACookie(res, mfaToken); + res + .status(201) + .send({ userId, mfaFormattedKey: formattedKey, mfaQRUri }); + return; + }, + (e) => { + if ( + e instanceof InvalidUserError && + e.type === InvalidUserType.ALREADY_EXISTS + ) { + // don't tell the client the user doesn't already exist, just return a + // generic error + res.status(400).send({ error: 'Invalid request' }); + return true; + } + return false; + }, + ); + } + + public static initateAuthSession(req: AuthenticatedRequest, res: Response) { + tryAPIController( + res, + async () => { + // check if user is already signed in + if (req.user) { + res.status(400).send({ error: 'Already logged in' }); + return; + } + + // get email from query + const email: Email | undefined = validateSchema( + res, + emailSchema, + req.query.email, + ); + + if (!email) { + return; + } + + const as = new AuthService(); + + // initiate auth session + const { salt, B } = await as.initiateAuthSession(email); + res.status(201).send({ salt, B: `0x${B.toString(16)}` }); + return; + }, + (e) => { + if (e instanceof InvalidUserError) { + // don't tell the client the user doesn't exist, return a generic + // error + res.status(400).send({ error: 'Invalid request' }); + return true; + } + return false; + }, + ); + } + + public static login(req: AuthenticatedRequest, res: Response) { + tryAPIController( + res, + async () => { + // check if user is already signed in + if (req.user) { + res.status(400).send({ error: 'Already logged in' }); + return; + } + + // validate the body + const data = validateSchema(res, loginDataSchema, req.body); + if (!data) { + return; + } + + // validate the login credentials + const as = new AuthService(); + + // get the auth session + const session = await as.getAuthSession(data.email); + + const Ms = as.validateLoginCredentials( + data.email, + data.A, + data.Mc, + session, + ); + + // login credentials are correct + // generate mfa token to send back to user + const ts = new TokenService(); + const mfaToken = await ts.createMFAToken( + session.userId.toString(), + req.deviceId!, + session.mfaConfirmed ? undefined : session.mfaFormattedKey, + ); + + this.writeMFACookie(res, mfaToken); + + // don't wait for this as it will unnecessarily delay the response + as.deleteAuthSession(data.email).catch((e) => + console.error('Error deleting auth sesssion:', e), + ); + + if (session.mfaConfirmed) { + res.status(200).send({ Ms: `0x${Ms.toString(16)}` }); + return; + } else { + const mfa = new MFAService(); + const mfaQRUri = mfa.generateMFAUri( + session.mfaFormattedKey, + data.email, + ); + res.status(200).send({ + Ms: `0x${Ms.toString(16)}`, + mfaFormattedKey: session.mfaFormattedKey, + mfaQRUri, + }); + return; + } + }, + (err) => { + if (err instanceof InvalidUserError) { + console.log('invalid user error'); + res.status(400).send({ error: 'Invalid request' }); + return true; + } else if (err instanceof IncorrectPasswordError) { + console.log('incorrect password'); + res.status(400).send({ error: 'Incorrect email or password' }); + return true; + } else if (err instanceof AuthSessionError) { + console.log('auth session does not exist'); + res.status(404).send({ error: 'Auth session does not exist' }); + return true; + } + return false; + }, + ); + } + + public static async confirmMFA(req: AuthenticatedRequest, res: Response) { + try { + const deviceId = req.deviceId!; + + // get mfa token + const mfaToken = req.cookies['mfa-token'] as string | undefined; + console.log(req.cookies); + + if (!mfaToken) { + res.status(400).send({ error: 'MFA token not found' }); + return; + } + + let code: MFACode; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + code = mfaCodeSchema.parse(req.body.code); + } catch (_) { + res.status(400).send({ error: 'Invalid code' }); + return; + } + + // verify mfa token + const ts = new TokenService(); + let mfaPayload: MFATokenPayload; + try { + mfaPayload = await ts.verifyMFAToken(mfaToken); + console.log('mfaPayload:', mfaPayload); + } catch (e) { + if (e instanceof InvalidTokenError) { + res.status(401).send({ error: 'Invalid MFA token' }); + return; + } else { + console.error(e); + res.status(500).send({ error: 'Internal Server Error' }); + return; + } + } + + if (deviceId !== mfaPayload.deviceId) { + res.status(400).send({ error: 'Invalid device id' }); + return; + } + + const as = new AuthService(); + try { + await as.confirmUserMFA(mfaPayload.userId, code); + const { accessToken, refreshToken, idToken } = + await ts.generateAllTokens(mfaPayload.userId, deviceId); + this.deleteMFACookie(res); + this.writeAuthCookies(res, accessToken, refreshToken, idToken); + res.status(200).send(); + return; + } catch (e) { + // mfa is valid, create a new one since the old one is now blacklisted + const newMfaToken = await ts.createMFAToken( + mfaPayload.userId, + mfaPayload.deviceId, + mfaPayload.formattedKey, + ); + this.writeMFACookie(res, newMfaToken); + if (e instanceof MFAError) { + switch (e.type) { + case MFAErrorType.INCORRECT_CODE: + res.status(400).send({ error: 'Incorrect MFA Code' }); + break; + case MFAErrorType.MFA_ALREADY_CONFIRMED: + res.status(400).send({ error: 'MFA already confirmed' }); + break; + case MFAErrorType.MFA_NOT_CONFIRMED: + // this should never happen + console.error('mfa not confirmed in confirm mfa'); + res.status(500).send({ error: 'Internal Server Error' }); + break; + } + } else { + console.error(e); + res + .status(400) + // user does not exist but don't tell client that + .send({ error: 'Invalid request' }); + } + } + return; + } catch (e) { + console.error(e); + res.status(500).send({ error: 'Internal Server Error' }); + } + } + + public static async verifyMFA(req: AuthenticatedRequest, res: Response) { + try { + // get mfa token + const mfaToken = req.cookies['mfa-token'] as string | undefined; + console.log(req.cookies); + + if (!mfaToken) { + res.status(400).send({ error: 'MFA token not found' }); + return; + } + + let code: MFACode; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + code = mfaCodeSchema.parse(req.body.code); + } catch (_) { + res.status(400).send({ error: 'Invalid code' }); + return; + } + + // verify mfa token + const ts = new TokenService(); + let mfaPayload: MFATokenPayload; + try { + mfaPayload = await ts.verifyMFAToken(mfaToken); + console.log('mfaPayload:', mfaPayload); + } catch (e) { + if (e instanceof InvalidTokenError) { + res.status(401).send({ error: 'Invalid MFA token' }); + return; + } else { + console.error(e); + res.status(500).send({ error: 'Internal Server Error' }); + return; + } + } + + if (req.deviceId !== mfaPayload.deviceId) { + res.status(400).send({ error: 'Invalid device id' }); + return; + } + + const as = new AuthService(); + try { + const result = await as.verifyMFA(mfaPayload.userId, code); + if (!result) { + // mfa is valid, create a new one since the old one is now blacklisted + const newMfaToken = await ts.createMFAToken( + mfaPayload.userId, + mfaPayload.deviceId, + mfaPayload.formattedKey, + ); + this.writeMFACookie(res, newMfaToken); + res.status(400).send({ error: 'Incorrect MFA Code' }); + return; + } + + // mfa correct, generate new tokens + const { accessToken, refreshToken, idToken } = + await ts.generateAllTokens(mfaPayload.userId, mfaPayload.deviceId); + this.writeAuthCookies(res, accessToken, refreshToken, idToken); + this.deleteMFACookie(res); + res.status(200).send(); + return; + } catch (e) { + if (e instanceof MFAError) { + // mfa is valid, create a new one since the old one is now blacklisted + const newMfaToken = await ts.createMFAToken( + mfaPayload.userId, + mfaPayload.deviceId, + mfaPayload.formattedKey, + ); + this.writeMFACookie(res, newMfaToken); + switch (e.type) { + case MFAErrorType.INCORRECT_CODE: + res.status(400).send({ error: 'Incorrect MFA Code' }); + break; + case MFAErrorType.MFA_ALREADY_CONFIRMED: + // this should never happen + console.error('mfa already confirmed in verify mfa'); + res.status(500).send({ error: 'Internal Server Error' }); + break; + case MFAErrorType.MFA_NOT_CONFIRMED: + res.status(400).send({ error: 'MFA not confirmed' }); + break; + } + } else { + console.error(e); + res.status(500).send({ error: 'Internal Server Error' }); + } + return; + } + } catch (e) { + console.error(e); + res.status(500).send({ error: 'Internal server error' }); + } + } + + /** + * Refreshes the user's tokens. This method will not send a response, it only + * adds the tokens to the response object. + * @param req - The request + * @param res - The response + */ + public static async refresh(req: AuthenticatedRequest, res: Response) { + if (req.refreshToken === undefined) { + throw new Error('No refresh token to refresh with'); + } + if (req.deviceId === undefined) { + throw new Error('No device id to refresh with'); + } + + const ts = new TokenService(); + // refresh tokens + const { + accessToken, + refreshToken: newRefreshToken, + idToken, + } = await ts.refreshTokens(req.refreshToken, req.deviceId); + + // set new tokens in cookies + AuthController.writeAuthCookies(res, accessToken, newRefreshToken, idToken); + req.tokensRefreshed = true; + } + + public static refreshTokens(req: AuthenticatedRequest, res: Response) { + tryAPIController(res, async () => { + if (req.tokensRefreshed) { + res.status(200).send(); + return; + } + await this.refresh(req, res); + res.status(200).send(); + return; + }); + } + + public static logout(req: AuthenticatedRequest, res: Response) { + tryAPIController(res, async () => { + // change generation id + const ts = new TokenService(); + await ts.revokeDeviceRefreshTokens(req.user!._id, req.deviceId!); + + // delete all auth cookies + this.deleteAllAuthCookies(res); + + res.status(200).send(); + }); + } +} diff --git a/api/src/controllers/household.ts b/api/src/controllers/household.ts index ee7acc2..c3d7255 100644 --- a/api/src/controllers/household.ts +++ b/api/src/controllers/household.ts @@ -1,5 +1,4 @@ import { Response } from 'express'; -import { AuthenticatedRequest } from '../schemas/auth/user'; import { Household, HouseholdRequestData, @@ -10,6 +9,7 @@ import { HouseholdMember, } from '../schemas/household'; import { HouseholdService } from '../services/household'; +import { AuthenticatedRequest } from '../schemas/auth/auth'; // TODO: proper error handling (maybe implement custom error classes) export class HouseholdController { @@ -106,7 +106,7 @@ export class HouseholdController { const updatedHousehold = await hs.addMember( householdId, memberId, - req.user._id, + req.user._id.toString(), ); res.status(200).send(updatedHousehold); @@ -132,7 +132,7 @@ export class HouseholdController { const updatedHousehold = await hs.respondToInvite( inviteId, response, - req.user._id, + req.user._id.toString(), ); res.status(200).send(updatedHousehold); @@ -159,7 +159,7 @@ export class HouseholdController { const updatedHousehold = await hs.removeMember( householdId, memberId, - req.user._id, + req.user._id.toString(), ); res.status(200).send(updatedHousehold); } catch (e) { @@ -180,7 +180,7 @@ export class HouseholdController { const { householdId } = req.params; const hs = new HouseholdService(); - await hs.deleteHousehold(householdId, req.user._id); + await hs.deleteHousehold(householdId, req.user._id.toString()); res.status(200).send({ message: 'Household deleted' }); } catch (e) { console.error(e); @@ -207,7 +207,7 @@ export class HouseholdController { const updatedHousehold = await hs.addRoom( householdId, roomRequestData, - req.user._id, + req.user._id.toString(), ); res.status(200).send(updatedHousehold); } catch (e) { @@ -262,7 +262,7 @@ export class HouseholdController { householdId, memberId, newRole, - req.user._id, + req.user._id.toString(), ); res.status(200).send(updatedHousehold); } catch (e) { @@ -288,7 +288,7 @@ export class HouseholdController { householdId, roomId, action, - req.user._id, + req.user._id.toString(), ); res.status(200).send(updatedHousehold); } catch (e) { @@ -312,7 +312,7 @@ export class HouseholdController { const updatedHousehold = await hs.removeRoom( householdId, roomId, - req.user._id, + req.user._id.toString(), ); res.status(200).send(updatedHousehold); diff --git a/api/src/controllers/mfa.ts b/api/src/controllers/mfa.ts deleted file mode 100644 index d2f5d13..0000000 --- a/api/src/controllers/mfa.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { Response } from 'express'; -import { DatabaseService, MFAService } from '../services/mfa'; -import { MFAToken, mfaTokenSchema } from '../schemas/mfa'; -import { AuthenticatedRequest } from '../schemas/auth/user'; - -// TODO: proper error handling (maybe implement custom error classes) -export class MFAController { - public static async initMFA(req: AuthenticatedRequest, res: Response) { - if (!req.user) { - console.log('Unauthorized'); - - res.status(401).send('Unauthorized'); - return; - } - - const db = new DatabaseService(); - const mfa = new MFAService(db); - - try { - const result = await mfa.initUserMFA(req.user._id); - console.log('MFA setup initiated'); - - res.status(201).send(result); - return; - } catch (e: unknown) { - console.log('Internal Server Error:', e); - res.status(500).send({ - error: 'Internal Server Error', - message: 'Please try again later', - }); - return; - } - } - - public static async confirmMFA(req: AuthenticatedRequest, res: Response) { - if (!req.user) { - console.log('Unauthorized'); - - res - .status(401) - .send({ error: 'Unauthorized', message: 'User must be authenticated' }); - return; - } - let mfaToken: MFAToken; - try { - //const userToken = req.body.token; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - mfaToken = mfaTokenSchema.parse(req.body.token); - } catch (_) { - console.log('Invalid token'); - - res.status(400).send({ - error: 'Invalid token', - message: 'Token must be a 6 digit number', - }); - return; - } - - const db = new DatabaseService(); - const mfa = new MFAService(db); - - try { - const confirmed = await mfa.finishInitMFASetup(req.user._id, mfaToken); - if (confirmed) { - console.log('MFA setup confirmed'); - - res.status(200).send({ - message: 'MFA setup confirmed', - }); - } else { - console.log('MFA setup not confirmed because code is incorrect'); - res.status(400).send({ - error: 'Incorrect Code', - message: 'Please enter the correct code', - }); - } - - return; - } catch (e: unknown) { - console.log('Internal Server Error:', e); - - res.status(500).send({ - error: 'Internal Server Error', - message: 'Please try again later', - }); - return; - } - } - - public static async verifyMFA(req: AuthenticatedRequest, res: Response) { - if (!req.user) { - console.log('Unauthorized'); - - res - .status(401) - .send({ error: 'Unauthorized', message: 'User must be authenticated' }); - return; - } - let mfaToken: MFAToken; - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - mfaToken = mfaTokenSchema.parse(req.body.token); - } catch (_) { - console.log('Invalid token'); - - res.status(400).send({ - error: 'Invalid token', - message: 'Token must be a 6 digit number', - }); - return; - } - const db = new DatabaseService(); - const mfa = new MFAService(db); - try { - const confirmed = await mfa.verifyMFA(req.user._id, mfaToken); - - // TODO: update this to be integrated with authentication instead of just - // returning true/false - - if (confirmed) { - console.log('MFA code correct'); - - res.status(200).send(); - return; - } else { - console.log('MFA code incorrect'); - res.status(400).send({ - error: 'Incorrect Code', - message: 'Please enter the correct code', - }); - return; - } - } catch (e: unknown) { - console.log('Internal Server Error:', e); - res.status(500).send({ - error: 'Internal Server Error', - message: e, - }); - return; - } - } -} diff --git a/api/src/generate-jwe-encryption-key.ts b/api/src/generate-jwe-encryption-key.ts new file mode 100644 index 0000000..a399f15 --- /dev/null +++ b/api/src/generate-jwe-encryption-key.ts @@ -0,0 +1,30 @@ +import crypto from 'crypto'; + +/** + * Generates a cryptographically secure random 256-bit key for JWE encryption + * and returns it as a Base64URL encoded string (the format used in JWE) + * + * @returns {string} Base64URL encoded 256-bit encryption key + */ +export function generateJweEncryptionKey(): string { + // Generate 32 bytes (256 bits) of random data + const key = crypto.randomBytes(32); + + // Convert to Base64URL encoding (Base64 with URL-safe characters) + // This replaces '+' with '-', '/' with '_', and removes padding '=' + const base64Key = key + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + return base64Key; +} + +// When this script is run directly, generate and log a new key +if (require.main === module) { + const encryptionKey = generateJweEncryptionKey(); + console.log('Generated 256-bit JWE Encryption Key:'); + console.log(encryptionKey); + console.log('\nStore this key securely in your environment variables.'); +} diff --git a/api/src/index.ts b/api/src/index.ts index 0d249a6..d2ebfaf 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -4,6 +4,10 @@ import cookieParser from 'cookie-parser'; import { logMiddleware } from './middleware/log'; import { authRouter } from './routes/auth'; import { householdRouter } from './routes/household'; +import { parseAuth } from './middleware/auth'; +import { bigIntToHexMiddleware } from './middleware/bigint'; +// eslint-disable-next-line no-restricted-imports +import { DatabaseService } from './services/db/db'; dotenv.config(); @@ -13,6 +17,8 @@ const port = process.env.PORT ?? 3000; app.use(express.json()); app.use(cookieParser()); app.use(logMiddleware); +app.use(parseAuth); +app.use(bigIntToHexMiddleware); const router = express.Router(); @@ -25,6 +31,24 @@ router.use('/households', householdRouter); app.use('/api', router); -app.listen(port, () => { - console.log(`Server running on port ${port}`); -}); +async function start() { + const db = new DatabaseService(); + + await db.connect(); + + await Promise.all([ + db.accessBlacklistRepository.loadBlacklistToCache(), + db.mfaBlacklistRepository.loadBlacklistToCache(), + db.srpSessionRepository.loadSessionsToCache(), + ]); + + console.log('Blacklists loaded'); + + app.listen(port, () => { + console.log(`Server running on port ${port}`); + console.log('\n\n\n'); + }); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +start(); diff --git a/api/src/middleware/auth.ts b/api/src/middleware/auth.ts index 0e70683..26368da 100644 --- a/api/src/middleware/auth.ts +++ b/api/src/middleware/auth.ts @@ -1,61 +1,119 @@ import { Response, NextFunction } from 'express'; -import { AuthenticatedRequest, userSchema } from '../schemas/auth/user'; +import { AuthenticatedRequest } from '../schemas/auth/auth'; +import { TokenService } from '../services/auth/token'; +import { AccessTokenPayload, tokenTypeSchema } from '../schemas/auth/tokens'; +import { AuthController } from '../controllers/auth'; -// TODO: implement requireAuth middleware -/** - * A middleware for routes which require the user to be logged in. If the user - * is found and valid, they will be added to the request object. If not found or - * not valid, a 401 is returned. - */ -export const requireAuth = ( +// TODO: auto refresh tokens if access is about to expire +// TODO: finish implementation and testing +export const parseAuth = async ( req: AuthenticatedRequest, res: Response, next: NextFunction, ) => { - // TODO: check for cookies/auth header - const user = (req.body as { user?: unknown }).user; + try { + // check for device id + const deviceId = req.headers['x-device-id'] as string | undefined; + if (deviceId) { + req.deviceId = deviceId; + } - if (!user) { - console.log('no user found'); + const ts = new TokenService(); - res.status(401).json({ message: 'Unauthorized' }); - return; - } + // get access token from authorization header + const authHeader = req.headers.authorization; + let accessTokenPayload: AccessTokenPayload | undefined = undefined; + if (authHeader) { + const prefix = 'Bearer '; + if (authHeader.startsWith(prefix)) { + const accessToken = authHeader.substring(prefix.length); + + const { valid, payload } = await ts.verifyToken(accessToken, true); + + if (valid) { + if (payload!.type === tokenTypeSchema.enum.ACCESS) { + accessTokenPayload = payload as AccessTokenPayload; + req.accessTokenPayload = accessTokenPayload; + req.user = (payload as AccessTokenPayload).user; + } + } + } + } else { + // check cookies for access + const accessToken: string | undefined = req.cookies['access-token'] as + | string + | undefined; + if (accessToken) { + const { valid, payload } = await ts.verifyToken(accessToken, true); + if (valid) { + if (payload!.type === tokenTypeSchema.enum.ACCESS) { + accessTokenPayload = payload as AccessTokenPayload; + req.accessTokenPayload = accessTokenPayload; + req.user = (payload as AccessTokenPayload).user; + } + } + } + } + + // check if refresh token exists + const refreshToken: string | undefined = req.cookies['refresh-token'] as + | string + | undefined; - const parseUserResult = userSchema.safeParse(user); + if (refreshToken) { + const ts = new TokenService(); + const { valid, payload } = await ts.verifyToken(refreshToken, true); - if (!parseUserResult.success) { - console.log('invalid user found'); + if (valid && payload!.type === tokenTypeSchema.enum.REFRESH) { + req.refreshToken = refreshToken; + } + } - res.status(401).json({ message: 'Unauthorized' }); + // auto refresh + // if access token is invalid (possibly expired) or expiring soon (within 5 minutes) + if ( + (!req.accessTokenPayload || + req.accessTokenPayload.exp - Date.now() / 1000 < 300) && + req.refreshToken !== undefined && + req.deviceId !== undefined + ) { + await AuthController.refresh(req, res); + } + + next(); + } catch (e) { + console.error(e); + res.status(500).send({ error: 'Internal Server Error' }); return; } +}; - req.user = parseUserResult.data; - - // check cookies for auth tokens - return next(); +export const requireDeviceId = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + if (!req.deviceId || req.deviceId.length === 0) { + res.status(400).send({ error: 'Device ID required' }); + return; + } + next(); }; /** - * A middleware for routes which may use authentication information if available - * but the user isn't required to be logged in. If found, the user will be added - * to the request object. If not found or not valid, it will be undefined. + * A middleware for routes which require the user to be logged in. If the user + * is found and valid, they will be added to the request object. If not found or + * not valid, a 401 is returned. */ -export const optionalAuth = ( +export const requireAuth = ( req: AuthenticatedRequest, res: Response, next: NextFunction, ) => { - // TODO: check for cookies/auth header - const user = (req.body as { user?: unknown }).user; - - const parseUserResult = userSchema.safeParse(user); - - if (parseUserResult.success) { - req.user = parseUserResult.data; + if (!req.user) { + res.status(401).send('Unauthorized'); + return; } - - // check cookies for auth tokens - return next(); + next(); + return; }; diff --git a/api/src/middleware/bigint.ts b/api/src/middleware/bigint.ts new file mode 100644 index 0000000..e6bda1a --- /dev/null +++ b/api/src/middleware/bigint.ts @@ -0,0 +1,69 @@ +import { Request, Response, NextFunction } from 'express'; + +/** + * Recursively converts any BigInt values in an object to base-16 strings + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function convertBigIntsToHex(obj: unknown): any { + if (obj === null || obj === undefined) { + return obj; + } + + if (typeof obj === 'bigint') { + return `0x${obj.toString(16)}`; + } + + if (Array.isArray(obj)) { + // Only create a new array if there are BigInts to convert + const converted = obj.map(convertBigIntsToHex); + return obj.some((item) => typeof item === 'bigint') ? converted : obj; + } + + if (obj && typeof obj === 'object' && !Buffer.isBuffer(obj)) { + // Only create a new object if there are BigInts to convert + const entries = Object.entries(obj); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + const convertedEntries = entries.map(([key, value]) => [ + key, + convertBigIntsToHex(value), + ]); + + // Check if any values were actually converted + const hasChanges = convertedEntries.some( + ([_, value], index) => value !== entries[index][1], + ); + + return hasChanges ? Object.fromEntries(convertedEntries) : obj; + } + + return obj; +} + +/** + * Express middleware that converts BigInt values to hex strings before JSON serialization + */ +export function bigIntToHexMiddleware( + req: Request, + res: Response, + next: NextFunction, +) { + // Store the original send function + const originalSend = res.send; + + // Override the send function + res.send = function (body: unknown): Response { + try { + // Convert any BigInts in the response body to hex strings + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const convertedBody = convertBigIntsToHex(body); + + // Call the original send with the converted body + return originalSend.call(this, convertedBody); + } catch (error) { + console.error('Error in bigIntToHexMiddleware:', error); + return originalSend.call(this, body); + } + }; + + next(); +} diff --git a/api/src/routes/.gitkeep b/api/src/routes/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index 25fdb95..9f069c8 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -1,6 +1,30 @@ import { Router } from 'express'; -import { mfaRouter } from './mfa'; +import { AuthController } from '../controllers/auth'; +import { requireAuth, requireDeviceId } from '../middleware/auth'; export const authRouter = Router(); +authRouter.use(requireDeviceId); -authRouter.use('/mfa', mfaRouter); +authRouter.post('/register', (req, res) => AuthController.register(req, res)); + +authRouter.post('/init', (req, res) => + AuthController.initateAuthSession(req, res), +); + +authRouter.post('/login', (req, res) => AuthController.login(req, res)); + +authRouter.post('/mfa/confirm', (req, res) => + AuthController.confirmMFA(req, res), +); + +authRouter.post('/mfa/verify', (req, res) => + AuthController.verifyMFA(req, res), +); + +authRouter.get('/refresh', requireAuth, (req, res) => + AuthController.refreshTokens(req, res), +); + +authRouter.get('/logout', requireAuth, (req, res) => + AuthController.logout(req, res), +); diff --git a/api/src/routes/mfa.ts b/api/src/routes/mfa.ts index 3b1c891..2227bcb 100644 --- a/api/src/routes/mfa.ts +++ b/api/src/routes/mfa.ts @@ -1,21 +1,20 @@ -import { Router } from 'express'; -import { MFAController } from '../controllers/mfa'; -import { requireAuth } from '../middleware/auth'; - -export const mfaRouter = Router(); - -// TODO: update routes so they aren't these temp ones for testing - -mfaRouter.post('/init', requireAuth, (req, res) => - MFAController.initMFA(req, res), -); - -mfaRouter.post('/confirm-init', requireAuth, (req, res) => - MFAController.confirmMFA(req, res), -); - -mfaRouter.post('/verify', requireAuth, (req, res) => - MFAController.verifyMFA(req, res), -); - -// TODO: add route to reinit MFA if they are alrady signed in +//import { Router } from 'express'; +//import { MFAController } from '../controllers/mfa'; +//import { requireAuth } from '../middleware/auth'; +//import { logMiddleware } from '../middleware/log'; +// +//export const mfaRouter = Router(); +// +//// TODO: update routes so they aren't these temp ones for testing +// +//mfaRouter.post('/init', requireAuth, MFAController.initMFA); +// +//mfaRouter.post('/confirm-init', requireAuth, MFAController.confirmMFA); +// +//mfaRouter.post('/verify', requireAuth, MFAController.verifyMFA); +// +//// ! temp +//// TODO: remove +//mfaRouter.get('/test/:id', MFAController.testRoute); +// +//// TODO: add route to reinit MFA if they are alrady signed in diff --git a/api/src/schemas/.gitkeep b/api/src/schemas/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/api/src/schemas/auth/auth.ts b/api/src/schemas/auth/auth.ts new file mode 100644 index 0000000..d6a04d6 --- /dev/null +++ b/api/src/schemas/auth/auth.ts @@ -0,0 +1,120 @@ +import { Request } from 'express'; +import { z } from 'zod'; +import { AccessTokenPayload, AccessTokenUser } from './tokens'; +import { objectIdOrStringSchema, objectIdStringSchema } from '../obj-id'; +import { emailSchema } from './user'; + +export interface AuthenticatedRequest extends Request { + user?: AccessTokenUser | undefined; + accessTokenPayload?: AccessTokenPayload | undefined; + refreshToken?: string | undefined; + deviceId?: string | undefined; + tokensRefreshed?: boolean | undefined; +} + +export const mfaFormattedKeySchema = z.string(); +export type MFAFormattedKey = z.infer; + +/** The MFA Code */ +export const mfaCodeSchema = z.coerce + .string() + .min(6) + .max(6) + .regex(/^\d+$/, 'Must contain only numeric characters'); + +/** The MFA Code */ +export type MFACode = z.infer; + +export const mfaSchema = z.object({ + formattedKey: mfaFormattedKeySchema, + confirmed: z.boolean(), +}); +export type MFA = z.infer; + +const bigIntTransormed = z + .union([z.string(), z.bigint()]) + .transform((val) => (typeof val === 'string' ? BigInt(val) : val)); + +export const srpSessionSchema = z.object({ + /** The user's id */ + userId: objectIdOrStringSchema, + /** The user's email */ + email: emailSchema, + /** The user's salt */ + salt: z.string(), + /** the user's verifier */ + verifier: bigIntTransormed, + /** Server private key */ + b: bigIntTransormed, + /** Server public key */ + B: bigIntTransormed, + /** MFA formatted key */ + mfaFormattedKey: mfaFormattedKeySchema, + /** Whether the user's mfa has been setup and confirmed */ + mfaConfirmed: z.boolean(), +}); + +export type SRPSession = z.infer; + +export const SRPJSONSessionSchema = z.object({ + /** the user's id */ + userId: objectIdStringSchema, + /** The user's email */ + email: emailSchema, + /** The datetime the session was created */ + createdAt: z.coerce.date().transform((val) => val.toISOString()), + /** The user's salt */ + salt: z.string(), + /** the user's verifier */ + verifier: bigIntTransormed.transform((val) => `0x${val.toString(16)}`), + /** Server private key */ + b: bigIntTransormed.transform((val) => `0x${val.toString(16)}`), + /** Server public key */ + B: bigIntTransormed.transform((val) => `0x${val.toString(16)}`), + /** MFA formatted key */ + mfaFormattedKey: mfaFormattedKeySchema, + /** Whether the user's mfa has been setup and confirmed */ + mfaConfirmed: z.boolean(), +}); + +export type SRPJSONSession = z.infer; + +export const SRPMongoSessionSchema = SRPJSONSessionSchema.extend({ + createdAt: z.coerce.date(), +}); + +export type SRPMongoSession = z.infer; + +/* Error types */ + +export enum MFAErrorType { + INCORRECT_CODE = 'INCORRECT_CODE', + MFA_ALREADY_CONFIRMED = 'MFA_ALREADY_CONFIRMED', + MFA_NOT_CONFIRMED = 'MFA_NOT_CONFIRMED', +} + +export class MFAError extends Error { + public readonly type: MFAErrorType; + constructor(type: MFAErrorType) { + super(`MFA ERROR: ${type}`); + this.name = 'MFAError'; + Object.setPrototypeOf(this, MFAError.prototype); + this.type = type; + } +} + +export class AuthSessionError extends Error { + constructor() { + super('Authentication Session Error'); + this.name = 'AuthSessionError'; + Object.setPrototypeOf(this, AuthSessionError.prototype); + } +} + +export class IncorrectPasswordError extends Error { + constructor() { + super('Incorrect Password'); + this.name = 'IncorrectPasswordError'; + Object.setPrototypeOf(this, IncorrectPasswordError.prototype); + } +} diff --git a/api/src/schemas/auth/tokens.ts b/api/src/schemas/auth/tokens.ts index 822830d..2081f57 100644 --- a/api/src/schemas/auth/tokens.ts +++ b/api/src/schemas/auth/tokens.ts @@ -1,4 +1,6 @@ import { z } from 'zod'; +import { userWithIdSchema } from './user'; +import { mfaFormattedKeySchema } from './auth'; export const jwtSecretSchema = z.object({ /** The secret to sign the JWT with */ @@ -13,15 +15,24 @@ export const tokenTypeSchema = z.enum(['ACCESS', 'REFRESH', 'ID']); export type TokenType = z.infer; -export const tokenPayloadSchema = z.object({ +export const mfaTokenTypeSchema = z.literal('MFA'); + +export type MFATokenType = z.infer; + +export const commonTokenInfoSchema = z.object({ /** The user ID of the user the token is for */ userId: z.string(), /** When the token was created */ iat: z.number(), - /** The token's generation id */ - generationId: z.string(), /** The expiration time of the token */ exp: z.number(), +}); + +export type CommonTokenInfo = z.infer; + +export const tokenPayloadSchema = commonTokenInfoSchema.extend({ + /** The token's generation id */ + generationId: z.string(), /** The type of the token */ type: tokenTypeSchema, }); @@ -39,9 +50,14 @@ export const refreshTokenPayloadSchema = tokenPayloadSchema.extend({ export type RefreshTokenPayload = z.infer; +export const accessTokenUserSchema = userWithIdSchema.extend({}); +export type AccessTokenUser = z.infer; + export const accessTokenPayloadSchema = refreshTokenPayloadSchema.extend({ - /** The email of the user the token is for */ - email: z.string(), + ///** The email of the user the token is for */ + //email: z.string(), + /** The user */ + user: accessTokenUserSchema, /** The refresh token used to generate this access token's identifier */ refreshJti: z.string().min(1), /** The type of the token */ @@ -54,14 +70,28 @@ export type AccessTokenPayload = z.infer; export const idTokenPayloadSchema = tokenPayloadSchema.extend({ /** The user's name */ name: z.string(), - /** The user's email */ - email: z.string().email(), + /** The user */ + user: accessTokenUserSchema, /** The type of the token */ type: z.literal(tokenTypeSchema.enum.ID), }); export type IDTokenPayload = z.infer; +// mfa token +export const mfaTokenPayloadSchema = commonTokenInfoSchema.extend({ + /** The token's type */ + type: mfaTokenTypeSchema, + /** The token's unique identifier. */ + jti: z.string().min(1), + /** The device ID of the device the token is for */ + deviceId: z.string().min(1), + /** MFA formatted key */ + formattedKey: mfaFormattedKeySchema.optional(), +}); + +export type MFATokenPayload = z.infer; + /** * An error that is thrown when a token is invalid or malformed */ diff --git a/api/src/schemas/auth/user.ts b/api/src/schemas/auth/user.ts index ce20b93..716e1b2 100644 --- a/api/src/schemas/auth/user.ts +++ b/api/src/schemas/auth/user.ts @@ -1,31 +1,107 @@ -import { Request } from 'express'; +import { objectIdOrStringSchema } from '../obj-id'; import { z } from 'zod'; -import { objectIdStringSchema } from '../obj-id'; +/** Email address */ +export const emailSchema = z.string().email(); +/** Email address */ +export type Email = z.infer; + +/** Date of birth */ +export const dobSchema = z.coerce.date(); +/** Date of birth */ +export type DOB = z.infer; + +/** The user's sex/gender */ +export const sexSchema = z.enum(['m', 'f']); +/** The user's sex/gender */ +export type Sex = z.infer; + +/** THe user schema */ export const userSchema = z.object({ - /** MongoDB Object Id*/ - _id: objectIdStringSchema, - /** Email address */ + /** The user's id */ + _id: objectIdOrStringSchema.optional(), + /** The user's email */ email: z.string().email(), + /** The user's date of birth */ + dob: dobSchema.optional(), + /** The user's sex/gender */ + sex: sexSchema.optional(), }); +/** User and their information */ export type User = z.infer; -export enum InvalidUseType { +/** The user schema but with an id guaranteed not to be undefined */ +export const userWithIdSchema = userSchema.extend({ + /** The user's id */ + _id: objectIdOrStringSchema, +}); + +/** The user schema but with an id guaranteed not to be undefined */ +export type UserWithId = z.infer; + +/** Invalid user types */ +export enum InvalidUserType { INVALID_ID = 'Invalid ID', INVALID_EMAIL = 'Invalid Email', DOES_NOT_EXIST = 'Does Not Exist', + ALREADY_EXISTS = 'Already Exists', OTHER = 'Other', } +/** Error for invalid users */ export class InvalidUserError extends Error { - constructor(details?: { type?: InvalidUseType; message?: string }) { + public readonly type: InvalidUserType = InvalidUserType.OTHER; + constructor(details?: { type?: InvalidUserType; message?: string }) { super(`Invalid User${details?.message ? `: ${details.message}` : ''}`); this.name = 'InvalidUserError'; Object.setPrototypeOf(this, InvalidUserError.prototype); + if (details?.type) { + this.type = details.type; + } } } -export interface AuthenticatedRequest extends Request { - user?: User | undefined; -} +/* Request schemas */ + +const bigIntTransformed = z + .union([z.string(), z.bigint()]) + .refine( + (val) => { + if (typeof val === 'string') { + try { + BigInt(val); + return true; + } catch (_) { + return false; + } + } + return true; + }, + { message: 'Value must be a string or a BigInt' }, + ) + .transform((val) => (typeof val === 'string' ? BigInt(val) : val)); + +//export const initUserDataSchema = z.object({ +// email: emailSchema, +//}); + +export const registerDataSchema = z.object({ + email: emailSchema, + dob: dobSchema.optional(), + sex: sexSchema.optional(), + salt: z.string(), + verifier: bigIntTransformed, +}); + +export type RegisterData = z.infer; + +export const loginDataSchema = z.object({ + /** The user's email */ + email: emailSchema, + /** The client's public key */ + A: bigIntTransformed, + /** The client's proof */ + Mc: bigIntTransformed, +}); +export type LoginData = z.infer; diff --git a/api/src/schemas/mfa.ts b/api/src/schemas/mfa.ts deleted file mode 100644 index c6fcffc..0000000 --- a/api/src/schemas/mfa.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod'; - -export const mfaTokenSchema = z - .string() - .min(6) - .max(6) - .regex(/^\d+$/, 'Must contain only numeric characters'); - -export type MFAToken = z.infer; diff --git a/api/src/schemas/obj-id.ts b/api/src/schemas/obj-id.ts index ba47ca6..00d2360 100644 --- a/api/src/schemas/obj-id.ts +++ b/api/src/schemas/obj-id.ts @@ -1,17 +1,21 @@ import { ObjectId } from 'mongodb'; import { z } from 'zod'; -export const objectIdStringSchema = z.coerce - .string() - .refine((val) => ObjectId.isValid(val), { - message: 'Invalid ObjectID', - }); +export const objectIdStringSchema = z + .union([ + z.coerce.string(), + z.instanceof(ObjectId).transform((val) => val.toString()), + ]) + .refine((val) => ObjectId.isValid(val), { message: 'Invalid ObjectID' }); export type ObjectIdString = z.infer; -export const objectIdSchema = objectIdStringSchema.transform( - (val) => new ObjectId(val), -); +//export const objectIdSchema = objectIdStringSchema.transform( +// (val) => new ObjectId(val), +//); +export const objectIdSchema = z + .union([objectIdStringSchema, z.instanceof(ObjectId)]) + .transform((val) => (val instanceof ObjectId ? val : new ObjectId(val))); export const objectIdOrStringSchema = z.union([ objectIdSchema, diff --git a/api/src/services/auth/auth.ts b/api/src/services/auth/auth.ts new file mode 100644 index 0000000..bbb8603 --- /dev/null +++ b/api/src/services/auth/auth.ts @@ -0,0 +1,430 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + MFAError, + MFACode, + MFA, + MFAErrorType, + IncorrectPasswordError, + srpSessionSchema, + SRPSession, + MFAFormattedKey, + SRPJSONSession, + AuthSessionError, +} from '../../schemas/auth/auth'; +import { + Email, + InvalidUserError, + InvalidUserType, + LoginData, + RegisterData, + UserWithId, + userWithIdSchema, +} from '../../schemas/auth/user'; +import { ObjectIdOrString } from '../../schemas/obj-id'; +import { DatabaseService } from '../db/db'; +import { MFAService } from './mfa'; +import crypto, { createHash, Hash } from 'crypto'; +import { modPow } from './srp-utils'; + +export class AuthService { + protected readonly db: DatabaseService; + + constructor() { + this.db = new DatabaseService(); + } + + /** + * Registers the user with their information and auth data + * @param data - The user's data and auth data + * @returns The user's id and their MFA formatted key so that they can setup + * MFA + * @throws An `InvalidUserError` if the user already exists + */ + public async registerUser( + data: RegisterData, + ): Promise<{ userId: ObjectIdOrString; formattedKey: MFAFormattedKey }> { + await this.db.connect(); + // check user doesn't already exist + const userExists = await this.db.userRepository.userExistsEmail(data.email); + if (userExists) { + throw new InvalidUserError({ type: InvalidUserType.ALREADY_EXISTS }); + } + + // generate their mfa info + const ms = new MFAService(); + const formattedKey = ms.generateMFAFormattedKey(); + + // create the user and get their user id + const userId = await this.db.userRepository.createUser(data, formattedKey); + + return { userId, formattedKey }; + } + + /** + * Initialises an auth session for the user. + * @param email - The user's email + */ + public async initiateAuthSession( + email: Email, + ): Promise<{ salt: string; B: bigint }> { + await this.db.connect(); + // get the user's SRP credentiails + const { + userId, + salt, + verifier: verifierString, + } = await this.db.userRepository.getUserSRPCredentials(email); + + // generate the server keys + const verifier = BigInt(verifierString); + const { b, B } = SRP.generateServerKeys(verifier); + + // get mfa info + const { formattedKey, confirmed } = + await this.db.userRepository.getUserMFA(userId); + + const session: SRPSession = { + userId: userId.toString(), + email, + salt, + verifier, + B, + b, + mfaFormattedKey: formattedKey, + mfaConfirmed: confirmed, + }; + + const success = await this.db.srpSessionRepository.storeSRPSession( + srpSessionSchema.parse(session), + ); + + if (!success) { + throw new Error('Failed to store SRP session'); + } + + return { salt, B }; + } + + /** + * Get a user's auth session + * @param email - The user's email + * @returns The SRP session + * @throws An {@link AuthSessionError} if the session is expired or does not exist + */ + public async getAuthSession(email: Email): Promise { + await this.db.connect(); + const jsonSession: SRPJSONSession | undefined = + await this.db.srpSessionRepository.getSRPSession(email); + + if (!jsonSession) { + throw new AuthSessionError(); + } + return srpSessionSchema.parse(jsonSession); + } + + /** + * Delete S a user's auth session + * @param email - The user's email + * @returns + */ + public async deleteAuthSession(email: Email): Promise { + await this.db.connect(); + return await this.db.srpSessionRepository.deleteSRPSession(email); + } + + /** + * Verifies the client's proof is correct and generates a corresponding server + * proof + * @param email - The user's email + * @param A - The client's public key + * @param Mc - The client's proof + * @returns The server's proof + * @throws An {@link IncorrectPasswordError} if the client's proof is incorrect + */ + public validateLoginCredentials( + email: Email, + A: bigint, + Mc: bigint, + session: SRPSession, + ): bigint { + // check the client proof is correct + const serverProof = SRP.verifyClientProof({ + email, + salt: session.salt, + verifier: session.verifier, + A, + b: session.b, + B: session.B, + Mc, + }); + + return serverProof; + } + + /** + * Check if a user with the provided email exists + * @param email - The email to check + * @returns true if the email is registered, false otherwise + */ + public async userExistsEmail(email: Email): Promise { + await this.db.connect(); + return await this.db.userRepository.userExistsEmail(email); + } + + /** + * Check if a user witht he provided id exists + * @param userId - The id to check + * @returns true if the user exists, false otherwise + */ + public async userExistsId(userId: string): Promise { + await this.db.connect(); + return await this.db.userRepository.userExists(userId); + } + + /** + * Verify an MFA Code and mark the user's mfa as correctly setup/confirmed + * @param userId - The id of the user + * @param code - the MFA code to verify + * @throws An error if the user does not exist + * @throws An error if the user already confirmed MFA + * @throws An IncorrectMFATokenError if the code is incorrect + */ + public async confirmUserMFA(userId: string, code: MFACode) { + await this.db.connect(); + const userMFA = await this.db.userRepository.getUserMFA(userId); + if (userMFA.confirmed) { + throw new MFAError(MFAErrorType.MFA_ALREADY_CONFIRMED); + } + const ms = new MFAService(); + if (ms.verifyCode(userMFA.formattedKey, code)) { + // correct, mark mfa as confirmed + await this.db.userRepository.confirmUserMFA(userId); + } else { + throw new MFAError(MFAErrorType.INCORRECT_CODE); + } + } + + /** + * Get a user by their id + * @param userId - The user's id + * @returns The user + */ + public async getUserById(userId: ObjectIdOrString): Promise { + await this.db.connect(); + return await this.db.userRepository.getUserById(userId); + } + + /** + * Verify the MFA code entered by the user is correct + * @param userId - The id of the user + * @param code - The MFA code the user entered + * @returns true if correct, false otherwise + * @throws An MFAError if the user has not confirmed setting up MFA + */ + public async verifyMFA( + userId: ObjectIdOrString, + code: MFACode, + ): Promise { + await this.db.connect(); + const mfa = await this.db.userRepository.getUserMFA(userId); + if (!mfa.confirmed) { + throw new MFAError(MFAErrorType.MFA_NOT_CONFIRMED); + } + const ms = new MFAService(); + const result = ms.verifyCode(mfa.formattedKey, code); + return result; + } +} + +export class SRP { + /** + * Generate server's private and public keys for SRP authentication + * @param verifier - The user's verifier value + * @returns The server's private key (b) and public key (B) + */ + static generateServerKeys(verifier: bigint): { b: bigint; B: bigint } { + // Generate random 32-byte private key + const b = this.generateRandomBigInt(32); + + // Calculate B = (k*v + g^b) % N + const kv = (this.k * verifier) % this.N; + const gb = modPow(this.g, b, this.N); + const B = (kv + gb) % this.N; + + return { b, B }; + } + + /** + * Verify the client's proof and generate server proof + * @param data - The SRP authentication data + * @returns The server's proof value + * @throws IncorrectPasswordError if the client's proof is incorrect + */ + static verifyClientProof(data: { + email: string; + salt: string; + verifier: bigint; + A: bigint; + b: bigint; + B: bigint; + Mc: bigint; + }): bigint { + const { email, salt, verifier, A, b, B, Mc } = data; + + // Security check: A should not be 0 mod N + if (A % this.N === BigInt(0)) { + console.log('A % N === 0, throwing'); + + throw new IncorrectPasswordError(); + } + + // Calculate u = H(A | B) + const u = this.calculateU(A, B); + + // Security check: u should not be 0 + if (u === BigInt(0)) { + console.log('u === 0, throwing'); + + throw new IncorrectPasswordError(); + } + + // Calculate S = (A * (verifier^u)) ^ b % N + const vu = modPow(verifier, u, this.N); + const Avu = (A * vu) % this.N; + const S = modPow(Avu, b, this.N); + + // Calculate K = H(S) + const K = this.hash(S.toString(16)); + + // Calculate expected client proof + const expectedMc = this.calculateClientProof(email, salt, A, B, K); + + // Verify client proof + if (expectedMc !== Mc) { + console.log('expectedMc !== Mc, throwing'); + + throw new IncorrectPasswordError(); + } + + // Generate server proof + const Ms = this.calculateServerProof(A, Mc, K); + return Ms; + } + + /** + * Calculate the hash parameter u = H(A | B) + */ + private static calculateU(A: bigint, B: bigint): bigint { + const concatenated = A.toString(16) + B.toString(16); + return BigInt('0x' + this.hashString(concatenated)); + } + + /** + * Calculate the client proof M = H(H(N) XOR H(g) | H(email) | salt | A | B | K) + */ + private static calculateClientProof( + email: string, + salt: string, + A: bigint, + B: bigint, + K: bigint, + ): bigint { + // Hash N and g + const HN = this.hash(this.N.toString(16)); + const Hg = this.hash(this.g.toString(16)); + + // XOR operation (convert to Buffer for easier XOR) + const hnBuffer = Buffer.from(HN.toString(16).padStart(64, '0'), 'hex'); + const hgBuffer = Buffer.from(Hg.toString(16).padStart(64, '0'), 'hex'); + const xorBuffer = Buffer.alloc(hnBuffer.length); + + for (let i = 0; i < hnBuffer.length; i++) { + xorBuffer[i] = hnBuffer[i] ^ (i < hgBuffer.length ? hgBuffer[i] : 0); + } + + const Hemail = this.hashString(email); + + const concatString = + xorBuffer.toString('hex') + + Hemail + + salt + + A.toString(16) + + B.toString(16) + + K.toString(16); + + return BigInt('0x' + this.hashString(concatString)); + } + + /** + * Calculate the server proof M2 = H(A | M | K) + */ + private static calculateServerProof(A: bigint, M: bigint, K: bigint): bigint { + const concatenated = A.toString(16) + M.toString(16) + K.toString(16); + return BigInt('0x' + this.hashString(concatenated)); + } + + /** + * Generate a random BigInt of specified byte length + */ + public static generateRandomBigInt(byteLength: number): bigint { + const bytes = crypto.randomBytes(byteLength); + return BigInt('0x' + bytes.toString('hex')); + } + + /** + * Hash a string using SHA-256 + */ + private static hashString(input: string): string { + return crypto.createHash('sha256').update(input).digest('hex'); + } + + /** + * Hash a BigInt + */ + private static hash(input: string): bigint { + return BigInt('0x' + this.hashString(input)); + } + + /** + * Calculate the parameter x = H(salt | H(email | ':' | password)) + * This is used to derive the private key from the password + */ + //private static calculateX( + public static calculateX( + email: string, + password: string, + salt: string, + ): bigint { + if (!email || !password || !salt) { + throw new Error('Email, password, and salt must not be empty'); + } + + // First hash: H(email | ':' | password) + const identity = this.hashString(`${email}:${password}`); + + // Convert salt from hex string to Buffer + const saltBuffer = Buffer.from(salt, 'hex'); + + // Concatenate salt bytes with identity hash bytes + const saltedIdentity = Buffer.concat([ + saltBuffer, + Buffer.from(identity, 'hex'), + ]); + + // Final hash: H(salt | H(email | ':' | password)) + const result = this.hashString(saltedIdentity.toString('hex')); + return BigInt('0x' + result); + } + + // Constants + private static readonly SRP_N_HEX = + `AC6BDB41 324A9A9B F166DE5E 1389582F AF72B665 1987EE07 FC319294 3DB56050 A37329CB B4A099ED 8193E075 7767A13D D52312AB 4B03310D CD7F48A9 DA04FD50 E8083969 EDB767B0 CF609517 9A163AB3 661A05FB D5FAAAE8 2918A996 2F0B93B8 55F97993 EC975EEA A80D740A DBF4FF74 7359D041 D5C33EA7 1D281E44 6B14773B CA97B43A 23FB8016 76BD207A 436C6481 F1D2B907 8717461A 5B9D32E6 88F87748 544523B5 24B0D57D 5EA77A27 75D2ECFA 032CFBDB F52FB378 61602790 04E57AE6 AF874E73 03CE5329 9CCC041C 7BC308D8 2A5698F3 A8D0C382 71AE35F8 E9DBFBB6 94B5C803 D89F7AE4 35DE236D 525F5475 9B65E372 FCD68EF2 0FA7111F 9E4AFF73` + .trim() + .replace(/[\s\r\n]+/g, ''); + private static readonly SRP_GENERATOR = 2; + private static readonly SRP_K = 3; + + public static readonly N = BigInt('0x' + this.SRP_N_HEX); + public static readonly g = BigInt(this.SRP_GENERATOR); + public static readonly k = BigInt(this.SRP_K); +} diff --git a/api/src/services/auth/mfa.ts b/api/src/services/auth/mfa.ts new file mode 100644 index 0000000..ef62329 --- /dev/null +++ b/api/src/services/auth/mfa.ts @@ -0,0 +1,43 @@ +import * as authenticator from 'authenticator'; +import { Email } from '../../schemas/auth/user'; +import { MFAFormattedKey, MFACode } from '../../schemas/auth/auth'; + +export class MFAService { + constructor() {} + + /** + * Generate a formatted key for MFA + * @returns The formatted key + */ + public generateMFAFormattedKey(): MFAFormattedKey { + return authenticator.generateKey(); + } + + /** + * Generate a QR Code URI for a user to setup MFA + * @param formattedKey - The user's formatted key + * @param email - The user's email address + * @returns The formatted key + */ + public generateMFAUri(formattedKey: MFAFormattedKey, email: Email) { + return authenticator.generateTotpUri( + formattedKey, + email, + 'Smartify', + 'SHA1', + 6, + 30, + ); + } + + /** + * Verifies the user's MFA token is correct + * @param formattedKey - The user's formatted key + * @param code - The code the user entered + * @returns True if the code is correct, false otherwise + */ + public verifyCode(formattedKey: MFAFormattedKey, code: MFACode): boolean { + const result = authenticator.verifyToken(formattedKey, code); + return result != null; + } +} diff --git a/api/src/services/auth/srp-utils.ts b/api/src/services/auth/srp-utils.ts new file mode 100644 index 0000000..17cd039 --- /dev/null +++ b/api/src/services/auth/srp-utils.ts @@ -0,0 +1,80 @@ +/** + * Helper utilities for SRP protocol implementation + */ + +import crypto from 'crypto'; + +/** + * Extension for BigInt to add modPow operation + * Calculates (base^exponent) % modulus efficiently + */ +export function modPow( + base: bigint, + exponent: bigint, + modulus: bigint, +): bigint { + if (modulus === BigInt(1)) return BigInt(0); + + let result = BigInt(1); + base = base % modulus; + + while (exponent > BigInt(0)) { + if (exponent % BigInt(2) === BigInt(1)) { + result = (result * base) % modulus; + } + exponent = exponent >> BigInt(1); + base = (base * base) % modulus; + } + + return result; +} + +/** + * Generate cryptographically secure random bytes as BigInt + */ +export function generateSecureRandomBigInt(byteLength: number): bigint { + const bytes = crypto.randomBytes(byteLength); + return BigInt('0x' + bytes.toString('hex')); +} + +/** + * Hash a string using SHA-256 + */ +export function hashString(input: string): string { + return crypto.createHash('sha256').update(input).digest('hex'); +} + +/** + * Convert a BigInt to a byte array + */ +export function bigIntToBytes(value: bigint): Buffer { + let hex = value.toString(16); + // Ensure even length for hex string + if (hex.length % 2 !== 0) { + hex = '0' + hex; + } + return Buffer.from(hex, 'hex'); +} + +/** + * Convert a byte array to a BigInt + */ +export function bytesToBigInt(bytes: Buffer): bigint { + return BigInt('0x' + bytes.toString('hex')); +} + +/** + * Perform security validation for SRP parameters + */ +export function validateSrpParameters( + A: bigint, + B: bigint, + N: bigint, +): boolean { + // Check that A and B are not zero modulo N + if (A % N === BigInt(0) || B % N === BigInt(0)) { + return false; + } + + return true; +} diff --git a/api/src/services/token.ts b/api/src/services/auth/token.ts similarity index 67% rename from api/src/services/token.ts rename to api/src/services/auth/token.ts index 5ce75ef..d01010f 100644 --- a/api/src/services/token.ts +++ b/api/src/services/auth/token.ts @@ -1,31 +1,36 @@ -import jwt from 'jsonwebtoken'; -import { randomUUID } from 'crypto'; -import { CompactEncrypt, compactDecrypt, importJWK } from 'jose'; -import { - InvalidUserError, - InvalidUseType, - type User, - userSchema, -} from '../schemas/auth/user'; +import * as jwt from 'jsonwebtoken'; +import { compactDecrypt, CompactEncrypt, importJWK } from 'jose'; +import { DatabaseService } from '../db/db'; import { - type AccessTokenPayload, + AccessTokenPayload, accessTokenPayloadSchema, - type IDTokenPayload, + accessTokenUserSchema, + CommonTokenInfo, + IDTokenPayload, idTokenPayloadSchema, InvalidTokenError, - type JWTSecret, - type RefreshTokenPayload, + JWTSecret, + MFATokenPayload, + mfaTokenPayloadSchema, + mfaTokenTypeSchema, + RefreshTokenPayload, refreshTokenPayloadSchema, - TokenPayload, tokenTypeSchema, -} from '../schemas/auth/tokens'; -import { DatabaseService } from './db/db'; - -//const algorithm = 'RS256'; +} from '../../schemas/auth/tokens'; +import { + InvalidUserError, + InvalidUserType, + User as UserWithId, + userSchema, +} from '../../schemas/auth/user'; +import { randomUUID } from 'crypto'; +import { ObjectIdOrString } from '../../schemas/obj-id'; +import { MFAFormattedKey } from '../../schemas/auth/auth'; export class TokenService { private static _ACCESS_TOKEN_LIFESPAN_SECONDS: number; private static _REFRESH_TOKEN_LIFESPAN_SECONDS: number; + private static _MFA_TOKEN_LIFESPAN_SECONDS: number; public static get ACCESS_TOKEN_LIFESPAN_SECONDS(): number { if (this._ACCESS_TOKEN_LIFESPAN_SECONDS === undefined) { @@ -58,6 +63,21 @@ export class TokenService { return this._REFRESH_TOKEN_LIFESPAN_SECONDS; } + public static get MFA_TOKEN_LIFESPAN_SECONDS(): number { + if (this._MFA_TOKEN_LIFESPAN_SECONDS === undefined) { + this._MFA_TOKEN_LIFESPAN_SECONDS = parseInt( + process.env.AUTH_TOKEN_MFA_EXPIRY_SECONDS!, + ); + } + + if (isNaN(this._MFA_TOKEN_LIFESPAN_SECONDS)) { + throw new Error( + `Invalid MFA token expiry time: ${process.env.AUTH_TOKEN_MFA_EXPIRY_SECONDS}`, + ); + } + return this._MFA_TOKEN_LIFESPAN_SECONDS; + } + private readonly db: DatabaseService; constructor() { @@ -101,8 +121,8 @@ export class TokenService { * @param encrypt - Whether to encrypt the token * @returns The generated JWT */ - private async generateToken( - payload: Omit, + private async generateToken( + payload: Omit, secret: JWTSecret, lifetime: number, encrypt: boolean, @@ -190,34 +210,40 @@ export class TokenService { * @param deviceId - The device ID to generate the tokens for * @returns The generated tokens */ - public async generateAllTokens(user: User, deviceId: string) { + public async generateAllTokens(userId: ObjectIdOrString, deviceId: string) { const secret: JWTSecret = { secret: process.env.JWT_SECRET!, // TODO: generate random one secretId: '1', // TODO: rotate keys and store in DB }; + await this.db.connect(); // check user exists - const userExists = await this.db.userRepository.userExists(user._id); + const userExists = await this.db.userRepository.userExists(userId); if (!userExists) { - throw new InvalidUserError({ type: InvalidUseType.DOES_NOT_EXIST }); + throw new InvalidUserError({ type: InvalidUserType.DOES_NOT_EXIST }); } // user._id, // true const generationId = await this.db.tokenRepository.getUserTokenGenerationId( - user._id.toString(), + userId, deviceId, true, ); + // get the user + const userInfo = await this.db.userRepository.getUserById(userId); + // TODO: get resources user can access + const user = accessTokenUserSchema.parse(userInfo); + const created = new Date(); // convert created to a number of seconds const createdSeconds = Math.floor(created.getTime() / 1000); const refreshTokenPayload: Omit = { - userId: user._id, + userId: userId.toString(), iat: createdSeconds, type: tokenTypeSchema.enum.REFRESH, jti: randomUUID(), @@ -225,8 +251,8 @@ export class TokenService { }; const accessTokenPayload: Omit = { - userId: user._id, - email: user.email, + userId: userId.toString(), + user, iat: createdSeconds, type: tokenTypeSchema.enum.ACCESS, jti: randomUUID(), @@ -235,8 +261,8 @@ export class TokenService { }; const idTokenPayload: Omit = { - userId: user._id, - email: user.email, + userId: userId.toString(), + user, type: tokenTypeSchema.enum.ID, generationId, iat: createdSeconds, @@ -328,7 +354,7 @@ export class TokenService { * @returns The token's payload * @throws An error if the token is invalid or is not a valid token type */ - private decodeToken( + private async decodeToken( token: string, ): Promise { // Decrypt the token first @@ -375,7 +401,8 @@ export class TokenService { const payload = this.parseToken(result); - // check token isn't blacklisted + await this.db.connect(); + // check token generation ID isn't blacklisted const blacklisted = await this.db.tokenRepository.isTokenGenerationIdBlacklisted( payload.generationId, @@ -384,6 +411,17 @@ export class TokenService { return { valid: false }; } + // For access tokens, also check if the specific token is blacklisted + if (payload.type === tokenTypeSchema.enum.ACCESS) { + const tokenBlacklisted = + await this.db.accessBlacklistRepository.isAccessTokenBlacklisted( + payload.jti, + ); + if (tokenBlacklisted) { + return { valid: false }; + } + } + return { valid: true, payload }; } catch (_) { return { valid: false }; @@ -439,11 +477,12 @@ export class TokenService { // TODO: replace with user service call // // get user's email - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + + await this.db.connect(); const userDoc = await this.db.userRepository.getUserById( oldRefreshPayload.userId, ); - let user: User; + let user: UserWithId; try { user = userSchema.parse(userDoc); } catch (e) { @@ -453,6 +492,8 @@ export class TokenService { // TODO: get user household access and roles + const accessUser = accessTokenUserSchema.parse(user); + const created = new Date(); // convert created to a number of seconds const createdSeconds = Math.floor(created.getTime() / 1000); @@ -467,7 +508,7 @@ export class TokenService { const accessTokenPayload: Omit = { userId: oldRefreshPayload.userId, - email: user.email, + user: accessUser, iat: createdSeconds, type: tokenTypeSchema.enum.ACCESS, jti: randomUUID(), @@ -477,7 +518,7 @@ export class TokenService { const idTokenPayload: Omit = { userId: oldRefreshPayload.userId, - email: user.email, + user: accessUser, type: tokenTypeSchema.enum.ID, generationId: oldRefreshPayload.generationId, iat: createdSeconds, @@ -526,7 +567,11 @@ export class TokenService { * @param deviceId - The device ID to revoke tokens for * @returns the user's new token generation ID */ - public async revokeDeviceRefreshTokens(userId: string, deviceId: string) { + public async revokeDeviceRefreshTokens( + userId: ObjectIdOrString, + deviceId: string, + ) { + await this.db.connect(); return await this.db.tokenRepository.changeUserTokenGenerationId( userId, deviceId, @@ -545,8 +590,163 @@ export class TokenService { * * @param userId - The user for whom to revoke all tokens */ - public async revokeAllTokensImmediately(userId: string) { + public async revokeAllTokensImmediately(userId: ObjectIdOrString) { + await this.db.connect(); // add to blacklist await this.db.tokenRepository.blacklistTokenGenerationIds(userId); } + + /** + * Blacklist a specific access token. This will prevent the token from being used + * for any future requests, but won't affect other tokens generated with the same + * refresh token. + * + * This is useful for scenarios where you want to revoke a single access token + * without affecting other sessions, such as when a user logs out of a specific device. + * + * @param accessToken - The access token to blacklist + * @returns Promise that resolves when the token has been blacklisted + * @throws InvalidTokenError if the token is invalid or not an access token + */ + public async blacklistAccessToken(accessToken: string): Promise { + // Verify and decode the token + const { valid, payload } = await this.verifyToken(accessToken, true); + + if (!valid || !payload) { + throw new InvalidTokenError('Invalid access token'); + } + + // Ensure it's an access token + if (payload.type !== tokenTypeSchema.enum.ACCESS) { + throw new InvalidTokenError('Token is not an access token'); + } + + // Get expiry time from the payload + const exp = (payload as jwt.JwtPayload).exp; + if (!exp) { + throw new InvalidTokenError('Token has no expiry time'); + } + await this.db.connect(); + + // Blacklist the token + await this.db.accessBlacklistRepository.blacklistAccessToken( + payload.jti, + exp, + ); + } + + /** + * Creates a new MFA token with the provided payload + * @param payload - The MFA token payload to include in the JWT + * @param secret - The secret to sign the JWT with + * @returns The generated MFA token + */ + private async generateMFAToken( + payload: Omit, + secret: JWTSecret, + ): Promise { + return await this.generateToken( + payload, + secret, + TokenService.MFA_TOKEN_LIFESPAN_SECONDS, + true, // Always encrypt MFA tokens + ); + } + + /** + * Creates a new MFA token for a user + * @param userId - The user's ID to create the token for + * @param deviceId - The device ID to create the token for + * @param formattedKey - The user's mfa formatted key + * @returns The generated MFA token + */ + public async createMFAToken( + userId: ObjectIdOrString, + deviceId: string, + formattedKey?: MFAFormattedKey, + ): Promise { + const created = new Date(); + const createdSeconds = Math.floor(created.getTime() / 1000); + + const mfaTokenPayload: Omit = { + type: 'MFA', + userId: userId.toString(), + deviceId, + iat: createdSeconds, + jti: randomUUID(), + formattedKey, + }; + + const secret: JWTSecret = { + secret: process.env.JWT_SECRET!, // TODO: generate random one + secretId: '1', // TODO: rotate keys and store in DB + }; + + return await this.generateMFAToken(mfaTokenPayload, secret); + } + + /** + * Verify and decode an MFA token. This method will automatically blacklist + * the MFA token if it's valid so that it can't be used again + * @param token - The MFA token to verify + * @returns The decoded token payload if valid + * @throws InvalidTokenError if the token is invalid or has been blacklisted + */ + public async verifyMFAToken(token: string): Promise { + // Decrypt the token first + const decryptedToken = await this.decryptToken(token); + + // Verify the decrypted token + const result = await new Promise( + (resolve, reject) => { + jwt.verify( + decryptedToken, + process.env.JWT_SECRET!, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (err: Error | null, decoded: any) => { + if (err) reject(err); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + else resolve(decoded); + }, + ); + }, + ); + + const { success, data } = mfaTokenPayloadSchema.safeParse(result); + + if (!success) { + throw new InvalidTokenError('Invalid MFA token'); + } + + if (data.type !== mfaTokenTypeSchema.value) { + throw new InvalidTokenError('Token is not an MFA token'); + } + + await this.db.connect(); + // check token generation ID isn't blacklisted + const blacklisted = + await this.db.mfaBlacklistRepository.isMFATokenBlacklisted(data.jti); + if (blacklisted) { + throw new InvalidTokenError('MFA token has been blacklisted'); + } + + // valid, blacklist so it can't be used again + await this.blacklistMFAToken(data); + + return data; + } + + /** + * Blacklist an MFA token to prevent it from being used again + * @param token - The MFA token to blacklist + */ + private async blacklistMFAToken(token: MFATokenPayload): Promise { + const exp = token.exp; + if (!exp) { + throw new InvalidTokenError('Token has no expiry time'); + } + await this.db.connect(); + + await this.db.mfaBlacklistRepository.blacklistMFA(token.jti, exp); + } } diff --git a/api/src/services/db/db.ts b/api/src/services/db/db.ts index 55522ca..0923580 100644 --- a/api/src/services/db/db.ts +++ b/api/src/services/db/db.ts @@ -1,6 +1,10 @@ import { Db, MongoClient } from 'mongodb'; -import { TokenRepository } from './repositories/token'; -import { UserRepository } from './repositories/user'; +import { + AccessBlacklistRepository, + MFABlacklistRepository, + TokenRepository, +} from './repositories/token'; +import { SRPSessionRepository, UserRepository } from './repositories/user'; import { createClient, RedisClientType } from 'redis'; import { HouseholdRepository } from './repositories/household'; @@ -10,40 +14,126 @@ export class DatabaseService { protected static client: MongoClient; protected static db: Db; protected static redis: RedisClientType; + private static connectionPromise: Promise | null = null; // Repositories - private _userRepository: UserRepository; - private _tokenRepository: TokenRepository; - private _householdRepository: HouseholdRepository; + private _userRepository!: UserRepository; + private _srpRepository!: SRPSessionRepository; + private _tokenRepository!: TokenRepository; + private _accessBlacklistRepository!: AccessBlacklistRepository; + private _mfaBlacklistRepository!: MFABlacklistRepository; + private _householdRepository!: HouseholdRepository; constructor() { + // Start connection process in constructor + this.connect().catch((err) => { + console.error( + 'Failed to connect to databases during initialization:', + err, + ); + throw new Error('Failed to connect to databases during initialization'); + }); + } + + /** + * Connects to MongoDB and Redis, then initializes repositories. + * Ensures only one connection attempt is made regardless of how many times it's called. + */ + public async connect(): Promise { + // If we're already connecting or connected, return the existing promise + if (DatabaseService.connectionPromise) { + return DatabaseService.connectionPromise; + } + + // Create a new connection promise + DatabaseService.connectionPromise = this.establishConnections(); + + try { + // Wait for connections to be established + await DatabaseService.connectionPromise; + + // Initialize repositories after successful connection + this.initializeRepositories(); + } catch (error) { + // Reset connection promise on failure so a future call can try again + DatabaseService.connectionPromise = null; + throw error; + } + + return DatabaseService.connectionPromise; + } + + /** + * Establishes connections to MongoDB and Redis + */ + private async establishConnections(): Promise { + // Connect to both databases but don't use Promise.all because we want + // to know specifically which one failed if there's an error + await this.connectToMongoDB(); + await this.connectToRedis(); + } + + /** + * Connects to MongoDB + */ + private async connectToMongoDB(): Promise { if (!DatabaseService.db) { const client = new MongoClient(process.env.MONGODB_URL!, { // TODO: configure pool settings }); - client - .connect() - .then(() => { - console.log('Connected to MongoDB'); - }) - .catch((err) => { - console.error('Error connecting to MongoDB', err); - }); - DatabaseService.client = client; - DatabaseService.db = client.db(DB_NAME); + + try { + await client.connect(); + console.log('Connected to MongoDB'); + DatabaseService.client = client; + DatabaseService.db = client.db(DB_NAME); + } catch (err) { + console.error('Error connecting to MongoDB', err); + throw new Error('Error connecting to MongoDB'); + } } + } + + /** + * Connects to Redis + */ + private async connectToRedis(): Promise { if (!DatabaseService.redis) { + // Define connection options with a reasonable socket timeout DatabaseService.redis = createClient({ url: process.env.REDIS_URL, + socket: { + reconnectStrategy: (retries) => { + // Maximum number of retries + if (retries > 3) { + return new Error( + 'Redis connection failed after multiple attempts', + ); + } + // Exponential backoff with a maximum of 3 seconds + return Math.min(retries * 1000, 3000); + }, + connectTimeout: 5000, // 5 seconds timeout for initial connection + }, }); - DatabaseService.redis - .on('connect', () => { + // Create a promise that will reject if a connection error occurs + const errorPromise = new Promise((_, reject) => { + const errorHandler = (err: Error) => { + console.error('Redis connection error:', err); + reject(new Error(`Failed to connect to Redis: ${err.message}`)); + }; + + DatabaseService.redis.on('error', errorHandler); + + // Remove the error handler once connected successfully + DatabaseService.redis.once('connect', () => { console.log('Connected to Redis'); - }) - .on('error', (err) => { - console.error('Error connecting to Redis', err); - }) + DatabaseService.redis.removeListener('error', errorHandler); + }); + }); + + DatabaseService.redis .on('end', () => { console.log('Disconnected from Redis'); }) @@ -51,26 +141,54 @@ export class DatabaseService { console.log('Reconnecting to Redis'); }); - DatabaseService.redis - .connect() - .then(() => console.log('db connected')) - .catch((err) => { - console.error('db error', err); - throw err; - }); + try { + // Race between connection attempt and connection error + await Promise.race([DatabaseService.redis.connect(), errorPromise]); + console.log('Redis client connected'); + } catch (err) { + console.error('Error connecting to Redis client:', err); + // Clean up the failed Redis client + await DatabaseService.redis.disconnect().catch(() => {}); + // Reset Redis client in a type-safe way + DatabaseService.redis = undefined as unknown as RedisClientType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + throw new Error(`Error connecting to Redis: ${(err as any).message}`); + } } + } + /** + * Initializes all repository instances + */ + private initializeRepositories(): void { // Initialize repositories this._userRepository = new UserRepository( DatabaseService.client, DatabaseService.db, DatabaseService.redis, ); + this._srpRepository = new SRPSessionRepository( + DatabaseService.client, + DatabaseService.db, + DatabaseService.redis, + ); + this._tokenRepository = new TokenRepository( DatabaseService.client, DatabaseService.db, DatabaseService.redis, ); + this._accessBlacklistRepository = new AccessBlacklistRepository( + DatabaseService.client, + DatabaseService.db, + DatabaseService.redis, + ); + this._mfaBlacklistRepository = new MFABlacklistRepository( + DatabaseService.client, + DatabaseService.db, + DatabaseService.redis, + ); + this._householdRepository = new HouseholdRepository( DatabaseService.client, DatabaseService.db, @@ -79,13 +197,71 @@ export class DatabaseService { } get userRepository(): UserRepository { + if (!this._userRepository) { + throw new Error( + 'Database connection not established. Call connect() and await it before using repositories.', + ); + } return this._userRepository; } + get srpSessionRepository(): SRPSessionRepository { + if (!this._srpRepository) { + throw new Error( + 'Database connection not established. Call connect() and await it before using repositories.', + ); + } + return this._srpRepository; + } + get tokenRepository(): TokenRepository { + if (!this._tokenRepository) { + throw new Error( + 'Database connection not established. Call connect() and await it before using repositories.', + ); + } return this._tokenRepository; } + + get accessBlacklistRepository(): AccessBlacklistRepository { + if (!this._accessBlacklistRepository) { + throw new Error( + 'Database connection not established. Call connect() and await it before using repositories.', + ); + } + return this._accessBlacklistRepository; + } + + get mfaBlacklistRepository(): MFABlacklistRepository { + if (!this._mfaBlacklistRepository) { + throw new Error( + 'Database connection not established. Call connect() and await it before using repositories.', + ); + } + return this._mfaBlacklistRepository; + } + get householdRepository(): HouseholdRepository { + if (!this._householdRepository) { + throw new Error( + 'Database connection not established. Call connect() and await it before using repositories.', + ); + } return this._householdRepository; } + + /** Configures all of the databases collections */ + public async configureCollections(): Promise { + // Ensure we're connected before configuring collections + await this.connect(); + + await Promise.all([ + this.userRepository.configureCollection(), + this.srpSessionRepository.configureCollection(), + this.tokenRepository.configureCollection(), + this.accessBlacklistRepository.configureCollection(), + this.mfaBlacklistRepository.configureCollection(), + this.householdRepository.configureCollection(), + ]); + } } diff --git a/api/src/services/db/repo.ts b/api/src/services/db/repo.ts index 6445c4a..1428ecd 100644 --- a/api/src/services/db/repo.ts +++ b/api/src/services/db/repo.ts @@ -2,6 +2,7 @@ import { Document, MongoClient, Collection, Db } from 'mongodb'; import { RedisClientType } from 'redis'; export abstract class DatabaseRepository { + protected readonly db: Db; protected readonly client: MongoClient; protected readonly collection: Collection; protected readonly redis: RedisClientType; @@ -13,6 +14,7 @@ export abstract class DatabaseRepository { redis: RedisClientType, ) { this.client = client; + this.db = db; this.collection = db.collection(collectionName); this.redis = redis; } diff --git a/api/src/services/db/repositories/token.ts b/api/src/services/db/repositories/token.ts index 3887d8b..c35b4c4 100644 --- a/api/src/services/db/repositories/token.ts +++ b/api/src/services/db/repositories/token.ts @@ -1,17 +1,49 @@ -import assert from 'assert'; import { randomUUID } from 'crypto'; import { Db, MongoClient, ObjectId } from 'mongodb'; import { RedisClientType } from 'redis'; -import { TokenService } from '../../token'; import { DatabaseRepository } from '../repo'; +import { TokenService } from '../../auth/token'; +import { ObjectIdOrString, objectIdSchema } from '../../../schemas/obj-id'; /** - * Token generation id document. There may be multiple toke - * - * The current one will have blacklisted as false and no expiry - * The previous ones will have an expiry set + * Document representing a blacklisted access token in the database + */ +interface BlacklistedAccessTokenDoc { + /** + * The JWT ID of the access token + */ + jti: string; + /** + * When the token was blacklisted + */ + created: Date; + /** + * When the token expires + */ + expiry: Date; +} + +/** + * Document representing a blacklisted MFA token in the database + */ +interface BlacklistedMFAToken { + /** + * The JWT ID of the MFA token + */ + jti: string; + /** + * When the token expires + */ + expiry: Date; +} + +/** + * Token generation ID document. There may be multiple tokens per user/device. * - * The blacklisted ones will have blacklisted as true and an expiry + * States: + * - Current token: blacklisted=false, no expiry + * - Previous tokens: blacklisted=false, has expiry + * - Blacklisted tokens: blacklisted=true, has expiry */ interface TokenGenIdDoc { /** @@ -40,24 +72,78 @@ interface TokenGenIdDoc { blacklisted: boolean; } +const TOKENS_COLLECTION_NAME = 'tokens'; +const ACCESS_BLACKLIST_COLLECTION_NAME = 'blacklisted_access_tokens'; +const MFA_BLACKLIST_COLLECTION_NAME = 'mfa_token_blacklist'; + // TODO: integrate with token service to remove lifespan parameter /* revoked access tokens are stored in redis (some sort of version * included in payload so if server crashes it invalidates all access tokens * that way no revoked access tokens get unrevoked) */ export class TokenRepository extends DatabaseRepository { - protected static readonly BLACKLIST_REDIS_KEY = 'token-blacklist'; + protected static readonly GENERATION_IDS_BLACKLIST_REDIS_KEY = + 'token-blacklist'; constructor(client: MongoClient, db: Db, redis: RedisClientType) { - super(client, db, 'tokens', redis); + super(client, db, TOKENS_COLLECTION_NAME, redis); } - // TODO: implement configure collection + /** + * Configures the token collection by creating necessary indices. + * This method should be called during application initialization. + */ public async configureCollection(): Promise { - // create collection - // - // configure indices - // + try { + // Check if collection exists, if not MongoDB will create it automatically + const collections = await this.db + .listCollections({ name: TOKENS_COLLECTION_NAME }) + .toArray(); + if (collections.length === 0) { + await this.db.createCollection(TOKENS_COLLECTION_NAME); + } + + // Configure indices for TokenGenIdDoc collection + await Promise.all([ + // Index for querying by userId and deviceId (used in getUserTokenGenerationId) + this.collection.createIndex({ userId: 1, deviceId: 1 }), + + // Index for querying by tokenGenerationId (used in isTokenGenerationIdBlacklisted) + this.collection.createIndex({ tokenGenerationId: 1 }), + + // Compound index for the frequent query pattern including blacklisted status + this.collection.createIndex({ + userId: 1, + deviceId: 1, + blacklisted: 1, + }), + + // Index to filter by blacklisted status and expiry + this.collection.createIndex({ blacklisted: 1, expiry: 1 }), + + // TTL index to automatically remove expired tokens + this.collection.createIndex( + { expiry: 1 }, + { + expireAfterSeconds: 0, + partialFilterExpression: { expiry: { $exists: true } }, + }, + ), + + // Index for sorting by created date (used in getUserTokenGenerationId with sort) + this.collection.createIndex({ created: 1 }), + ]); + + console.log( + `Configured ${TOKENS_COLLECTION_NAME} collection with required indices`, + ); + } catch (error) { + console.error( + `Failed to configure ${TOKENS_COLLECTION_NAME} collection:`, + error, + ); + throw error; + } } /** @@ -75,7 +161,7 @@ export class TokenRepository extends DatabaseRepository { * @returns The user's token generation ID or null if it doesn't exist */ public async getUserTokenGenerationId( - userId: string, + userId: ObjectIdOrString, deviceId: string, ): Promise; @@ -87,7 +173,7 @@ export class TokenRepository extends DatabaseRepository { * @returns The user's token generation ID or null if it doesn't exist and upsert is false */ public async getUserTokenGenerationId( - userId: string, + userId: ObjectIdOrString, deviceId: string, upsert: false, ): Promise; @@ -99,7 +185,7 @@ export class TokenRepository extends DatabaseRepository { * @returns The user's token generation ID * */ public async getUserTokenGenerationId( - userId: string, + userId: ObjectIdOrString, deviceId: string, upsert: true, ): Promise; @@ -111,16 +197,14 @@ export class TokenRepository extends DatabaseRepository { * @returns The user's token generation ID or null if it doesn't exist and upsert is false */ public async getUserTokenGenerationId( - userId: string, + userId: ObjectIdOrString, deviceId: string, upsert?: boolean, ): Promise { - assert(ObjectId.isValid(userId), 'userId must be a valid ObjectId'); - // always get the latest token generation id const doc = await this.collection.findOne( { - userId: new ObjectId(userId), + userId: objectIdSchema.parse(userId), deviceId, blacklisted: false, $or: [{ expiry: { $exists: false } }, { expiry: undefined }], @@ -145,17 +229,15 @@ export class TokenRepository extends DatabaseRepository { * @returns the new token generation ID */ public async changeUserTokenGenerationId( - userId: string, + userId: ObjectIdOrString, deviceId: string, ): Promise { - assert(ObjectId.isValid(userId), 'userId must be a valid ObjectId'); - const expiry = this.tokenExpiry(); // update the latest document await this.collection.updateMany( { - userId: new ObjectId(userId), + userId: objectIdSchema.parse(userId), $or: [{ expiry: { $exists: false } }, { expiry: undefined }], }, { $set: { expiry } }, @@ -165,7 +247,7 @@ export class TokenRepository extends DatabaseRepository { const genId = this.generateTokenGenerationId(); const doc: TokenGenIdDoc = { - userId: new ObjectId(userId), + userId: objectIdSchema.parse(userId), tokenGenerationId: genId, deviceId, created: new Date(), @@ -182,10 +264,10 @@ export class TokenRepository extends DatabaseRepository { } /** - * Adds the token genearation IDs provided in docs to the blacklist cache - * @param docs - The documents to cache + * Adds the token generation IDs provided in docs to the Redis blacklist cache + * @param docs - Array of documents containing tokenGenerationId and expiry to cache */ - private async cacheBlacklist( + private async cacheGenIDBlacklist( docs: Pick[], ) { console.log('adding blacklisted genids to cache'); @@ -203,7 +285,7 @@ export class TokenRepository extends DatabaseRepository { } // key format: token-blacklist: - const key = `${TokenRepository.BLACKLIST_REDIS_KEY}:${doc.tokenGenerationId}`; + const key = `${TokenRepository.GENERATION_IDS_BLACKLIST_REDIS_KEY}:${doc.tokenGenerationId}`; // add to redis with TTL using SET // use '1' as the key since we don't care about the value we just want @@ -217,23 +299,27 @@ export class TokenRepository extends DatabaseRepository { return; } + /** + * Calculate the expiry date for a token based on the current time and token lifespan + * @returns The calculated expiry date + */ private tokenExpiry = (): Date => new Date(Date.now() + TokenService.ACCESS_TOKEN_LIFESPAN_SECONDS * 1000); /** - * Blacklist all a user's token generation ID to prevent all tokens from being - * used including access tokens. This method will also change the user's token - * generation ID. - * - * @param genId - The token generation ID to blacklist + * Blacklist all a user's token generation IDs to prevent all tokens from being + * used including access tokens. + * @param userId - The ID of the user whose tokens should be blacklisted */ - public async blacklistTokenGenerationIds(userId: string): Promise { + public async blacklistTokenGenerationIds( + userId: ObjectIdOrString, + ): Promise { const expiry = this.tokenExpiry(); // begin blacklisting all tokens in the db but don't wait const dbPromise = this.collection .updateMany( - { userId: new ObjectId(userId), blacklisted: false }, + { userId: objectIdSchema.parse(userId), blacklisted: false }, [ { $set: { @@ -263,19 +349,24 @@ export class TokenRepository extends DatabaseRepository { // get all of them as well to blacklist in redis const docs = await this.collection .find( - { userId: new ObjectId(userId) }, + { userId: objectIdSchema.parse(userId) }, { projection: { tokenGenerationId: 1, expiry: 1 } }, ) .toArray(); - await Promise.all([this.cacheBlacklist(docs), dbPromise]); + await Promise.all([this.cacheGenIDBlacklist(docs), dbPromise]); } + /** + * Check if a token generation ID is blacklisted in the Redis cache + * @param genId - The token generation ID to check + * @returns True if the token generation ID is blacklisted in cache, false otherwise + */ private async isTokenGenerationIdBlacklistedCache( genId: string, ): Promise { const result = await this.redis.get( - `${TokenRepository.BLACKLIST_REDIS_KEY}:${genId}`, + `${TokenRepository.GENERATION_IDS_BLACKLIST_REDIS_KEY}:${genId}`, ); return result !== null; @@ -313,11 +404,311 @@ export class TokenRepository extends DatabaseRepository { // add to redis cache to avoid another cache miss // this should be non blocking so don't await - this.cacheBlacklist([doc]).catch((e) => - console.error('error caching blacklist', e), + this.cacheGenIDBlacklist([doc]).catch((e) => + console.warn('Error blacklisting gen id:', e), ); // and return return true; } } + +export class AccessBlacklistRepository extends DatabaseRepository { + protected static readonly ACCESS_BLACKLIST_REDIS_KEY = + 'access-token-blacklist'; + + constructor(client: MongoClient, db: Db, redis: RedisClientType) { + super(client, db, ACCESS_BLACKLIST_COLLECTION_NAME, redis); + } + + /** + * Load all non-expired blacklisted access tokens from MongoDB into Redis cache + * This should be called on application startup to ensure Redis has all blacklisted tokens + */ + public async loadBlacklistToCache(): Promise { + const now = new Date(); + const docs = await this.collection + .find({ + expiry: { $gt: now }, + }) + .toArray(); + + console.log( + `Loading ${docs.length} access tokens to Redis blacklist cache`, + ); + + const promises = docs.map((doc) => { + return this.cacheAccessBlacklist( + doc.jti, + Math.floor(doc.expiry.getTime() / 1000), + ); + }); + + await Promise.all(promises); + } + + /** + * Configures the access token blacklist collection by creating necessary indices. + * This method should be called during application initialization. + */ + public async configureCollection(): Promise { + try { + // Check if collection exists, if not MongoDB will create it automatically + const collections = await this.db + .listCollections({ name: ACCESS_BLACKLIST_COLLECTION_NAME }) + .toArray(); + if (collections.length === 0) { + await this.db.createCollection(ACCESS_BLACKLIST_COLLECTION_NAME); + } + + // Configure indices + await Promise.all([ + // Unique index for querying by JTI (used in isAccessTokenBlacklisted) + this.collection.createIndex({ jti: 1 }, { unique: true }), + + // Index on created date for potential analytics or cleanup operations + this.collection.createIndex({ created: 1 }), + + // TTL index to automatically remove expired tokens + this.collection.createIndex( + { expiry: 1 }, + { + expireAfterSeconds: 0, + }, + ), + ]); + + console.log( + `Configured ${ACCESS_BLACKLIST_COLLECTION_NAME} collection with required indices`, + ); + } catch (error) { + console.error( + `Failed to configure ${ACCESS_BLACKLIST_COLLECTION_NAME} collection:`, + error, + ); + throw error; + } + } + + /** + * Blacklist a specific access token by its JTI. This will add the token to both + * the database and Redis cache. + * @param jti - The JWT ID of the access token to blacklist + * @param expirySeconds - When the token expires in seconds since epoch + */ + public async blacklistAccessToken( + jti: string, + expirySeconds: number, + ): Promise { + const ttlSeconds = expirySeconds - Math.floor(Date.now() / 1000); + + // Don't blacklist if already expired + if (ttlSeconds <= 0) { + return; + } + + const expiry = new Date(expirySeconds * 1000); + + // Add to MongoDB + const doc: BlacklistedAccessTokenDoc = { + jti, + created: new Date(), + expiry, + }; + + const [dbResult] = await Promise.all([ + // Add to MongoDB + this.collection.insertOne(doc), + // Add to Redis cache + this.cacheAccessBlacklist(jti, expirySeconds), + ]); + + if (!dbResult.acknowledged) { + throw new Error('Failed to blacklist access token in database'); + } + } + + /** + * Add an access token to the Redis blacklist cache + * @param jti - The JWT ID of the access token to blacklist + * @param expirySeconds - When the token expires in seconds since epoch + */ + private async cacheAccessBlacklist( + jti: string, + expirySeconds: number, + ): Promise { + const ttlSeconds = expirySeconds - Math.floor(Date.now() / 1000); + + // Don't blacklist if already expired + if (ttlSeconds <= 0) { + return; + } + + const key = `${AccessBlacklistRepository.ACCESS_BLACKLIST_REDIS_KEY}:${jti}`; + await this.redis.set(key, '1', { + EX: ttlSeconds, + }); + } + + /** + * Check if an access token is blacklisted by checking both Redis cache and MongoDB + * @param jti - The JWT ID to check + * @returns True if the token is blacklisted, false otherwise + */ + public async isAccessTokenBlacklisted(jti: string): Promise { + try { + // Only check Redis cache + return await this.isAccessTokenBlacklistedCache(jti); + } catch (e) { + console.error('Error checking redis cache for access token blacklist', e); + return false; + } + } + + /** + * Check if an access token is blacklisted by its JTI + * @param jti - The JWT ID to check + * @returns True if the token is blacklisted, false otherwise + */ + private async isAccessTokenBlacklistedCache(jti: string): Promise { + const key = `${AccessBlacklistRepository.ACCESS_BLACKLIST_REDIS_KEY}:${jti}`; + const result = await this.redis.get(key); + return result !== null; + } +} + +export class MFABlacklistRepository extends DatabaseRepository { + protected static readonly MFA_BLACKLIST_KEY = MFA_BLACKLIST_COLLECTION_NAME; + + constructor(client: MongoClient, db: Db, redis: RedisClientType) { + super(client, db, MFA_BLACKLIST_COLLECTION_NAME, redis); + } + + /** + * Load all non-expired blacklisted MFA tokens from MongoDB into Redis cache + * This should be called on application startup to ensure Redis has all blacklisted tokens + */ + public async loadBlacklistToCache(): Promise { + const now = new Date(); + const docs = await this.collection + .find({ + expiry: { $gt: now }, + }) + .toArray(); + + console.log(`Loading ${docs.length} MFA tokens to Redis blacklist cache`); + + const promises = docs.map((doc) => { + const ttlSeconds = Math.ceil((doc.expiry.getTime() - Date.now()) / 1000); + if (ttlSeconds <= 0) return Promise.resolve(); + + const key = `${MFABlacklistRepository.MFA_BLACKLIST_KEY}:${doc.jti}`; + return this.redis.set(key, '1', { + EX: ttlSeconds, + }); + }); + + await Promise.all(promises); + } + + /** + * Configures the MFA token blacklist collection by creating necessary indices. + * This method should be called during application initialization. + */ + public async configureCollection(): Promise { + try { + // Check if collection exists, if not MongoDB will create it automatically + const collections = await this.db + .listCollections({ name: MFA_BLACKLIST_COLLECTION_NAME }) + .toArray(); + if (collections.length === 0) { + await this.db.createCollection(MFA_BLACKLIST_COLLECTION_NAME); + } + + // Configure indices + await Promise.all([ + // Unique index for querying by JTI (used in isMFATokenBlacklisted) + this.collection.createIndex({ jti: 1 }, { unique: true }), + + // TTL index to automatically remove expired tokens + this.collection.createIndex( + { expiry: 1 }, + { + expireAfterSeconds: 0, + }, + ), + ]); + + console.log( + `Configured ${MFA_BLACKLIST_COLLECTION_NAME} collection with required indices`, + ); + } catch (error) { + console.error( + `Failed to configure ${MFA_BLACKLIST_COLLECTION_NAME} collection:`, + error, + ); + throw error; + } + } + + /** + * Blacklist an MFA token by adding it to both Redis cache and MongoDB storage + * @param jti - The JWT ID of the MFA token to blacklist + * @param expirySeconds - When the token expires in seconds since epoch + * @throws {Error} If the token fails to be blacklisted in the database + */ + public async blacklistMFA(jti: string, expirySeconds: number): Promise { + const expiry = new Date(expirySeconds * 1000); + // calculate TTL + const ttlSeconds = Math.ceil( + ((expiry ?? TokenService.MFA_TOKEN_LIFESPAN_SECONDS).getTime() - + Date.now()) / + 1000, + ); + + // don't add it if it's already expired + if (ttlSeconds <= 0) { + return; + } + + const doc: BlacklistedMFAToken = { + jti, + expiry, + }; + + // cache in Redis + const key = `${MFABlacklistRepository.MFA_BLACKLIST_KEY}:${jti}`; + const redisPromise = this.redis + .set(key, '1', { + EX: ttlSeconds, + }) + .catch((e) => console.warn('Error caching MFA token blacklist', e)); + + // add to db in case Redis fails + const dbPromise = this.collection.insertOne(doc).then((result) => { + return result.acknowledged; + }); + + const [dbResult] = await Promise.all([dbPromise, redisPromise]); + + if (!dbResult) { + throw new Error('Failed to blacklist MFA token in database'); + } + } + + /** + * Check if an MFA token is blacklisted by its JTI + * @param jti - The JWT ID to check + * @returns True if the token is blacklisted, false otherwise + */ + public async isMFATokenBlacklisted(jti: string): Promise { + try { + const key = `${MFABlacklistRepository.MFA_BLACKLIST_KEY}:${jti}`; + const existsInCache = await this.redis.get(key); + return existsInCache !== null; + } catch (e) { + console.error('Error checking redis cache for MFA token blacklist', e); + return false; + } + } +} diff --git a/api/src/services/db/repositories/user.ts b/api/src/services/db/repositories/user.ts index 636d959..775a694 100644 --- a/api/src/services/db/repositories/user.ts +++ b/api/src/services/db/repositories/user.ts @@ -1,35 +1,154 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import assert from 'assert'; -import { Db, MongoClient, ObjectId } from 'mongodb'; -import { randomInt } from 'crypto'; import { RedisClientType } from 'redis'; import { DatabaseRepository } from '../repo'; +import { Db, MongoClient, ObjectId } from 'mongodb'; +import { + Email, + InvalidUserError, + InvalidUserType, + RegisterData, + User, + UserWithId, + userWithIdSchema, +} from '../../../schemas/auth/user'; +import { ObjectIdOrString, objectIdSchema } from '../../../schemas/obj-id'; +import { + MFA, + MFAFormattedKey, + SRPJSONSession, + SRPJSONSessionSchema, + SRPMongoSession, + SRPMongoSessionSchema, + SRPSession, +} from '../../../schemas/auth/auth'; + +export interface UserDoc extends User { + _id?: ObjectId; + /** The user's mfa formatted key */ + mfaFormattedKey: string; + /** Whether the user has confirmed correctly setting up MFA */ + mfaConfirmed: boolean; + /** The user's salt for SRP */ + salt: string; + /** The user's verifier for SRP */ + verifier: string; +} -// TODO: create type +const USER_COLLECTION_NAME = 'users'; -export class UserRepository extends DatabaseRepository { +export class UserRepository extends DatabaseRepository { constructor(client: MongoClient, db: Db, redis: RedisClientType) { - super(client, db, 'users', redis); + super(client, db, USER_COLLECTION_NAME, redis); } - // TODO: implement configure collection + // TODO: create more indexes on relevant fields + /** + * Configures the user collection by creating necessary indices. + * This method should be called during application initialization. + */ public async configureCollection(): Promise { - // create collection - // - // configure indices - // + try { + // Check if collection exists, if not MongoDB will create it automatically + const collections = await this.db + .listCollections({ name: USER_COLLECTION_NAME }) + .toArray(); + if (collections.length === 0) { + await this.db.createCollection(USER_COLLECTION_NAME); + } + + // Configure indices + await Promise.all([ + // Create a unique index on email to ensure no duplicate accounts + this.collection.createIndex({ email: 1 }, { unique: true }), + ]); + + console.log( + `Configured ${USER_COLLECTION_NAME} collection with required indices`, + ); + } catch (error) { + console.error( + `Failed to configure ${USER_COLLECTION_NAME} collection:`, + error, + ); + throw error; + } } - // ! temp method - // TODO: replace with actual methods - public async getUserById(userId: string) { - assert(ObjectId.isValid(userId), 'userId must be a valid ObjectId'); + /** + * Initialises a document for the user in the database containing the user's + * authentication information and their data (email, etc.). Also sets the + * user's mfa as unconfirmed. + * @param email - The user's email + * @param salt - The user's salt + * @param verifier - The user's verifier + * @param mfaFormattedKey - The user's MFA formatted key + * @returns The user's id + */ + public async createUser( + data: RegisterData, + mfaFormattedKey: MFAFormattedKey, + ): Promise { + const result = await this.collection.insertOne({ + ...data, + // convert the verifier to a hex string + verifier: `0x${data.verifier.toString(16)}`, + mfaFormattedKey, + mfaConfirmed: false, + }); + + return result.insertedId; + } - const doc = await this.collection.findOne({ _id: new ObjectId(userId) }); + /** + * Get the user's SRP credentials + * @param email - The user's email + * @returns the user's salt and verifier + * @throws An InvalidUserError if the user does not exist + */ + public async getUserSRPCredentials( + email: Email, + ): Promise<{ userId: ObjectId; salt: string; verifier: string }> { + const result = await this.collection.findOne( + { + email, + }, + { projection: { _id: 1, salt: 1, verifier: 1 } }, + ); + if (!result) { + throw new InvalidUserError({ type: InvalidUserType.DOES_NOT_EXIST }); + } + return { userId: result._id, salt: result.salt, verifier: result.verifier }; + } + + /** + * Get's a user by their id + * @param userId - The id of the user to get + * @returns The user with the given id + * @throws Error if the user does not exist + */ + public async getUserById(userId: ObjectIdOrString): Promise { + const user = await this.collection.findOne({ + _id: objectIdSchema.parse(userId), + }); + if (!user) { + throw new InvalidUserError({ type: InvalidUserType.DOES_NOT_EXIST }); + } + return userWithIdSchema.parse(user); + } - return doc; + /** + * Get's a user by their email + * @param email - The email of the user to get + * @returns The user with the given id + * @throws Error if the user does not exist + */ + public async getUserByEmail(email: Email): Promise { + const user = await this.collection.findOne({ + email: email, + }); + if (!user) { + throw new InvalidUserError({ type: InvalidUserType.DOES_NOT_EXIST }); + } + return userWithIdSchema.parse(user); } /** @@ -37,20 +156,297 @@ export class UserRepository extends DatabaseRepository { * @param userId - The user to check * @returns true if the user exists, false otherwise */ - public async userExists(userId: string): Promise { - assert(ObjectId.isValid(userId), 'userId must be a valid ObjectId'); - const user = await this.collection.findOne({ _id: new ObjectId(userId) }); + public async userExists(userId: ObjectIdOrString): Promise { + const user = await this.collection.findOne( + { + _id: objectIdSchema.parse(userId), + }, + { projection: { _id: 1 } }, + ); return !!user; } /** - * @returns The user's id + *Checks if a user exists by their email + * @param email - The email to check + * @returns true if the user exists, false otherwise */ - public async createUser() { - const result = await this.collection.insertOne({ - email: `example${randomInt(99999)}@domain.com`, + public async userExistsEmail(email: Email): Promise { + const user = await this.collection.findOne( + { + email: email, + }, + { projection: { _id: 1 } }, + ); + return !!user; + } + + /** + * Checks if the user has confirmed MFA + * @param userId - The id of the user + * @returns true if the user has confirmed MFA, false otherwise + * @throws Error if the user does not exist + */ + public async isUserMFAConfirmed(userId: ObjectIdOrString): Promise { + const user = await this.collection.findOne( + { + _id: objectIdSchema.parse(userId), + }, + { projection: { mfaConfirmed: 1 } }, + ); + if (!user) { + throw new InvalidUserError({ type: InvalidUserType.DOES_NOT_EXIST }); + } + return user.mfaConfirmed ?? false; + } + + /** + * Get's the user's MFA formatted key + * @param userId - The id of the user + * @returns The user's MFA formatted key + * @throws Error if the user does not exist + */ + public async getUserMFA(userId: ObjectIdOrString): Promise { + const user = await this.collection.findOne( + { + _id: objectIdSchema.parse(userId), + }, + { projection: { mfaFormattedKey: 1, mfaConfirmed: 1 } }, + ); + if (!user) { + throw new InvalidUserError({ type: InvalidUserType.DOES_NOT_EXIST }); + } + return { formattedKey: user.mfaFormattedKey, confirmed: user.mfaConfirmed }; + } + + /** + * Set the user's MFA to be confirmed + * @param userId - The id of the user + * @returns true if the operation was successful + */ + public async confirmUserMFA(userId: ObjectIdOrString): Promise { + const result = await this.collection.updateOne( + { _id: objectIdSchema.parse(userId) }, + { $set: { mfaConfirmed: true } }, + ); + return result.acknowledged && result.matchedCount === 1; + } + + /** + * Get a user's document. This method should be used carefully, and it's + * results must be filtered and should never be returned tot he client without + * preprocessing as it contains sensitive authentication information for + * server-side use only. + * @param userId - The user's id + * @returns The user's document in the database + */ + public async getUserDoc(userId: ObjectIdOrString): Promise { + const result = await this.collection.findOne({ + _id: objectIdSchema.parse(userId), }); + if (!result) { + throw new InvalidUserError({ type: InvalidUserType.DOES_NOT_EXIST }); + } + return result; + } + + /** + * Get the user's document by their email. Similar to getUserDoc but uses the + * user's email. This method also should not be used to return data to the + * client without preprocessing. + * @param email - The user's email + * @returns The user's document + */ + public async getUserDocByEmail(email: string): Promise { + const result = await this.collection.findOne({ + email: email, + }); + if (!result) { + throw new InvalidUserError({ type: InvalidUserType.DOES_NOT_EXIST }); + } + return result; + } +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface SRPSessionDoc extends SRPMongoSession {} + +const SRP_SESSION_COLLECTION_NAME = 'srp-sessions'; +const SRP_SESSION_KEY_PREFIX = 'srp-auth-session'; +const SRP_SESSION_EXPIRY_SECONDS = 60 * 5; // 5 minutes + +export class SRPSessionRepository extends DatabaseRepository { + constructor(client: MongoClient, db: Db, redis: RedisClientType) { + super(client, db, SRP_SESSION_COLLECTION_NAME, redis); + } + + /** + * Load all valid SRP sessions from MongoDB into Redis cache. + * This should be called on application startup to ensure Redis has all active sessions. + * Sessions older than SRP_SESSION_EXPIRY_SECONDS are considered expired and won't be loaded. + */ + public async loadSessionsToCache(): Promise { + const cutoffTime = new Date(Date.now() - SRP_SESSION_EXPIRY_SECONDS * 1000); + + // Find all sessions that were created within the expiry window + const docs = await this.collection + .find({ + createdAt: { $gt: cutoffTime }, + }) + .toArray(); + + console.log(`Loading ${docs.length} SRP sessions to Redis cache`); + + const promises = docs.map(async (doc) => { + // Calculate remaining TTL + const createdAt = new Date(doc.createdAt); + const elapsedSeconds = Math.floor( + (Date.now() - createdAt.getTime()) / 1000, + ); + const remainingTTL = SRP_SESSION_EXPIRY_SECONDS - elapsedSeconds; + + // Only cache if there's remaining time + if (remainingTTL <= 0) return; + + try { + await this.redis.set( + `${SRP_SESSION_KEY_PREFIX}:${doc.email}`, + JSON.stringify(SRPJSONSessionSchema.parse(doc)), + { + EX: remainingTTL, + }, + ); + } catch (e) { + console.error(`Failed to cache SRP session ${doc.email}:`, e); + } + }); + + await Promise.all(promises); + } + + public async configureCollection(): Promise { + // Check if collection exists, if not MongoDB will create it automatically + const collections = await this.db + .listCollections({ name: USER_COLLECTION_NAME }) + .toArray(); + if (collections.length === 0) { + await this.db.createCollection(USER_COLLECTION_NAME); + } + + await Promise.all([ + // Create unique index on sessionId + this.collection.createIndex({ email: 1 }, { unique: true }), + // Create TTL index on createdAt for auto-expiration after SRP_SESSION_EXPIRY_SECONDS + this.collection.createIndex( + { createdAt: 1 }, + { expireAfterSeconds: SRP_SESSION_EXPIRY_SECONDS }, + ), + ]); + } + + /** + * Store's an auth session in the database. The session will expire after a + * set time. + * @param session - The session to store + * @returns A boolean indicating if the session was stored successfully + */ + public async storeSRPSession(session: SRPSession) { + // convert to json friendly format + const jsonSession = SRPJSONSessionSchema.parse({ + ...session, + createdAt: new Date(), + }); + + // store session in redis + const redisPromise = this.redis.set( + `${SRP_SESSION_KEY_PREFIX}:${session.email}`, + JSON.stringify(jsonSession), + { + EX: SRP_SESSION_EXPIRY_SECONDS, + }, + ); + + // store session in mongo as a backup (fail-safe) + const mongoPromise = this.collection.updateOne( + { userId: session.userId.toString() }, + { + $set: SRPMongoSessionSchema.parse(jsonSession), + }, + { upsert: true }, + ); + + try { + const redisResult = await redisPromise; + if (redisResult) { + return true; + } + } catch (e) { + console.error('Failed to store SRP session in redis:', e); + // ignore, try mongo + } + + try { + const mongoResult = await mongoPromise; + return ( + mongoResult.acknowledged && + mongoResult.matchedCount + + mongoResult.modifiedCount + + mongoResult.upsertedCount > + 0 + ); + } catch (e) { + console.error('Failed to store SRP session in mongo:', e); + } + return false; + } + + /** + * Get a user's SRP session if it exists + * @param userId - The user's id + * @returns The auth session if it exists. If it doesn't undefined will be + * returned + */ + public async getSRPSession( + email: Email, + ): Promise { + // check redis first + const redisResult = await this.redis.get( + `${SRP_SESSION_KEY_PREFIX}:${email}`, + ); + + if (redisResult) { + try { + return SRPJSONSessionSchema.parse(JSON.parse(redisResult)); + } catch (e) { + console.error('Failed to parse SRP session from redis', e); + // ignore, try mongo + } + } + + const session = await this.collection.findOne({ + email: email, + }); + if (!session) { + return; + } + return SRPJSONSessionSchema.parse(session); + } + + /** + * Delete a user's SRP session + * @param email - The user's email + */ + public async deleteSRPSession(email: Email): Promise { + try { + await this.redis.del(`${SRP_SESSION_KEY_PREFIX}:${email}`); + } catch (e) { + console.error('Failed to delete SRP session from redis:', e); + } - return result.insertedId.toString(); + try { + await this.collection.deleteOne({ email: email }); + } catch (e) { + console.error('Failed to delete SRP session from mongo:', e); + } } } diff --git a/api/src/services/household.ts b/api/src/services/household.ts index 2860555..63e045f 100644 --- a/api/src/services/household.ts +++ b/api/src/services/household.ts @@ -15,6 +15,7 @@ export class HouseholdService { public async createHousehold( data: Omit, ): Promise { + await this.db.connect(); return this.db.householdRepository.createHousehold(data); } @@ -24,6 +25,7 @@ export class HouseholdService { * @returns */ public async getHousehold(id: string): Promise { + await this.db.connect(); return this.db.householdRepository.getHouseholdById(id); } public async addMember( @@ -31,6 +33,7 @@ export class HouseholdService { memberId: string, ownerId: string, ): Promise { + await this.db.connect(); return this.db.householdRepository.addMember( householdId, memberId, @@ -43,6 +46,7 @@ export class HouseholdService { memberId: string, ownerId: string, ): Promise { + await this.db.connect(); return this.db.householdRepository.removeMember( householdId, memberId, @@ -54,6 +58,7 @@ export class HouseholdService { response: boolean, userId: string, ): Promise { + await this.db.connect(); return this.db.householdRepository.processInviteResponse( inviteId, response, @@ -64,6 +69,7 @@ export class HouseholdService { householdId: string, ownerId: string, ): Promise { + await this.db.connect(); await this.db.householdRepository.deleteHousehold(householdId, ownerId); } public async addRoom( @@ -71,6 +77,7 @@ export class HouseholdService { roomData: HouseholdRoom, ownerId: string, ): Promise { + await this.db.connect(); return this.db.householdRepository.addRoom(householdId, roomData, ownerId); } public async removeRoom( @@ -78,6 +85,7 @@ export class HouseholdService { roomId: string, ownerId: string, ): Promise { + await this.db.connect(); return this.db.householdRepository.removeRoom(householdId, roomId, ownerId); } public async changeUserRole( @@ -86,6 +94,7 @@ export class HouseholdService { newRole: HouseholdMember, ownerId: string, ): Promise { + await this.db.connect(); return this.db.householdRepository.changeUserRole( householdId, memberId, @@ -99,6 +108,7 @@ export class HouseholdService { action: 'add' | 'remove', ownerId: string, ): Promise { + await this.db.connect(); return this.db.householdRepository.manageRooms( householdId, roomId, diff --git a/api/src/services/mfa.ts b/api/src/services/mfa.ts deleted file mode 100644 index 3dc0f40..0000000 --- a/api/src/services/mfa.ts +++ /dev/null @@ -1,182 +0,0 @@ -import * as authenticator from 'authenticator'; -import { MFAToken } from '../schemas/mfa'; - -// TODO: replace temp classes with actual classes - -// ! temp interface -interface MFAFormattedKey { - formattedKey: string; - confirmed: boolean; -} - -// ! temp interface -interface User { - _id: string; - email: string; -} - -// ! temp class -class UserRepository { - private static readonly m: { [key: string]: MFAFormattedKey } = {}; - - // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars - public async userExists(userId: string): Promise { - return true; - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async getUserById(userId: string) { - const testUser: User = { - _id: userId, - email: 'example@domain.com', - }; - return testUser; - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async getUserMFAformattedKey(userId: string) { - console.log('userid', userId, 'm', UserRepository.m); - - if (!UserRepository.m[userId]) { - return null; - } - return UserRepository.m[userId]; - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async isUserMFAInitialised(userId: string) { - return !!UserRepository.m[userId]; - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async saveUserMFAUnconfirmed(userId: string, formattedKey: string) { - console.log('mfa initialised'); - - UserRepository.m[userId] = { formattedKey, confirmed: false }; - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async confirmUserMFA(userId: string) { - if (!UserRepository.m[userId]) { - throw new Error('User did not setup MFA'); - } - - UserRepository.m[userId].confirmed = true; - } -} - -// ! temp class -export class DatabaseService { - private readonly _userRepo; - - constructor() { - this._userRepo = new UserRepository(); - } - - get userRepository(): UserRepository { - return this._userRepo; - } -} - -export class MFAService { - private readonly db; - - constructor(db: DatabaseService) { - this.db = db; - } - - /** - * Initialises MFA for the user. It must be confirmed by {@link finishInitMFASetup} before it can be used. They also must not have already setup MFA. - * @param userId - The user for whom to init MFA - * @returns The formattedKey to be used for MFA - * @throws Error if the user already has MFA setup - * @throws Error if the user does not exist - * @throws Error if the db operation fails - */ - public async initUserMFA(userId: string): Promise<{ - formattedKey: string; - uri: string; - }> { - const user = await this.db.userRepository.getUserById(userId); - - if (!(await this.db.userRepository.userExists(userId))) { - throw new Error('User does not exist'); - } - - if (await this.db.userRepository.isUserMFAInitialised(userId)) { - throw new Error('User already has MFA setup'); - } - - const formattedKey = authenticator.generateKey(); - const uri = authenticator.generateTotpUri( - formattedKey, - user.email, - 'Smartify', - 'SHA1', - 6, - 30, - ); - - await this.db.userRepository.saveUserMFAUnconfirmed(userId, formattedKey); - - // TODO: check if result is successful once implemented with actual classes - - return { formattedKey, uri }; - } - - /** - * Verifies the user's MFA token is correct - * @param formattedKey - The user's formatted key - * @param userToken - The token the user entered - * @returns True if the token is correct, false otherwise - */ - private verifyToken(formattedKey: string, userToken: MFAToken): boolean { - const result = authenticator.verifyToken(formattedKey, userToken); - return result != null; - } - - /** - * Finish the users MFA setup by confirming the user has correctly setup MFA - * @param userId - The id of the user - * @param token - The token the user entered to confirm they've correctly setup MFA - * @returns A boolean indicating if the code is correct or not, and hence, if the MFA setup was successful - * @throws Error if the user does not exist - * @throws Error if the user did not setup MFA - * @throws Error if the user already confirmed MFA - */ - public async finishInitMFASetup( - userId: string, - token: MFAToken, - ): Promise { - if (!(await this.db.userRepository.userExists(userId))) { - throw new Error('User does not exist'); - } - const userMFA = await this.db.userRepository.getUserMFAformattedKey(userId); - if (!userMFA || !userMFA.formattedKey) { - throw new Error('User did not setup MFA'); - } - if (userMFA.confirmed) { - throw new Error('User already confirmed MFA'); - } - if (!this.verifyToken(userMFA.formattedKey, token)) { - return false; - } - await this.db.userRepository.confirmUserMFA(userId); - return true; - } - - /** - * Verify the user's MFA token. - * @param userId - The user's id - * @param token - The token the user entered (6 digit string) - * @returns whether the token is correct or not - * @throws Error if the user did not setup MFA - */ - public async verifyMFA(userId: string, token: MFAToken): Promise { - const userMFA = await this.db.userRepository.getUserMFAformattedKey(userId); - if (!userMFA || !userMFA.formattedKey || !userMFA.confirmed) { - throw new Error('User did not setup MFA'); - } - return this.verifyToken(userMFA.formattedKey, token); - } -} diff --git a/api/src/util.ts b/api/src/util.ts new file mode 100644 index 0000000..869f90b --- /dev/null +++ b/api/src/util.ts @@ -0,0 +1,70 @@ +import { Response } from 'express'; +import { z } from 'zod'; + +/** + * Validates the schema in a request, and returns the parsed object. + * Will send a 400 response if the schema is invalid. + * @returns The schema if valid, undefined if invalid + */ +export function validateSchema( + res: Response, + schema: T, + data: unknown, +): z.infer | undefined { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return schema.parse(data); + } catch (_) { + console.log(_); + + res.status(400).send('Invalid Request'); + return undefined; + } +} + +/** + * Try's to run a controller, and sends a 500 response if it fails. + * @param res - The response object + * @param controller - the controller to run + * @param customErrorHandling - A function to add custom error handling. It + * returns a true if the error was handled, and false otherwise. This function + * will be called before the default error handling. + */ +export function tryAPIController( + res: Response, + controller: () => Promise, + customErrorHandling?: (err: unknown) => boolean, +) { + const handleError = (err: unknown) => { + if (customErrorHandling && customErrorHandling(err)) { + return; + } + console.log('err caught in tryAPIController'); + console.error(err); + res.status(500).send({ error: 'internal server error' }); + }; + try { + controller().catch(handleError); + } catch (e) { + handleError(e); + } +} + +export function bigIntModPow(base: bigint, exp: bigint, mod: bigint): bigint { + let result = BigInt(1); + base = base % mod; + while (exp > BigInt(0)) { + if (exp % BigInt(2) === BigInt(1)) { + result = (result * base) % mod; + } + exp = exp >> BigInt(1); + base = (base * base) % mod; + } + return result; +} + +//// test bigintmodpow +//export function testBigIntModPow() { +// console.log(bigIntModPow(BigInt(2), BigInt(3), BigInt(5))); // Should output 3n (2^3 = 8, 8 mod 5 = 3) +// console.log(bigIntModPow(BigInt(3), BigInt(4), BigInt(7))); // Should output 4n (3^4 = 81, 81 mod 7 = 4) +//} diff --git a/api/tsconfig.eslint.json b/api/tsconfig.eslint.json index 9b84481..cbd86eb 100644 --- a/api/tsconfig.eslint.json +++ b/api/tsconfig.eslint.json @@ -1,5 +1,15 @@ { "extends": "./tsconfig.json", - "include": ["src/**/*", "jest.config.ts", "eslint.config.mjs"], + "include": [ + "src/**/*", + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.tsx", + "src/**/*.js", + "src/**/*.jsx", + "jest.config.ts", + "eslint.config.mjs", + "src/util.d.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/api/tsconfig.json b/api/tsconfig.json index c015247..f0e5404 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -108,6 +108,6 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, - "include": ["src/**/*", "test"], + "include": ["src/**/*", "src/**/*.d.ts", "test"], "exclude": ["node_modules", "**/*.spec.ts"] } diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 8e3ca5d..15cada4 100644 --- a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -59,6 +59,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/app/lib/main.dart b/app/lib/main.dart index 20aeba5..275b853 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,8 +1,13 @@ +// ignore_for_file: unused_local_variable, prefer_const_declarations, avoid_print, constant_identifier_names, non_constant_identifier_names + import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:smartify/screens/auth/sign_in.dart'; +import 'models/mfa.dart'; +import 'models/user.dart'; import 'services/mfa.dart'; import 'widgets/mfa_code.dart'; @@ -18,6 +23,53 @@ class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { + ////const email = 'hady@gmail.com'; + ////const password = 'password'; + ////////final salt = SRP.generateSalt(); + ////const salt = 'e6eb7979923ebb117785a56ab84f94ff'; + ////final verifier = SRP.deriveVerifier(email, password, salt); + //// + ////const BString = + //// '0x586d9483b54b8c09f987ee5e1c49e6053c8b5289aedf25176ff84280bd5faa8d6a357f1757165d8292926268fde0d4e0774db44d4a2e1babf96197df397a655377bd870f5dbede11cb00b16bb33350c58b6d10300cb4ffc8d83f7a3637616bbcf1f0d66a04b16674be9b6a7df64c8337fc4c07a6315934292d5e453db68dfe751b1c902fb5190aff696af17023a1b7159cc04c8ae5d28bdfd5b9a682e4762b15c28ac795416dc33254393a41584b34d8c8717cc2398621630375dc1e43e34c0a1c63f23efc57a07a363df4663693a0e2eae5c047f8fb2d1cdfe61fef47ccfc72ab96013272bc2a8aff184e94a77e95b393eccb37aa89dfdc24d3412a9a473643'; + ////final B = BigInt.parse(BString); + //// + ////const aString = + //// '0xf1397a8f372866bf49ddf9a661e27e9612f88866ed7be78e741b90abcbb6ee77'; + ////final a = BigInt.parse(aString); + //// + ////final result = SRP.respondToAuthChallenge(email, password, salt, a, B); + //// + ////print('A: 0x${result.A.toRadixString(16)}'); + ////print('Mc: 0x${result.M.toRadixString(16)}'); + // + //// api test + //const email = 'hady@gmail.com'; + //const password = 'password'; + ////const salt = 'e6eb7979923ebb117785a56ab84f94ff'; + ////final salt = SRP.generateSalt(); + ////print(salt); + //const salt = 'e231b0fd7f9106a2ca2cc2fc81167495'; + //final verifier = SRP.deriveVerifier(email, password, salt); + ////print('verifier: 0x${verifier.toRadixString(16)}'); + //final BString = + // '0x982d84e26544a62e722528d94c60dac8082dfd38b33a9558ebd9db190cd5a4fcefa13b466447b03da5ee7e72328d46e2b3d3f523b6dd1d8b3a0bceb628b093b84ba34be6577d075f819cc10a6b5beb17d2b9778b026bed4260c3ddf3ad15c132479828691c7db816de2cdf067df0b78733553c88fa7bcc5d7149bf426a943b28dfe68bd17dab93b19fa4ee57c5113e410e34b302d4ecb72dc8fcf6b00bbb6150d7504f2123868e95e06761ac6d21c3aaa6f1f3b7a35bba1828af129247f5f615eb27e88a3c5de1dc0a3c81b70d2f56f7022266d7024955b945326e5c7ed9921cb3463e5d6d9c723784c53dedce35c9d3875dc29f00fe695d41bc5e5cdde35d04'; + // + ////const BString = + //// '0x4301fd8a79bda87196fd2aa6a47d06642d6eaceb572aba4d5b48e6036cb87448cc6e6d2c2bd232308dd48f69fb9aa3526fc1bccd00c32b9fa023151e95147b8290792bd2d4927e547f10237b829c03cb783c033a9fe3bb89c869cc0b6b772f0f778785b0de5f7e2f20ca78bbca62e26dbd731df08f1bd1d5703ea865e0d86ef5925413536d1ff28200b129cc39084ecc465280dec04aa11253650809150ef8a542cab6a0e2dabd8333f84748b6f3d5f85d552eeba800fe24637df378925312d8ea9f4cceee4dfce82f623cdf59a6683eb8b8d244a994a20c52bb88b17f5996355434269975fd46cc77fdcf385d414e2fbcef730f9291c6d750eeee947ea3467a'; + //final B = BigInt.parse(BString); + ////final a = SRP.generatePrivateKey(); + //final a = BigInt.parse( + // '0x31fbe9b30e7a5037f37137483b0909e0dce1c4ac4de47a269879e4481044d282'); + //final A = SRP.derivePublicKey(a); + // + ////final a = BigInt.parse( + //// '0xf1397a8f372866bf49ddf9a661e27e9612f88866ed7be78e741b90abcbb6ee77'); + //// + //final result = SRP.respondToAuthChallenge(email, password, salt, a, B); + // + //print('A: 0x${result.A.toRadixString(16)}'); + //print('Mc: 0x${result.M.toRadixString(16)}'); + return MaterialApp( title: 'Flutter Demo', theme: ThemeData( @@ -39,7 +91,7 @@ class MyApp extends StatelessWidget { colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), - home: const MFATest(), + home: const SignInScreen(), ); } } diff --git a/app/lib/models/mfa.dart b/app/lib/models/mfa.dart new file mode 100644 index 0000000..8fa5f5e --- /dev/null +++ b/app/lib/models/mfa.dart @@ -0,0 +1,13 @@ +class MFAFormattedKey { + final String _formattedKey; + final String _qrCodeUri; + MFAFormattedKey(this._formattedKey, this._qrCodeUri); + + // from json + MFAFormattedKey.fromJson(Map json) + : _formattedKey = json['formattedKey'], + _qrCodeUri = json['uri']; + + String get formattedKey => _formattedKey; + String get qrCodeUri => _qrCodeUri; +} diff --git a/app/lib/models/user.dart b/app/lib/models/user.dart new file mode 100644 index 0000000..0c07520 --- /dev/null +++ b/app/lib/models/user.dart @@ -0,0 +1,29 @@ +enum Sex { + male, + female, +} + +class User { + final String id; + final String email; + final DateTime? dob; + final Sex? sex; + + User(this.id, this.email, {this.dob, this.sex}); + + User.fromJson(Map json) + : id = json['id'], + email = json['email'], + dob = json['dob'] != null ? DateTime.parse(json['dob']) : null, + sex = Sex.male; // TODO: check if 'm' or 'f' + + // convert to json + Map toJson() { + return { + 'id': id, + 'email': email, + 'dob': dob?.toIso8601String(), + 'sex': sex == Sex.male ? 'm' : 'f', + }; + } +} diff --git a/app/lib/screens/auth/sign_in.dart b/app/lib/screens/auth/sign_in.dart new file mode 100644 index 0000000..f34e49d --- /dev/null +++ b/app/lib/screens/auth/sign_in.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:smartify/services/auth.dart'; + +class SignInScreen extends StatefulWidget { + const SignInScreen({super.key}); + + @override + State createState() => _SignInScreenState(); +} + +class _SignInScreenState extends State { + final GlobalKey _formKey = GlobalKey(); + + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Sign In'), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextFormField( + controller: _emailController, + decoration: const InputDecoration(hintText: 'Email'), + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + decoration: const InputDecoration(hintText: 'Password'), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () async { + final as = AuthService(); + final result = + await as.register('hady@gmail.com', 'password'); + debugPrint('result: $result'); + }, + child: const Text('Register'), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () async { + final as = AuthService(); + // TODO: update to get from form fields + await as.signIn('hady@gmail.com', 'password'); + }, + child: const Text('Sign In'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/services/auth.dart b/app/lib/services/auth.dart new file mode 100644 index 0000000..bb7e4c5 --- /dev/null +++ b/app/lib/services/auth.dart @@ -0,0 +1,303 @@ +import 'dart:convert'; // for the utf8.encode method +import 'dart:math'; +import 'package:crypto/crypto.dart'; +import 'package:convert/convert.dart'; +import 'package:http/http.dart' as http; + +// TODO: get uri from environment variables +// TODO: get device id dynamically +class AuthService { + static String? mfaToken; + static String? accessToken; + static String? refreshToken; + static String? idToken; + + Future<({String mfaFormattedKey, String mfaQRUri})?> register( + String email, String password, + {DateTime? dob, String? sex}) async { + try { + final salt = SRP.generateSalt(); + final verifier = SRP.deriveVerifier(email, password, salt); + final body = { + 'email': email, + 'salt': salt, + 'verifier': '0x${verifier.toRadixString(16)}', + }; + if (dob != null) { + body['dob'] = dob.toIso8601String(); + } + if (sex != null) { + body['sex'] = sex; + } + final uri = Uri.parse('http://localhost:3000/api/auth/register'); + final response = await http.post(uri, body: jsonEncode(body), headers: { + 'Content-Type': 'application/json', + 'x-device-id': '1234', + }); + + if (response.statusCode >= 200 && response.statusCode < 300) { + // get json body + final responseBody = jsonDecode(response.body) as Map; + try { + //final userId = responseBody['userId'] as String; + final mfaFormattedKey = responseBody['mfaFormattedKey'] as String?; + final mfaQRUri = responseBody['mfaQRUri'] as String?; + if (mfaFormattedKey == null || mfaQRUri == null) { + print('Error registering: MFA key or QR URI not found'); + return null; + } + + return (mfaQRUri: mfaQRUri, mfaFormattedKey: mfaFormattedKey); + } catch (e) { + print('Error getting body: $e'); + } + } else { + if (response.body.isNotEmpty) { + final error = jsonDecode(response.body) as Map; + print('Error registering: ${error['message'] ?? error['error']}'); + } + } + } catch (e) { + print('Error registering: $e'); + } + return null; + } + + /// Initiates an authentication session for the user with the given [email]. + Future<({String salt, BigInt B})?> _initiateAuthSession(String email) async { + try { + // initiate auth session + final uri = Uri.parse('http://localhost:3000/api/auth/init?email=$email'); + final response = await http.post(uri, headers: { + 'x-device-id': '1234', + }); + if (response.statusCode >= 200 && response.statusCode < 300) { + // get body + final responseBody = jsonDecode(response.body) as Map; + try { + final salt = responseBody['salt'] as String; + final BString = responseBody['B'] as String; + + // convert B to BigInt + final B = BigInt.parse(BString.substring(2), radix: 16); + + return (salt: salt, B: B); + } catch (e) { + print('Error getting body: $e'); + } + } else { + // error + if (response.body.isNotEmpty) { + final error = jsonDecode(response.body) as Map; + print('Error registering: ${error['message'] ?? error['error']}'); + } + } + } catch (e) { + print('Error signing in: $e'); + } + return null; + } + + /// Signs the user in with the given [email] and [password]. The user must + /// complete the MFA challenge after this to complete the sign in process. + Future signIn(String email, String password) async { + try { + // create an auth session + final session = await _initiateAuthSession(email); + if (session == null) { + print('Error initiating auth session'); + return; + } + + // generate private key + final a = SRP.generatePrivateKey(); + final proof = SRP.respondToAuthChallenge( + email, password, session.salt, a, session.B); + final body = { + 'email': email, + 'A': '0x${proof.A.toRadixString(16)}', + 'Mc': '0x${proof.M.toRadixString(16)}', + }; + + final uri = Uri.parse('http://localhost:3000/api/auth/login'); + final response = await http.post(uri, body: jsonEncode(body), headers: { + 'Content-Type': 'application/json', + 'x-device-id': '1234', + }); + + if (response.statusCode >= 200 && response.statusCode < 300) { + // get body + final responseBody = jsonDecode(response.body) as Map; + final MsString = responseBody['Ms'] as String; + final Ms = BigInt.parse(MsString.substring(2), radix: 16); + print('Ms: $Ms'); + + print('logged in successfully'); + } else { + // error + if (response.body.isNotEmpty) { + final error = jsonDecode(response.body) as Map; + print('Error registering: ${error['message'] ?? error['error']}'); + } + } + } catch (e) { + print('Error signing in: $e'); + } + } +} + +/// Client SRP methods +class SRP { + // ignore: non_constant_identifier_names + static String SRP_N_HEX = (""" + AC6BDB41 324A9A9B F166DE5E 1389582F AF72B665 1987EE07 FC319294 3DB56050 A37329CB B4A099ED 8193E075 7767A13D D52312AB 4B03310D CD7F48A9 DA04FD50 E8083969 EDB767B0 CF609517 9A163AB3 661A05FB D5FAAAE8 2918A996 2F0B93B8 55F97993 EC975EEA A80D740A DBF4FF74 7359D041 D5C33EA7 1D281E44 6B14773B CA97B43A 23FB8016 76BD207A 436C6481 F1D2B907 8717461A 5B9D32E6 88F87748 544523B5 24B0D57D 5EA77A27 75D2ECFA 032CFBDB F52FB378 61602790 04E57AE6 AF874E73 03CE5329 9CCC041C 7BC308D8 2A5698F3 A8D0C382 71AE35F8 E9DBFBB6 94B5C803 D89F7AE4 35DE236D 525F5475 9B65E372 FCD68EF2 0FA7111F 9E4AFF73""") + .trim() + .replaceAll(RegExp(r'[\s\r\n]+'), ''); + // ignore: non_constant_identifier_names + static int SRP_GENERATOR = 2; + // ignore: non_constant_identifier_names + static int SRP_K = 3; + + // Prime number used as modulus + static BigInt get N => BigInt.parse(SRP_N_HEX, radix: 16); + // Generator + static BigInt get g => BigInt.from(SRP_GENERATOR); + // Multiplier parameter + static BigInt get k => BigInt.from(SRP_K); + + // helpers + + static String hashString(String input) { + final bytes = utf8.encode(input); + final digest = sha256.convert(bytes); + return digest.toString(); + } + + /// Hashes the input and returns it as a BigInt + static BigInt hashToBigInt(String input) { + return BigInt.parse(hashString(input), radix: 16); + } + + static generateSalt([int length = 16]) { + final random = Random.secure(); + final saltBytes = List.generate(length, (_) => random.nextInt(256)); + return hex.encode(saltBytes); + } + + static BigInt _randomBigInt(int length) { + final random = Random.secure(); + final bytes = List.generate(length, (_) => random.nextInt(256)); + return BigInt.parse(hex.encode(bytes), radix: 16); + } + + static BigInt generatePrivateKey() { + return _randomBigInt(32); + } + + static BigInt deriveVerifier(String email, String password, String salt) { + final x = calculateX(email, password, salt); + return g.modPow(x, N); + } + + static BigInt derivePublicKey(BigInt a) { + final A = g.modPow(a, N); + if (A % N == BigInt.zero) { + throw Exception("Invalid client public key: A mod N equals 0"); + } + return A; + } + + static BigInt calcluateU(BigInt A, BigInt B) { + final concatenated = A.toRadixString(16) + B.toRadixString(16); + return BigInt.parse(hashString(concatenated), radix: 16); + } + + static BigInt calculateX(String email, String password, String salt) { + if (email.isEmpty || password.isEmpty || salt.isEmpty) { + throw Exception("Email, password, and salt must not be empty"); + } + + // First H(email | ':' | password) + final identity = hashString('$email:$password'); + + // convert salt from hex to buffer + final saltBuffer = hex.decode(salt); + + // convert identity to buffer + final identityBuffer = hex.decode(identity); + + // concatenate salt with identity hash bytes + final saltedIdentity = [...saltBuffer, ...identityBuffer]; + + // final hash H(salt | H(email | ':' | password)) + final result = hashString(hex.encode(saltedIdentity)); + return BigInt.parse(result, radix: 16); + } + + static BigInt calculateClientProof( + String email, String salt, BigInt A, BigInt B, BigInt K) { + // Hash N and g + // ignore: non_constant_identifier_names + final HN = hashToBigInt(N.toRadixString(16)); + // ignore: non_constant_identifier_names + final Hg = hashToBigInt(g.toRadixString(16)); + + // XOR operation (convert to buffer for easier XOR) + final hnBuffer = hex.decode(HN.toRadixString(16).padLeft(64, '0')); + final hgBuffer = hex.decode(Hg.toRadixString(16).padLeft(64, '0')); + + final xorBuffer = List.generate( + hnBuffer.length, + (i) => hnBuffer[i] ^ (i < hgBuffer.length ? hgBuffer[i] : 0), + ); + + // hash email + // ignore: non_constant_identifier_names + final Hemail = hashString(email); + + // concat + final concatString = hex.encode(xorBuffer) + + Hemail + + salt + + A.toRadixString(16) + + B.toRadixString(16) + + K.toRadixString(16); + + return hashToBigInt(concatString); + } + + static ({BigInt A, BigInt M, BigInt K}) respondToAuthChallenge( + String email, String password, String salt, BigInt a, BigInt B) { + // Step 1: derive public key + final A = derivePublicKey(a); + + // Step 2: calculate u = H(A | B) + final u = calcluateU(A, B); + + // Step 3: calculate x = H(salt | H(email | ':' | password)) + final x = calculateX(email, password, salt); + + // Step 4: calculate S = (B - k * g^x)^(a + u * x) % N + final gx = g.modPow(x, N); + final kgx = (k * gx) % N; + + var base = (B - kgx) % N; + if (base < BigInt.zero) base += N; + + final exponent = (a + (u * x)) % (N - BigInt.one); + + final S = base.modPow(exponent, N); + + // Step 5: calculate K = H(S) + final K = hashToBigInt(S.toRadixString(16)); + + // Step 6: calculate client proof M = H(H(N) XOR H(g) | H(email) | salt | A | B | K) + final M = calculateClientProof(email, salt, A, B, K); + + return ( + A: A, + M: M, + K: K, + ); + } +} diff --git a/app/lib/services/mfa.dart b/app/lib/services/mfa.dart index 3a86c94..de1d61c 100644 --- a/app/lib/services/mfa.dart +++ b/app/lib/services/mfa.dart @@ -2,43 +2,8 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'dart:convert'; -// ! temp -// TODO: replace with actual user -class User { - final String id; - final String email; - - User(this.id, this.email); - - // from json - User.fromJson(Map json) - : id = json['id'], - email = json['email']; - - // convert to json - Map toJson() { - return { - 'id': id, - 'email': email, - }; - } -} - -// ! temp class -// TODO: replace with actual data class -class MFAFormattedKey { - final String _formattedKey; - final String _qrCodeUri; - MFAFormattedKey(this._formattedKey, this._qrCodeUri); - - // from json - MFAFormattedKey.fromJson(Map json) - : _formattedKey = json['formattedKey'], - _qrCodeUri = json['uri']; - - String get formattedKey => _formattedKey; - String get qrCodeUri => _qrCodeUri; -} +import '../models/mfa.dart'; +import '../models/user.dart'; // TODO: remove user args and get auth from auth service class MFAService { diff --git a/app/lib/utils/srp_debug_utils.dart b/app/lib/utils/srp_debug_utils.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/lib/utils/srp_debug_utils.dart @@ -0,0 +1 @@ + diff --git a/app/lib/widgets/mfa_code.dart b/app/lib/widgets/mfa_code.dart index 726fb2a..b35c56e 100644 --- a/app/lib/widgets/mfa_code.dart +++ b/app/lib/widgets/mfa_code.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import 'package:smartify/services/mfa.dart'; + +import '../models/mfa.dart'; // TODO: update style (this is just a placeholder for testing) class MFASetup extends StatelessWidget { diff --git a/app/pubspec.lock b/app/pubspec.lock index e0fa520..48cd14a 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -5,42 +5,58 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.12.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.19.1" + convert: + dependency: "direct main" + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -53,10 +69,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.2" flutter: dependency: "direct main" description: flutter @@ -103,18 +119,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -135,10 +151,10 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -151,18 +167,18 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" qr: dependency: transitive description: @@ -188,50 +204,50 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.4" typed_data: dependency: transitive description: @@ -252,10 +268,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" url: "https://pub.dev" source: hosted - version: "14.3.0" + version: "14.3.1" web: dependency: transitive description: @@ -265,5 +281,5 @@ packages: source: hosted version: "1.1.0" sdks: - dart: ">=3.5.4 <4.0.0" + dart: ">=3.7.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 1a0a2ce..75a6811 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: http: ^1.2.2 flutter_dotenv: ^5.2.1 qr_flutter: ^4.1.0 + crypto: ^3.0.6 + convert: ^3.1.2 dev_dependencies: flutter_test: diff --git a/marketingwebsite/css/styles.css b/marketingwebsite/css/styles.css new file mode 100644 index 0000000..6bb3611 --- /dev/null +++ b/marketingwebsite/css/styles.css @@ -0,0 +1,937 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Helvetica Neue', Arial, sans-serif; +} + +body { + background-color: #fff; + color: #000; + line-height: 1.6; +} + +/* Layout */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; +} + +/* Header */ +header { + padding: 30px 0; + border-bottom: 1px solid #fff; + width: 100%; + position: relative; +} + +.header-content { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 15px; +} + +.header-logo { + display: flex; + align-items: center; + margin-left: -110px; +} + +.header-logo img { + height: 50px; + margin-right: 10px; +} + +.header-button { + padding: 10px 20px; + font-size: 16px; + font-weight: 600; + margin-right: -10px; + /* Ensure the button maintains consistent size */ + display: inline-block; + min-width: 120px; + text-align: center; +} + +.logo { + font-size: 32px; + font-weight: 700; + letter-spacing: 2px; + color: #000; +} + +/* Hero Section - Updated vertical alignment */ +.hero { + min-height: 70vh; /* Reduced from 80vh */ + display: flex; + align-items: center; + position: relative; + overflow: hidden; + margin-top: 20px; /* Reduced from 50px to bring closer to header */ +} + +/* Hero container layout adjustments - push slideshow further right */ +.hero .container { + width: 100%; + max-width: 1200px; + display: flex; + flex-direction: row; + justify-content: space-between; /* Ensure space between elements */ + align-items: flex-start; /* Change from center to flex-start for top alignment */ + padding: 0 0 0 20px; /* Remove right padding, keep left padding */ + gap: 0; /* Remove the gap property */ +} + +/* Hero Section - Refined vertical alignment */ +.hero-content { + width: 35%; /* Reduced width to move text more to the left */ + z-index: 2; + text-align: left; + max-width: 380px; /* Slightly reduce max-width */ + padding-right: 30px; /* Add padding to right side */ + padding-top: 40px; /* Reduced from 60px to move text up */ + margin-left: 0; /* Ensure alignment to the left */ + padding-left: 0; /* Remove any left padding */ + margin-right: auto; /* Push content to the left */ +} + +.hero h1 { + font-size: 60px; + font-weight: 800; + margin-bottom: 20px; + line-height: 1.1; +} + +.hero p { + font-size: 20px; + margin-bottom: 30px; + opacity: 0.9; +} + +/* Image/slideshow positioning - move further to the right */ +.hero-image { + width: 60%; /* Increase width from 55% to 60% */ + display: flex; + justify-content: flex-end; /* Changed from center to flex-end to push slideshow to the right */ + align-items: center; /* Ensure vertical centering */ + position: relative; + background: transparent; + margin-top: -10px; /* Changed from -40px to move slideshow down */ + padding-left: 60px; /* Increase left padding to push content right */ + margin-left: auto; /* Push to the right */ + margin-right: -40px; /* Add negative right margin to push past container bounds */ +} + +.device-mockup { + width: 300px; + height: 600px; + border: 8px solid #000; + border-radius: 30px; + position: relative; + overflow: hidden; + box-shadow: 0 0 50px rgba(0, 0, 0, 0.2); +} + +.device-screen { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #111, #000); + padding: 20px; + display: flex; + flex-direction: column; +} + +.app-icon { + width: 50px; + height: 50px; + background: #fff; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 12px; + margin-bottom: 20px; +} + +.app-menu { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 20px; +} + +.menu-item { + background: rgba(255, 255, 255, 0.1); + padding: 15px; + border-radius: 10px; + display: flex; + align-items: center; + cursor: pointer; + transition: background 0.3s ease; +} + +.menu-item:hover { + background: rgba(255, 255, 255, 0.2); +} + +.menu-icon { + width: 25px; + height: 25px; + background: #fff; + border-radius: 5px; + margin-right: 15px; +} + +/* Features Section */ +.features { + padding: 100px 0; +} + +.section-title { + font-size: 40px; + margin-bottom: 60px; + text-align: center; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 40px; +} + +.feature-card { + background: rgba(0, 0, 0, 0.05); + padding: 40px 30px; + border-radius: 15px; + transition: transform 0.3s ease, background 0.3s ease; +} + +.feature-card:hover { + transform: translateY(-5px); + background: rgba(0, 0, 0, 0.1); +} + +.feature-icon { + width: 60px; + height: 60px; + background: #000; + border-radius: 50%; + margin-bottom: 20px; + display: flex; + align-items: center; + justify-content: center; +} + +.feature-icon svg { + width: 30px; + height: 30px; + stroke: #fff; +} + +.feature-title { + font-size: 24px; + margin-bottom: 15px; +} + +.feature-description { + opacity: 0.8; +} + +/* CTA Section */ +.cta { + padding: 100px 0; + text-align: center; + background: #fff; +} + +.cta h2 { + font-size: 48px; + margin-bottom: 30px; +} + +.cta p { + font-size: 20px; + max-width: 600px; + margin: 0 auto 40px; + opacity: 0.9; +} + +.button { + display: inline-block; + background: #000; + color: #fff; + padding: 15px 40px; + border-radius: 30px; + font-size: 18px; + font-weight: 600; + text-decoration: none; + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.button:hover { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2); +} + +/* Footer */ +footer { + padding: 50px 0; + text-align: center; + border-top: 1px solid #fff; +} + +.social-links { + display: flex; + justify-content: center; + gap: 20px; + margin-bottom: 30px; +} + +.social-link { + width: 40px; + height: 40px; + background: rgba(0, 0, 0, 0.1); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.3s ease; +} + +.social-link:hover { + background: rgba(0, 0, 0, 0.3); +} + +.copyright { + opacity: 0.6; + font-size: 14px; +} + +.highlight { + color: #000; /* Same as regular text */ + font-weight: 900; /* Extra bold */ + letter-spacing: 1px; /* Slight letter spacing for emphasis */ +} + +/* Responsive Adjustments */ +@media (max-width: 992px) { + .hero { + flex-direction: column; + height: auto; + padding: 50px 0; + } + + .hero-content, .hero-image { + width: 100%; + position: relative; + text-align: center; + max-width: 100%; + } + + .hero-content { + padding: 0 20px; + margin: 0 auto 30px; + } + + .hero h1 { + font-size: 48px; + } + + .hero p { + font-size: 18px; + } + + .hero-image { + height: auto; + margin-top: 20px; + } + + .features-grid { + grid-template-columns: repeat(2, 1fr); + } + + .extreme-left { + margin-left: 0 !important; + } + + .slideshow-container { + width: 90%; + aspect-ratio: 3/2; + height: auto; + } +} + +@media (max-width: 768px) { + .hero h1 { + font-size: 36px; + } + + .hero p { + font-size: 16px; + } + + .features-grid { + grid-template-columns: 1fr; + } + + .desktop-only { + display: none; + } + + .slideshow-container { + width: 95%; + aspect-ratio: 1/1; + } + + .slide img { + max-width: 100%; + max-height: 100%; + } +} + +@media (max-width: 480px) { + .hero h1 { + font-size: 32px; + } + + .hero-content { + padding: 0 15px; + } + + .slideshow-container { + aspect-ratio: 4/5; + } +} + +/* Animation Styles */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +.animated { + animation: fadeIn 0.8s ease forwards; +} + +.delay-1 { animation-delay: 0.2s; } +.delay-2 { animation-delay: 0.4s; } +.delay-3 { animation-delay: 0.6s; } + +/* Comparison Table */ +.comparison { + padding: 100px 0; + background: #f9f9f9; +} + +.table-container { + overflow-x: auto; + margin-top: 40px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08); + border-radius: 8px; +} + +.comparison-table { + width: 100%; + border-collapse: collapse; + background: #fff; + overflow: hidden; +} + +.comparison-table th, +.comparison-table td { + padding: 20px; + text-align: center; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); +} + +.comparison-table th { + background: #000; + color: #fff; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + font-size: 14px; +} + +.comparison-table th:first-child { + text-align: left; +} + +.comparison-table td:first-child { + text-align: left; + font-weight: 500; +} + +.comparison-table tr:hover { + background: rgba(0, 0, 0, 0.02); +} + +.check { + color: #000; + font-weight: bold; + font-size: 18px; +} + +.cross { + color: #999; + font-weight: bold; + font-size: 18px; +} + +.limited { + color: #777; + font-style: italic; + font-size: 16px; +} + +@media (max-width: 768px) { + .comparison-table th, + .comparison-table td { + padding: 12px 10px; + font-size: 14px; + } + + .check, .cross { + font-size: 16px; + } +} + +/* About section styles */ +.about { + padding: 80px 0; + background-color: #f8f9fa; +} + +.about-content { + display: flex; + align-items: center; + gap: 40px; +} + +.about-text { + flex: 1; +} + +.about-image { + flex: 1; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .about-content { + flex-direction: column; + } + + .about-text, .about-image { + width: 100%; + } +} + +/* Slideshow styles */ +.slideshow-container { + width: 85%; + max-width: 600px; + aspect-ratio: 4/3; + position: relative; + overflow: hidden; + border-radius: 12px; + box-shadow: 0 5px 25px rgba(0, 0, 0, 0.15); + background-color: #f8f9fa; + margin: 0 auto; + margin-left: auto; /* Ensure it stays at the right */ + width: 100%; /* Full width of parent */ + transform: translateX(20px); /* Move 20px to the right */ + margin-right: -20px; /* Move container edge past parent boundaries */ +} + +.slideshow-slides { + width: 100%; + height: 100%; + position: relative; + display: flex; + justify-content: center; + align-items: center; +} + +.slide { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + transition: opacity 0.8s ease; + display: flex; + justify-content: center; + align-items: center; + padding: 20px; /* Add padding around the slide content */ +} + +.slide.active { + opacity: 1; +} + +.slide img { + width: auto; + height: auto; + max-width: 95%; + max-height: 95%; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + /* For portrait images, prioritize height over width */ + display: block; + margin: 0 auto; +} + +.slide-caption { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.7); + color: white; + padding: 10px 20px; + border-radius: 5px; + font-size: 16px; + font-weight: 500; + z-index: 2; +} + +/* Slideshow controls - better positioning */ +.slideshow-controls { + position: relative; + display: flex; + justify-content: center; + margin-top: 15px; + gap: 10px; + padding: 8px 12px; + z-index: 5; +} + +/* Improved dot styling */ +.slideshow-dot { + cursor: pointer; + height: 12px; + width: 12px; + border-radius: 50%; + display: inline-block; + background-color: #d0d0d0; + opacity: 0.7; + transition: all 0.3s ease; + border: 1px solid rgba(0, 0, 0, 0.1); + margin: 0 5px; +} + +.slideshow-dot.active { + background-color: #707070; + transform: scale(1.2); + opacity: 1; +} + +.slideshow-dot:hover { + background-color: #bbbbbb; +} + +.slideshow-controls { + position: relative; + display: flex; + justify-content: center; + margin-top: 15px; + gap: 10px; + padding: 8px 12px; + z-index: 10; +} + +.slideshow-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background-color: #d0d0d0; + cursor: pointer; + transition: all 0.3s ease; + border: 1px solid rgba(0, 0, 0, 0.1); + opacity: 0.7; + display: inline-block; +} + +.slideshow-dot.active { + background-color: #707070; + transform: scale(1.2); + opacity: 1; +} + +.slideshow-dot:hover { + background-color: #bbbbbb; +} + +.slideshow-dot.active:hover { + background-color: #333333; +} + +/* Position slideshow nav buttons below the dots */ +.slideshow-nav-buttons { + display: flex; + justify-content: center; + gap: 20px; + margin-top: 15px; + position: relative; + z-index: 5; +} + +@media (max-width: 992px) { + .slideshow-container { + height: 600px; /* Increased from 500px to 600px */ + width: 90%; + } +} + +@media (max-width: 768px) { + .slideshow-container { + height: 500px; /* Increased from 400px to 500px */ + width: 95%; + } +} + +@media (max-width: 480px) { + .slideshow-container { + aspect-ratio: 4/5; + } +} + +/* Extreme positioning - Adjusted */ +.extreme-left { + margin-left: px !important; /* Add negative margin to push text more left */ + padding-left: 0 !important; + position: relative; +} + +@media (min-width: 1200px) { + .extreme-left { + margin-left: -30px !important; /* Increase negative margin on larger screens */ + } +} + +/* Responsive header adjustments */ +@media (max-width: 1200px) { + .header-logo { + margin-left: -60px; + } + + .header-button { + margin-right: -100px; + padding: 10px 20px; + font-size: 16px; + } +} + +@media (max-width: 992px) { + .header-logo { + margin-left: -30px; + } + + .header-button { + margin-right: -30px; + padding: 10px 20px; + font-size: 16px; + } +} + +@media (max-width: 768px) { + .header-logo { + margin-left: 0; + } + + .header-button { + margin-right: 0; + padding: 10px 20px; + font-size: 16px; + min-width: 120px; + } + + .header-logo img { + height: 40px; + } + + .logo { + font-size: 24px; + } + + .header-button { + padding: 8px 16px; + font-size: 14px; + } +} + +@media (max-width: 480px) { + .header-content { + flex-direction: column; + gap: 20px; + text-align: center; + } + + .header-logo { + margin: 0 auto; + } + + .header-button { + margin: 0; + } +} + +/* Slideshow Styles - Unified and fixed */ +.slideshow-container, +.slideshow-slides, +.slide { + background: transparent; + box-shadow: none; +} + +.slideshow-container { + width: 100%; + max-width: 700px; + height: auto; + position: relative; + overflow: hidden; + margin: 0; + border-radius: 12px; + transform: translateY(-5px); /* Changed from -20px to remove upward shift */ + margin-right: 0; /* Align to the right edge */ + margin-left: auto; /* Push to the right */ +} + +.slide img { + width: 100%; + height: auto; + max-height: 600px; + object-fit: contain; + border-radius: 12px; + transition: transform 0.3s ease; +} + +.slide img:hover { + transform: scale(1.02); +} + +.slideshow-controls { + position: relative; + display: flex; + justify-content: center; + margin-top: 15px; + gap: 10px; +} + +.slideshow-dot { + cursor: pointer; + height: 12px; + width: 12px; + border-radius: 50%; + display: inline-block; + background-color: #d0d0d0; /* Light grey for inactive dots */ + opacity: 0.7; + transition: background-color 0.3s ease; +} + +.slideshow-dot.active { + background-color: #707070; /* Darker grey for active dot */ + opacity: 1; +} + +/* Slideshow navigation buttons */ +.slideshow-nav-buttons { + display: flex; + justify-content: center; + gap: 20px; + margin-top: 20px; + margin-bottom: 20px; + position: relative; + z-index: 20; + padding: 10px; +} + +.slide-nav-button { + background-color: #000; + color: #fff; + border: none; + padding: 12px 25px; + border-radius: 30px; + cursor: pointer; + font-size: 16px; + font-weight: 600; + transition: all 0.3s ease; + min-width: 120px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.slide-nav-button:hover { + background-color: #333; + transform: translateY(-2px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); +} + +.slide-nav-button:active { + transform: translateY(0); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +/* Responsive adjustments for buttons */ +@media (max-width: 768px) { + .slideshow-nav-buttons { + gap: 10px; + } + + .slide-nav-button { + padding: 8px 15px; + font-size: 14px; + } +} + +@media (max-width: 480px) { + .slideshow-nav-buttons { + flex-direction: column; + align-items: center; + gap: 10px; + } + + .slide-nav-button { + width: 80%; + } +} + +/* Responsive Adjustments */ +@media (max-width: 1100px) { + .hero .container { + flex-direction: column; + text-align: center; + align-items: center; /* Reset alignment for mobile */ + } + + .hero-content { + width: 100%; + max-width: 600px; + padding-right: 0; + padding-top: 0; /* Remove top padding on mobile */ + padding-bottom: 20px; /* Reduced from 30px */ + margin: 0 auto; + margin-left: auto; /* Reset for mobile */ + } + + .hero-image { + width: 100%; + justify-content: center; /* Center on mobile */ + margin-top: 0; /* Remove negative margin on mobile */ + padding-left: 0; /* Remove left padding on mobile */ + margin-right: 0; /* Reset negative margin on smaller screens */ + } + + .slideshow-container { + max-width: 90%; + margin: 0 auto; + transform: translateX(0); /* Reset transform on smaller screens */ + margin-left: auto; + margin-right: auto; /* Center on mobile */ + } + + .extreme-left { + margin-left: 0 !important; /* Reset negative margin on mobile */ + } +} \ No newline at end of file diff --git a/marketingwebsite/images/Devices.jpeg b/marketingwebsite/images/Devices.jpeg new file mode 100644 index 0000000..6540fe9 Binary files /dev/null and b/marketingwebsite/images/Devices.jpeg differ diff --git a/marketingwebsite/images/IoT.jpeg b/marketingwebsite/images/IoT.jpeg new file mode 100644 index 0000000..ec8e66b Binary files /dev/null and b/marketingwebsite/images/IoT.jpeg differ diff --git a/marketingwebsite/images/dashboardImg.jpeg b/marketingwebsite/images/dashboardImg.jpeg new file mode 100644 index 0000000..3cfa548 Binary files /dev/null and b/marketingwebsite/images/dashboardImg.jpeg differ diff --git a/marketingwebsite/images/facebook-svgrepo-com.svg b/marketingwebsite/images/facebook-svgrepo-com.svg new file mode 100644 index 0000000..4b54089 --- /dev/null +++ b/marketingwebsite/images/facebook-svgrepo-com.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/marketingwebsite/images/instagram-svgrepo-com.svg b/marketingwebsite/images/instagram-svgrepo-com.svg new file mode 100644 index 0000000..c483545 --- /dev/null +++ b/marketingwebsite/images/instagram-svgrepo-com.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/marketingwebsite/images/lamp-light-svgrepo-com.svg b/marketingwebsite/images/lamp-light-svgrepo-com.svg new file mode 100644 index 0000000..91195a8 --- /dev/null +++ b/marketingwebsite/images/lamp-light-svgrepo-com.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/marketingwebsite/images/lightning-svgrepo-com.svg b/marketingwebsite/images/lightning-svgrepo-com.svg new file mode 100644 index 0000000..016c202 --- /dev/null +++ b/marketingwebsite/images/lightning-svgrepo-com.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/marketingwebsite/images/logo.png b/marketingwebsite/images/logo.png new file mode 100644 index 0000000..74fe118 Binary files /dev/null and b/marketingwebsite/images/logo.png differ diff --git a/marketingwebsite/images/modern-house.png b/marketingwebsite/images/modern-house.png new file mode 100644 index 0000000..4c905f9 Binary files /dev/null and b/marketingwebsite/images/modern-house.png differ diff --git a/marketingwebsite/images/schedules.jpeg b/marketingwebsite/images/schedules.jpeg new file mode 100644 index 0000000..cacabf2 Binary files /dev/null and b/marketingwebsite/images/schedules.jpeg differ diff --git a/marketingwebsite/images/twitter-154-svgrepo-com.svg b/marketingwebsite/images/twitter-154-svgrepo-com.svg new file mode 100644 index 0000000..6a4a536 --- /dev/null +++ b/marketingwebsite/images/twitter-154-svgrepo-com.svg @@ -0,0 +1,19 @@ + + + + + twitter [#154] + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/marketingwebsite/index.html b/marketingwebsite/index.html new file mode 100644 index 0000000..198f992 --- /dev/null +++ b/marketingwebsite/index.html @@ -0,0 +1,420 @@ + + + + + + Marketing Website + + + +
+
+
+ + Join Now! +
+
+
+ +
+
+
+

+ Where your home meets
+ innovation. +

+

+ Smartify connects all your devices into one seamless experience. + Simple. Intuitive. Smart. +

+ Get Started +
+
+
+
+
+ Smart Home Living Room +
+
+ Smart Home Kitchen +
+
+ Smart Home Security +
+
+
+ + + +
+ +
+ + +
+
+
+
+
+ +
+
+
+
+

What is Smartify?

+

+ Smartify is a comprehensive smart home solution designed to + simplify your life. Our platform seamlessly integrates with your + existing devices, providing a unified control system that's both + powerful and intuitive. +

+

+ With advanced AI capabilities, Smartify learns your preferences + and routines to create a truly personalized home environment. + Whether you're managing lighting, security, temperature, or + entertainment, our system adapts to your lifestyle. +

+

+ Founded in 2025, our mission is to make smart home technology + accessible to everyone. We believe that home automation should + enhance your daily life without adding complexity. +

+
+
+ Smartify in action +
+
+
+
+ +
+
+

Why Choose Smartify?

+
+
+
+ + + +
+

Advanced Security

+

+ Monitor and control your home security system from anywhere. + Receive real-time alerts and view camera feeds directly from your + phone. +

+
+
+
+ + + + +
+

Energy Efficient

+

+ Optimize your home's energy usage with smart scheduling and + automated routines that save money while reducing your + environmental footprint. +

+
+
+
+ + + + + + +
+

Universal Compatibility

+

+ Works with all major smart home devices and brands. No need for + multiple apps - control everything from a single interface. +

+
+
+
+
+ +
+
+

How We Compare To Others?

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeaturesSmartifyABB-free@homeGoogle Home
Easy InstallationLimitedLimited
Highly customizableLimitedBasic
Voice Control IntegrationBasicLimited
Platform CompatibilityBasicBasic
AI-Powered SuggestionsBasicBasic
Data Privacy + Outstanding + BasicPoor
No Subscription RequiredLimitedLimited
+
+
+
+ +
+
+

Ready to Smartify Your Home?

+

+ Download our app today and experience the future of home automation. + Available on iOS and Android. +

+
+ +
+
+
+ + + + + + diff --git a/api/src/controllers/.gitkeep b/marketingwebsite/js/main.js similarity index 100% rename from api/src/controllers/.gitkeep rename to marketingwebsite/js/main.js