diff --git a/.gitignore b/.gitignore index a1465ef..5b31d10 100644 --- a/.gitignore +++ b/.gitignore @@ -91,7 +91,6 @@ out # Nuxt.js build / generate output .nuxt dist -api # Gatsby files .cache/ diff --git a/api/classes/authFactorClass.js b/api/classes/authFactorClass.js new file mode 100644 index 0000000..2d0bf75 --- /dev/null +++ b/api/classes/authFactorClass.js @@ -0,0 +1,159 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _AuthFactor = _interopRequireDefault(require("../models/AuthFactor")); +var _User = _interopRequireDefault(require("../models/User")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +/** + * @typedef {Object} AuthFactorType + * Options + * - TOTP + * - WEBAUTHN + * - OAUTH + */ + +/** + * @typedef {Object} AuthFactor + * @property {string} id - The id of the auth factor (UUID, set by the database) + * @property {string} userId - The id of the user that owns the auth factor + * @property {AuthFactorType} factor - The type of auth factor (TOTP, SMS, etc.) + * @property {string} secret - The secret that is used to generate the TOTP + */ +/** + * Handles the methods for interacitng with the AuthFactor data + */ +class AuthFactor { + /** + * Creates an auth record in the database with a secret + * - Is inactive when created because the user needs to verify it + * + * @async + * @param {string} userId - The id of the user that owns the auth factor + * @param {AuthFactorType} factor - The type of auth factor (TOTP, SMS, etc.) + * @param {string} secret - The secret that is used to generate the TOTP + * @returns {Promise} The id of the auth factor record or false if it fails + */ + async createRecord(userId, factor, secret) { + try { + const authFactor = await _AuthFactor.default.create({ + userId, + factor, + secret, + verified: false + }); + return authFactor.dataValues; + } catch (error) { + throw new _CodedError.default(error.message, error.status ?? 500, error.location ?? "AUTHFACTOR|01"); + } + } + + /** + * Activates an auth factor record + * - Sets the verified flag to true + * - Sets the verifiedAt date to the current date + * + * @async + * @param {string} id - The id of the auth factor record + * @returns {Promise} True if the record was activated, false if it fails + */ + async activateRecord(id) { + try { + const authFactor = await _AuthFactor.default.findOne({ + where: { + id + } + }); + if (!authFactor) throw new _CodedError.default("Auth factor not found", 404, "AUTHFACTOR|02"); + await authFactor.update({ + verified: true, + verifiedAt: new Date() + }); + + // update user mfa flag + const user = await _User.default.findOne({ + where: { + id: authFactor.userId + } + }); + if (!user) throw new _CodedError.default("User not found", 404, "AUTHFACTOR|03"); + await user.update({ + mfa: true + }); + return true; + } catch (error) { + throw new _CodedError.default(error.message, error.status ?? 500, error.location ?? "AUTHFACTOR|03"); + } + } + + /** + * Deletes an auth factor record + * - if after deletion the user has no auth factors, the user's mfa flag is set to false + * @async + * @param {string} id - The id of the auth factor record + * @returns {Promise} True if the record was deleted, false if it fails + */ + async deleteRecord(id) { + try { + const authFactor = await _AuthFactor.default.findOne({ + where: { + id + } + }); + if (!authFactor) throw new _CodedError.default("Auth factor not found", 404, "AUTHFACTOR|04"); + await authFactor.destroy(); + const user = await _User.default.findOne({ + where: { + id: authFactor.userId + } + }); + if (!user) throw new _CodedError.default("User not found", 404, "AUTHFACTOR|05"); + const userAuthFactors = await user.getAuthFactors(); + if (userAuthFactors.length === 0) { + await user.update({ + mfa: false + }); + } + return true; + } catch (error) { + throw new _CodedError.default(error.message, error.status ?? 500, error.location ?? "AUTHFACTOR|05"); + } + } + + /** + * Deletes all auth factor records for a user and sets the user's mfa flag to false + * + * @async + * @param {string} userId - The id of the user that owns the auth factor + * @returns {Promise} True if the records were deleted, false if it fails + */ + async disableMFA(userId) { + try { + const user = await _User.default.findOne({ + where: { + id: userId + } + }); + if (!user) throw new _CodedError.default("User not found", 404, "AUTHFACTOR|06"); + await user.update({ + mfa: false + }); + const authFactors = await _AuthFactor.default.getAll({ + where: { + userId + } + }); + if (!authFactors) throw new _CodedError.default("Auth factors not found", 404, "AUTHFACTOR|07"); + authFactors.forEach(async authFactor => { + await authFactor.destroy(); + }); + return true; + } catch (error) { + throw new _CodedError.default(error.message, error.status ?? 500, error.location ?? "AUTHFACTOR|08"); + } + } +} +var _default = exports.default = AuthFactor; \ No newline at end of file diff --git a/api/classes/capabilitiesClass.js b/api/classes/capabilitiesClass.js new file mode 100644 index 0000000..621083c --- /dev/null +++ b/api/classes/capabilitiesClass.js @@ -0,0 +1,110 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _Capability = _interopRequireDefault(require("../models/Capability")); +var _Role = _interopRequireDefault(require("../models/Role")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +class Capability { + /** + * Get all capabilities that match the conditions + * @async + * @param {*} conditions + * @returns {Promise} + */ + async getCapabilities(conditions = {}) { + try { + const capabilities = await _Capability.default.findAll({ + where: conditions + }); + return capabilities; + } catch (error) { + throw new _CodedError.default(error.message, 400, "CAPABILITY|00"); + } + } + + /** + * Returns the first capability that matches the conditions + */ + async getCapability(conditions = {}) { + try { + const capability = await _Capability.default.findOne({ + where: conditions + }); + return capability; + } catch (error) { + throw new _CodedError.default(error.message, 400, "CAPABILITY|01"); + } + } + + /** + * Create a new capability + * @async + * @param {Object} data + * @param {Object} options + * - + * - roles: Array - The names of the roles to assign the capability to + * @returns {Promise} + */ + async createCapability(data = {}, options = {}) { + const { + name, + description + } = data; + const { + roles + } = options; + try { + const capability = await _Capability.default.create({ + name, + description + }); + if (roles) { + await capability.setRoles(roles); + } + return capability; + } catch (error) { + throw new _CodedError.default(error.message, 400, "CAPABILITY|02"); + } + } + + /** + * Delete capabilities + * - Will do nothing if no capabilities are provided + * - Removes the capability from all roles + * @async + * @param {Array} capabilities - The names of the capabilities to delete + * @returns {Promise} - True if the capabilities were deleted + */ + async deleteCapabilities(capabilities) { + try { + if (!capabilities) throw new _CodedError.default("No capabilities provided", 400, "CAPABILITY|02"); + if (!Array.isArray(capabilities)) throw new _CodedError.default("Capabilities must be an array", 400, "CAPABILITY|03"); + await _Capability.default.destroy({ + where: { + name: capabilities + } + }); + + // Remove the capabilities from all roles + const roles = await _Role.default.findAll({ + include: [{ + model: _Capability.default, + as: "capabilities" + }] + }); + roles.forEach(async role => { + const roleCapabilities = role.capabilities.map(capability => capability.name); + const updatedCapabilities = roleCapabilities.filter(capability => !capabilities.includes(capability)); + await role.setCapabilities(updatedCapabilities); + }); + return true; + } catch (error) { + throw new _CodedError.default(error.message, 400, "CAPABILITY|03"); + } + } +} +var _default = exports.default = Capability; \ No newline at end of file diff --git a/api/classes/cookiesClass.js b/api/classes/cookiesClass.js new file mode 100644 index 0000000..583aaaa --- /dev/null +++ b/api/classes/cookiesClass.js @@ -0,0 +1,89 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } +function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } +function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } +function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } +function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } +class Cookies { + constructor(req, res) { + this.req = req; + this.res = res; + } + + /** + * Sets a cookie in the response + */ + set(name, value, options = {}) { + try { + this.res.cookie(name, value, options); + return true; + } catch (error) { + throw new _CodedError.default(`Error setting cookie: ${error.message}`, 500, "LOG|05"); + } + } + setSessionCookie(value) { + this.set("session", value, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict" + }); + } + + /** + * Gets the refresh token from the request cookies + * @returns {String} - The refresh token or false if not found + */ + getRefreshToken() { + try { + const token = this.req?.cookies?.refreshToken; + return token; + } catch (error) { + return false; + } + } + + /** + * Adds an httpOnly cookie to the response + * @param {string} name - The name of the cookie + * @param {*} value - The value of the cookie + * @param {*} options - The options of the cookie + * @returns true if the cookie was added, throws an error otherwise + */ + setHttpOnly(name, value, options = {}) { + try { + this.res.cookie(name, value, _objectSpread({ + httpOnly: true, + secure: process.env.NODE_ENV === "production" + }, options)); + return true; + } catch (error) { + throw new _CodedError.default(`Error setting cookie: ${error.message}`, 500, "LOG|05"); + } + } + + /** + * Sets the refresh token in the response cookies + * + * @param {*} token - The refresh token + * @returns true if the cookie was added, throws an error otherwise + */ + setRefreshToken(token) { + try { + this.setHttpOnly("refreshToken", token, { + maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days + }); + + return true; + } catch (error) { + throw new _CodedError.default(`Error setting cookie: ${error.message}`, 500, "LOG|05"); + } + } +} +var _default = exports.default = Cookies; \ No newline at end of file diff --git a/api/classes/jwtClass.js b/api/classes/jwtClass.js new file mode 100644 index 0000000..0841bdf --- /dev/null +++ b/api/classes/jwtClass.js @@ -0,0 +1,105 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _jsonwebtoken = _interopRequireDefault(require("jsonwebtoken")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +var _Token = _interopRequireDefault(require("../models/Token")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +/** + * The Standard payload for a JWT token + * @typedef {Object} JWTToken + * @property {String} id - The user id + * @property {String} email - The user email + * @property {String} role - The user role + */ + +class JWT { + /** + * Creates a new JWT instance + * @param {String} secret - The secret to sign the JWT token (default: process.env.JWT_SECRET) + * @returns {JWT} - The JWT instance + */ + + constructor(publicKey, privateKey) { + this.publicKey = publicKey || process.env.JWT_PUBLIC; + this.privateKey = privateKey || process.env.JWT_PRIVATE; + if (!this.publicKey || !this.privateKey) throw new _CodedError.default("Invalid key configuration", 500, "JWT|00"); + } + + /** + * Returns a signed JWT token + * @async + * @param {JWTToken} payload - The payload to be signed: { id: String, email: String, role: String } + * @param {String} expiresIn - The expiration time of the token (default: "1h") + * @returns {Promise} - The signed JWT token + */ + async sign(payload, expiresIn = "1m") { + const token = _jsonwebtoken.default.sign(payload, this.privateKey, { + expiresIn, + algorithm: "RS256" + }); + const expiresUnix = _jsonwebtoken.default.decode(token).exp; + const expires = new Date(expiresUnix * 1000); + const logToken = await _Token.default.create({ + token, + expires + }); + if (!logToken) throw new _CodedError.default("Error logging token", 500, "JWT|05"); + return token; + } + + /** + * Returns the payload of a JWT token + * @async + * @param {String} token - The JWT token + * @returns {Promise} - The payload of the token + */ + async verify(token) { + try { + const tokenExists = await _Token.default.findOne({ + where: { + token + } + }); + if (!tokenExists) throw new _CodedError.default("Token not found", 400, "JWT|02"); + if (tokenExists.blacklisted) throw new _CodedError.default("Token is blacklisted", 400, "JWT|03"); + return _jsonwebtoken.default.verify(token, this.publicKey, { + algorithms: ["RS256"] + }); + } catch (error) { + return new _CodedError.default(error.message, 400, "JWT|01"); + } + } + + /** + * Blacklists a JWT token + * @async + * @param {String} token - The JWT token + * @returns {Promise} - True if the token was blacklisted + */ + async blacklist(token) { + try { + let tokenExists = await _Token.default.findOne({ + where: { + token + } + }); + if (!tokenExists) { + const decodedToken = _jsonwebtoken.default.decode(token); + tokenExists = await _Token.default.create({ + token, + expires: decodedToken.exp + }); + } + tokenExists.blacklisted = true; + await tokenExists.save(); + return true; + } catch (error) { + throw new _CodedError.default(error.message, 400, "JWT|04"); + } + } +} +var _default = exports.default = JWT; \ No newline at end of file diff --git a/api/classes/responseClass.js b/api/classes/responseClass.js new file mode 100644 index 0000000..777d439 --- /dev/null +++ b/api/classes/responseClass.js @@ -0,0 +1,84 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +class Response { + /** + * Used to normalize the response + * + * @param {*} req + * @param {*} res + */ + constructor(req, res) { + this.req = req; + this.res = res; + } + + /** + * Called when the request is successful + * @param {*} data + * @returns {boolean} true + */ + success(data) { + const response = { + success: true + }; + if (this.req.token) response.token = this.req.token; + if (data) response.data = data; + this.res.status(this.req.status ?? 200).json(response); + return true; + } + + /** + * Called when the request is unsuccessful + * @param {*} error + * @returns {boolean} true + */ + error(error) { + const isDev = process.env.NODE_ENV === "development"; + if (isDev) console.error(error); + if (error instanceof _CodedError.default) { + const responseBody = { + error: true, + success: false, + message: error.message, + location: isDev ? error.location : undefined, + req: isDev ? this.req.body : undefined, + data: error.data + }; + this.res.status(error.status ?? 500).json(responseBody); + return true; + } + this.res.status(500).json({ + success: false, + error: true, + message: error.message + }); + return true; + } + + /** + * Sets the status code for the response + * @param {*} status + * @returns {boolean} - true + */ + setStatus(status) { + this.req.status = status; + return true; + } + + /** + * Sets the token for the response + * @param {*} token + * @returns {boolean} - true + */ + setToken(token) { + this.req.token = token; + return true; + } +} +var _default = exports.default = Response; \ No newline at end of file diff --git a/api/classes/rolesClass.js b/api/classes/rolesClass.js new file mode 100644 index 0000000..37f0a9e --- /dev/null +++ b/api/classes/rolesClass.js @@ -0,0 +1,232 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _Role = _interopRequireDefault(require("../models/Role")); +var _Capability = _interopRequireDefault(require("../models/Capability")); +var _capabilitiesClass = _interopRequireDefault(require("./capabilitiesClass")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +class Role { + /** + * Get all roles that match the conditions + * - Gets all roles if no conditions are provided + * @async + * @param {*} conditions + * @returns {Promise} + */ + async getRoles(conditions = {}) { + try { + const roles = await _Role.default.findAll({ + where: conditions + }); + return roles; + } catch (error) { + throw new _CodedError.default(error.message, 400, "ROLE|00"); + } + } + + /** + * Gets a single role + * @async + * @param {*} conditions + * @returns {Promise} + */ + async getRole(conditions = {}) { + try { + const role = await _Role.default.findOne({ + where: conditions + }); + return role; + } catch (error) { + throw new _CodedError.default(error.message, 400, "ROLE|01"); + } + } + + /** + * Creates a new role + * @async + * @param {*} data + * @param {Object} options - custom options + * - copyFrom: String - The name of the role to copy capabilities from + * @returns {Promise} + */ + async createRole(data = {}, options = {}) { + const { + name, + description, + capabilities: cababilityNames + } = data; + const { + copyFrom + } = options; + try { + const role = await _Role.default.create({ + name, + description + }); + const capability = new _capabilitiesClass.default(); + const SETTING_NEW_CAPABILITIES = cababilityNames && cababilityNames.length; + const capabilityObjects = SETTING_NEW_CAPABILITIES ? await capability.getCapabilities({ + name: cababilityNames + }) : []; + if (copyFrom) { + const existingRole = await _Role.default.findOne({ + where: { + name: copyFrom + }, + include: [{ + model: _Capability.default, + as: "capabilities" + }] + }); + if (!existingRole?.capabilities) return role; + const capabilitiesList = [...existingRole.capabilities, ...capabilityObjects]; + await role.setCapabilities(capabilitiesList); + } else { + if (!capabilityObjects.length) return role; + await role.setCapabilities(capabilityObjects); + } + return role; + } catch (error) { + throw new _CodedError.default(error.message, 400, "ROLE|02"); + } + } + + /** + * Updates a role + * @async + * @param {*} data + * - Can use name or id to select the role to update + * - If capabilities are provided, it will replace the existing capabilities + * @returns {Promise} + */ + async updateRole(data = {}) { + const { + id, + name, + description, + capabilities: capabilityNames + } = data; + const capability = new _capabilitiesClass.default(); + try { + const condition = id ? { + id + } : { + name + }; + const role = await _Role.default.findOne({ + where: condition + }); + if (!role) throw new _CodedError.default("Role not found", 400, "ROLE|03"); + await role.update({ + name, + description + }); + if (Array.isArray(capabilityNames) && !capabilityNames.length) { + await role.setCapabilities([]); + return role; + } + if (capabilityNames) { + const capabilities = await capability.getCapabilities({ + name: capabilityNames + }); + if (!capabilities.length) return role; + await role.setCapabilities(capabilities); + } + return role; + } catch (error) { + throw new _CodedError.default(error.message, 400, "ROLE|04"); + } + } + + /** + * Adds capabilities to a role + * @async + * @param {String} roleName + * @param {String[]} capabilities - array of capability names + * @returns {Promise} + */ + async addCapabilities(roleName, capabilities = []) { + const capability = new _capabilitiesClass.default(); + try { + if (typeof roleName !== "string") throw new _CodedError.default("Role name is required", 400, "ROLE|05"); + const role = await _Role.default.findOne({ + where: { + name: roleName + } + }); + if (!role) throw new _CodedError.default("Role not found", 400, "ROLE|05"); + const capabilityObjects = await capability.getCapabilities({ + name: capabilities + }); + if (!capabilityObjects.length) return role; + await role.addCapabilities(capabilityObjects); + return role; + } catch (error) { + throw new _CodedError.default(error.message, 400, "ROLE|06"); + } + } + + /** + * Removes capabilities from a role + * @async + * @param {String} roleName + * @param {String[]} capabilities - array of capability names to remove + * @returns {Promise} + */ + async removeCapabilities(roleName, capabilities = []) { + const capability = new _capabilitiesClass.default(); + try { + if (typeof roleName !== "string") throw new _CodedError.default("Role name is required", 400, "ROLE|07"); + const role = await _Role.default.findOne({ + where: { + name: roleName + } + }); + if (!role) throw new _CodedError.default("Role not found", 400, "ROLE|07"); + const capabilityObjects = await capability.getCapabilities({ + name: capabilities + }); + if (!capabilityObjects.length) return role; + await role.removeCapabilities(capabilityObjects); + return role; + } catch (error) { + throw new _CodedError.default(error.message, 400, "ROLE|08"); + } + } + + /** + * Deletes a role + * - Migrates all users with the role to another role + * @async + * @param {String} roleName + * @param {String} migrateToRole - The role to migrate users to + * @returns {Promise} + */ + async deleteRole(roleName, migrateToRole) { + try { + if (typeof roleName !== "string") throw new _CodedError.default("Role name is required", 400, "ROLE|09"); + const role = await _Role.default.findOne({ + where: { + name: roleName + } + }); + if (!role) throw new _CodedError.default("Role not found", 400, "ROLE|09"); + const migrateToRoleObject = await _Role.default.findOne({ + where: { + name: migrateToRole + } + }); + if (!migrateToRoleObject) throw new _CodedError.default("Role to migrate to not found", 400, "ROLE|09"); + await role.setUsers(migrateToRoleObject.users); + await role.destroy(); + return role; + } catch (error) { + throw new _CodedError.default(error.message, 400, "ROLE|10"); + } + } +} +var _default = exports.default = Role; \ No newline at end of file diff --git a/api/classes/totpClass.js b/api/classes/totpClass.js new file mode 100644 index 0000000..e2b12a6 --- /dev/null +++ b/api/classes/totpClass.js @@ -0,0 +1,191 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var OTPAuth = _interopRequireWildcard(require("otpauth")); +var _crypto = _interopRequireDefault(require("crypto")); +var _hiBase = _interopRequireDefault(require("hi-base32")); +var _authFactorClass = _interopRequireDefault(require("./authFactorClass")); +var _User = _interopRequireDefault(require("../models/User")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); } +function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && Object.prototype.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; } +/* eslint-disable import/no-extraneous-dependencies */ + +/** + * Methods for handling TOTP authentication + */ +class TOTP { + /** + * Generates a secret that is used to generate the TOTP + * - The secret will be stored in the AuthFactor table and associated with a user + * + * @returns {string} The secret + */ + generateSecret() { + try { + const secret = _crypto.default.randomBytes(32).toString("hex"); + const encodedSecret = _hiBase.default.encode(secret); + console.log("HERE's YOUR SECRET", encodedSecret); + return encodedSecret; + } catch (error) { + throw new _CodedError.default(error.message, error.status ?? 500, error.location ?? "TOTPc|01"); + } + } + + /** + * Generates a URI that is used to generate the QR code + * - does not include the secret + * + * @param {string} label - The label that will be displayed on the authenticator app + * @param {string} issuer - The issuer that will be displayed on the authenticator app + * @returns {string} The URI + */ + getURI(label, issuer, secret) { + try { + const totp = new OTPAuth.TOTP({ + issuer, + label, + algorithm: "SHA256", + secret, + digits: 6, + period: 30 + }); + const uri = totp.toString(); + return uri; + } catch (error) { + throw new _CodedError.default(error.message, error.status ?? 500, error.location ?? "TOTPc|02"); + } + } + generateRecoveryCodes() {} + + /** + * Verifies a TOTP code + * - Checks if the code is valid + * + * @async + * @param {string} userId - The id of the user that owns the auth factor + * @param {string} code - The code that will be verified + * @returns {Promise} True if the code is valid, false if it is not + */ + async verify(userId, code) { + try { + const user = await _User.default.findOne({ + where: { + id: userId + } + }); + if (!user) throw new _CodedError.default("User not found", 404, "TOTPc|03"); + const userAuthFactors = await user.getAuthFactors(); + const totpAuthFactor = userAuthFactors.find(authFactor => authFactor.factor === "TOTP"); + if (!totpAuthFactor) throw new _CodedError.default("TOTP auth factor not found", 404, "TOTPc|04"); + const totp = new OTPAuth.TOTP({ + issuer: "Authenticator", + label: user.email, + algorithm: "SHA256", + digits: 6, + secret: totpAuthFactor.secret, + period: 30 + }); + const delta = totp.validate({ + token: code, + window: 1 + }); + const isValid = delta === 0 || delta === 1 || delta === -1; + if (!isValid) throw new _CodedError.default("Invalid code", 400, "TOTPc|051"); + return true; + } catch (error) { + throw new _CodedError.default(error.message, error.status ?? 500, error.location ?? "TOTPc|05"); + } + } + verifyRecoveryCode() {} + + /** + * Creates a TOTP auth factor record for a user + * - Is inactive when created because the user needs to verify it + * - Verifies the user exists, and does not already have a TOTP auth factor + * + * @async + * @param {*} userId + * @returns {Promise} The id of the auth factor record or false if it fails + */ + async createRecord(userId) { + try { + const user = await _User.default.findOne({ + where: { + id: userId + } + }); + if (!user) throw new _CodedError.default("User not found", 404, "TOTPc|06"); + const userAuthFactors = await user.getAuthFactors(); + const userHasTOTPFactor = userAuthFactors.some(authFactor => authFactor.factor === "TOTP"); + if (userHasTOTPFactor) throw new _CodedError.default("User already has a TOTP auth factor", 400, "TOTPc|07"); + const secret = this.generateSecret(); + const authFactorMethods = new _authFactorClass.default(); + const authFactor = await authFactorMethods.createRecord(userId, "TOTP", secret); + return authFactor; + } catch (error) { + throw new _CodedError.default(error.message, error.status ?? 500, error.location ?? "TOTPc|08"); + } + } + + /** + * User must verify the TOTP auth factor before it can be used + * - Sets the verified flag to true + * + * @async + * @param {string} userId - The id of the user that owns the auth factor + * @param {string} code - The code that will be verified + * @returns {Promise} True if the record was activated, false if it fails + */ + async activateRecord(userId, code) { + try { + const secretIsValid = await this.verify(userId, code); + if (!secretIsValid) throw new _CodedError.default("Invalid code", 400, "TOTPc|09"); + const user = await _User.default.findOne({ + where: { + id: userId + } + }); + if (!user) throw new _CodedError.default("User not found", 404, "TOTPc|10"); + const userAuthFactors = await user.getAuthFactors(); + const totpAuthFactor = userAuthFactors.find(authFactor => authFactor.factor === "TOTP"); + if (!totpAuthFactor) throw new _CodedError.default("TOTP auth factor not found", 404, "TOTPc|11"); + const authFactorMethods = new _authFactorClass.default(); + const updated = await authFactorMethods.activateRecord(totpAuthFactor.id); + return updated; + } catch (error) { + throw new _CodedError.default(error.message, error.status ?? 500, error.location ?? "TOTPc|12"); + } + } + + /** + * Deletes a TOTP auth factor record for a user + * + * @async + * @param {*} userId + * @returns {Promise} True if the record was deleted, false if it fails + */ + async deleteRecord(userId) { + try { + const user = await _User.default.findOne({ + where: { + id: userId + } + }); + if (!user) throw new _CodedError.default("User not found", 404, "TOTPc|13"); + const userAuthFactors = await user.getAuthFactors(); + const totpAuthFactor = userAuthFactors.find(authFactor => authFactor.factor === "TOTP"); + if (!totpAuthFactor) throw new _CodedError.default("TOTP auth factor not found", 404, "TOTPc|14"); + const authFactorMethods = new _authFactorClass.default(); + const deleted = await authFactorMethods.deleteRecord(totpAuthFactor.id); + return deleted; + } catch (error) { + throw new _CodedError.default(error.message, error.status ?? 500, error.location ?? "TOTPc|15"); + } + } +} +var _default = exports.default = TOTP; \ No newline at end of file diff --git a/api/classes/usersClass.js b/api/classes/usersClass.js new file mode 100644 index 0000000..6b745b8 --- /dev/null +++ b/api/classes/usersClass.js @@ -0,0 +1,482 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _bcryptjs = _interopRequireDefault(require("bcryptjs")); +var _isStrongPassword = _interopRequireDefault(require("validator/lib/isStrongPassword")); +var _isJWT = _interopRequireDefault(require("validator/lib/isJWT")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +var _jwtClass = _interopRequireDefault(require("./jwtClass")); +var _rolesClass = _interopRequireDefault(require("./rolesClass")); +var _User = _interopRequireDefault(require("../models/User")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +/** + * Default data to create a new user + * @typedef {Object} DefaultUser + * @property {String} email - The user email + * @property {String} password - The user password + * @property {Boolean} active - The user status + * @property {String} role - The name of the role + */ + +class User { + /** + * Class for handling User operations + */ + constructor() { + this.User = _User.default; + } + + /** + * Get all users that match the conditions + * - Gets all users if no conditions are provided + * @async + * @param {object} conditions - Conditions to filter the users + * @returns {Promise} Users + */ + async getUsers(conditions) { + try { + const users = await _User.default.findAll({ + where: conditions + }); + users.forEach(user => { + // eslint-disable-next-line no-param-reassign + delete user.dataValues.password; + }); + return users; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|00"); + } + } + + /** + * Get a single user + * @async + * @param {object} conditions - Conditions to filter the user + * @returns {Promise} User + */ + async getUser(conditions = {}) { + try { + const user = await _User.default.findOne({ + where: conditions + }); + // if (user) delete user.dataValues.password + + return user; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|01"); + } + } + + /** + * Hashes a password + * - Also ensures the password complies with the password policy + * @param {*} password + */ + async hashPassword(password) { + try { + if (!password) throw new _CodedError.default("Password is required", 400, "USER|32"); + if (!(0, _isStrongPassword.default)(password)) throw new _CodedError.default("Password does not meet requirements", 400, "USER|33"); + const hashedPassword = await _bcryptjs.default.hash(password, 10); + return hashedPassword; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|34"); + } + } + + /** + * Creates a new User + * @async + * @param {object} data - Data to create the user + * @returns {Promise} User + */ + async createUser(data = {}) { + const role = new _rolesClass.default(); + try { + const createUserData = { + active: data.active ?? true + }; + if (!data.email) throw new _CodedError.default("Email is required", 400, "USER|02"); + createUserData.email = data.email; + const roleName = data.role || "User"; + const roleObject = await role.getRole({ + name: roleName + }); + createUserData.roleId = roleObject.id; + if (!roleObject) { + if (roleName !== "User") throw new _CodedError.default("Role not found", 400, "USER|03"); + const newRole = await role.createRole({ + name: roleName + }); + createUserData.roleId = newRole.id; + } + if (data.password) { + const hashedPassword = await this.hashPassword(data.password); + createUserData.password = hashedPassword; + } + const user = await _User.default.create(createUserData); + return user; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|04"); + } + } + + /** + * Updates a single user + * - Can be used to update a user's password + * - Can be used to update a user's role + * @async + * @param {object} data - Data to update the user + * @param {object} conditions - Conditions to filter the user + * @returns {Promise} User + */ + async updateUser(data, conditions) { + try { + // get users + const user = await this.getUser(conditions); + if (!user.length) throw new _CodedError.default("No users found", 400, "USER|05"); + + // prep data + const updateUserData = {}; + if (data.email) { + const userWithEmail = await this.getUser({ + email: data.email + }); + if (userWithEmail && user.dataValues.id !== userWithEmail.dataValues.id) throw new _CodedError.default("Email already taken", 400, "USER|06"); + updateUserData.email = data.email; + } + if (typeof data.active !== "undefined") updateUserData.active = data.active; + if (data.password) updateUserData.password = await this.hashPassword(data.password); + if (data.role) { + const role = new _rolesClass.default(); + const roleObject = await role.getRole({ + name: data.role + }); + if (!roleObject) throw new _CodedError.default("Role not found", 400, "USER|07"); + updateUserData.roleId = roleObject.id; + } + + // update user + const updatedUser = await user.update(updateUserData); + return updatedUser; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|08"); + } + } + + /** + * Deletes a single user + * @async + * @param {object} conditions - Will delete the first user that matches the conditions + * @returns {Promise} User + */ + async deleteUser(conditions) { + try { + const user = await this.getUser(conditions); + if (!user) throw new _CodedError.default("User not found", 400, "USER|09"); + await user.destroy(); + return user; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|10"); + } + } + + /** + * Checks if a user has a capability + * @async + * @param {Number} userId - The user id + * @param {String} capability - The capability to check + * @returns {Promise} - True if the user has the capability + */ + async hasCapabilities(userId, ...capabilities) { + return true; + // try { + // return true // right now, all users have all capabilities + + // // if (!userId) throw new CodedError("User ID is required", 400, "USER|11") + // // if (!capabilities.length) throw new CodedError("At least 1 capability is required", 400, "USER|12") + + // // const user = await this.getUser({ id: userId }) + // // if (!user) throw new CodedError("User not found", 400, "USER|13") + + // // const userRole = await user.getRole() + // // const userRoleData = userRole.get() + // // const userCapabilities = userRoleData.capabilities.map((capability) => capability.dataValues?.name) + // // const userHasAllCapabilities = capabilities.every((capability) => userCapabilities.includes(capability)) + + // // return userHasAllCapabilities + // } catch (error) { + // // throw new CodedError(error.message, error.status ?? 500, error.location ?? "USER|15") + // } + } + + /** + * Checks if a password matches the user's password + * @async + * @param {String} userId - The user id + * @param {String} password - The password to check + * @returns {Promise} - True if the password matches + */ + async checkPassword(userId, password) { + try { + const user = await this.getUser({ + id: userId + }); + if (!user) throw new _CodedError.default("User not found", 400, "USER|13"); + const passwordMatches = await _bcryptjs.default.compare(password, user?.dataValues?.password); + return passwordMatches; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|14"); + } + } + + /** + * Generate a session token for a user + * @async + * @param {Number} userId - The user id + * @returns {Promise} - The session token + */ + async createSessionToken(userId) { + try { + const jwt = new _jwtClass.default(); + const user = await this.getUser({ + id: userId + }); + if (!user) throw new _CodedError.default("User not found", 400, "USER|15"); + const token = await jwt.sign({ + id: user.id, + email: user.email, + role: user?.dataValues?.roleId, + mfa: user?.dataValues?.mfa ?? false, + sessionState: "full" + }, "15m" // change to 15m in production + ); + + return token; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|16"); + } + } + + /** + * Create a 'half' session token for a user + * - This token signifies that the user has input their password correctly + * - but has not yet completed 2FA + * @async + * @param {Number} userId - The user id + * @returns {Promise} - The session token + */ + async createHalfSessionToken(userId) { + try { + const jwt = new _jwtClass.default(); + const user = await this.getUser({ + id: userId + }); + if (!user) throw new _CodedError.default("User not found", 400, "USER|15"); + const token = await jwt.sign({ + id: user.id, + email: user.email, + role: user?.dataValues?.roleId, + sessionState: "half" + }, "5m"); + return token; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|16"); + } + } + + /** + * Generates a refresh token for a user + * @async + * @param {Number} userId - The user id + * @returns {Promise} - The refresh token + */ + async createRefreshToken(userId) { + try { + const jwt = new _jwtClass.default(process.env.REFRESH_JWT_PUBLIC, process.env.REFRESH_JWT_PRIVATE); + const user = await this.getUser({ + id: userId + }); + if (!user) throw new _CodedError.default("User not found", 400, "USER|17"); + + // expires in 1 week + const token = await jwt.sign({ + id: user.id, + email: user.email, + role: user?.dataValues?.roleId + }, "1w"); + return token; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|18"); + } + } + + /** + * Checks if a session token is valid + * - Session token needs to be a 'full' session token + * - Half session tokens are not valid + * @async + * @param {String} token - The session token + * @returns {Promise} - True if the token is valid + */ + async checkSessionToken(token) { + try { + const jwt = new _jwtClass.default(); + const decodedToken = await jwt.verify(token); + if (decodedToken instanceof _CodedError.default) throw decodedToken; + if (decodedToken.sessionState !== "full") throw new _CodedError.default("Session Token is invalid", 400, "USER|18"); + const user = await this.getUser({ + id: decodedToken.id + }); + if (!user) throw new _CodedError.default("User not found", 400, "USER|19"); + return true; + } catch (error) { + return false; + } + } + + /** + * Checks if a half session token is valid + * @async + * @param {String} token - The session token + * @returns {Promise} - True if the token is valid + */ + async checkHalfSessionToken(token) { + try { + const jwt = new _jwtClass.default(); + const decodedToken = await jwt.verify(token); + if (decodedToken instanceof _CodedError.default) throw decodedToken; + if (decodedToken.sessionState !== "half") throw new _CodedError.default("Session Token is invalid", 400, "USER|18"); + const user = await this.getUser({ + id: decodedToken.id + }); + if (!user) throw new _CodedError.default("User not found", 400, "USER|19"); + return true; + } catch (error) { + return false; + } + } + + /** + * Checks if a refresh token is valid + * @async + * @param {String} token - The refresh token + * @returns {Promise} - An object containing the data from the token + */ + async checkRefreshToken(token) { + try { + if (!token) throw new _CodedError.default("Refresh Token is required", 400, "USER|20"); + if (!(0, _isJWT.default)(token)) throw new _CodedError.default("Token is invalid", 400, "USER|21"); + const jwt = new _jwtClass.default(process.env.REFRESH_JWT_PUBLIC, process.env.REFRESH_JWT_PRIVATE); + const decodedToken = await jwt.verify(token); + if (decodedToken instanceof _CodedError.default) throw decodedToken; + const user = await this.getUser({ + id: decodedToken.id + }); + if (!user) throw new _CodedError.default("User not found", 400, "USER|21"); + return decodedToken; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|22"); + } + } + + /** + * Refreshes a session token + * @async + * @param {String} refreshToken - The refresh token + * @returns {Promise} - An object containing the new session token and new refresh token + */ + async refreshSessionToken(refreshToken) { + try { + const jwt = new _jwtClass.default(); + const validRefreshToken = await this.checkRefreshToken(refreshToken); + const token = await this.createSessionToken(validRefreshToken.id); + const newRefreshToken = await this.createRefreshToken(validRefreshToken.id); + const blacklistOldToken = await jwt.blacklist(refreshToken); + if (!blacklistOldToken) throw new _CodedError.default("Could not blacklist old token", 500, "USER|23"); + return { + sessionToken: token, + refreshToken: newRefreshToken + }; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|24"); + } + } + + /** + * Issues a password reset token + * @async + * @param {String} email - The user email + * @returns {Promise} - The password reset token + */ + async createPasswordResetToken(email) { + try { + const jwt = new _jwtClass.default(process.env.PASSWORD_JWT_PUBLIC, process.env.PASSWORD_JWT_PRIVATE); + const user = await this.getUser({ + email + }); + if (!user) throw new _CodedError.default("User not found", 400, "USER|25"); + const token = await jwt.sign({ + id: user.id, + email: user.email + }, "15m"); + return token; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|26"); + } + } + + /** + * Checks if a password reset token is valid + * @async + * @param {String} token - The password reset token + * @returns {Promise} - True if the token is valid + */ + async checkPasswordResetToken(token) { + try { + const jwt = new _jwtClass.default(process.env.PASSWORD_JWT_PUBLIC, process.env.PASSWORD_JWT_PRIVATE); + const decodedToken = await jwt.verify(token); + if (decodedToken instanceof _CodedError.default) throw decodedToken; + const user = await this.getUser({ + id: decodedToken.id + }); + if (!user) throw new _CodedError.default("User not found", 400, "USER|27"); + return true; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|28"); + } + } + + /** + * Resets a user's password + * @async + * @param {String} token - The password reset token + * @param {String} password - The new password + * @returns {Promise} - True if the password was reset + */ + async resetPassword(token, password) { + try { + if (!token) throw new _CodedError.default("Password Token is required", 400, "USER|29"); + if (!password) throw new _CodedError.default("Password is required", 400, "USER|30"); + const jwt = new _jwtClass.default(process.env.PASSWORD_JWT_PUBLIC, process.env.PASSWORD_JWT_PRIVATE); + const decodedToken = await jwt.verify(token); + if (decodedToken instanceof _CodedError.default) throw decodedToken; + const user = await this.getUser({ + id: decodedToken.id + }); + if (!user) throw new _CodedError.default("User not found", 400, "USER|29"); + const hashedPassword = await this.hashPassword(password); + await user.update({ + password: hashedPassword + }); + const blacklistOldToken = await jwt.blacklist(token); + if (!blacklistOldToken) throw new _CodedError.default("Could not blacklist old token", 400, "USER|30"); + return true; + } catch (error) { + throw new _CodedError.default(error.message, 400, "USER|31"); + } + } +} +var _default = exports.default = User; \ No newline at end of file diff --git a/api/config/CodedError.js b/api/config/CodedError.js new file mode 100644 index 0000000..b2f0626 --- /dev/null +++ b/api/config/CodedError.js @@ -0,0 +1,24 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _dotenv = _interopRequireDefault(require("dotenv")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +_dotenv.default.config({ + path: ".env" +}); +_dotenv.default.config({ + path: ".env.secrets" +}); +class CodedError extends Error { + constructor(message, status, location, data) { + super(message); // Human-readable message + this.name = this.constructor.name; + this.status = status; // HTTP status code + this.location = location; + this.data = data; + } +} +var _default = exports.default = CodedError; \ No newline at end of file diff --git a/api/config/config.js b/api/config/config.js new file mode 100644 index 0000000..d57108c --- /dev/null +++ b/api/config/config.js @@ -0,0 +1,34 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +require("dotenv/config"); +const config = { + development: { + username: process.env.DEV_DB_USER, + password: process.env.DEV_DB_PASS, + database: process.env.DEV_DB_NAME, + host: process.env.DEV_DB_HOST, + port: process.env.DEV_DB_PORT, + dialect: "mysql" + }, + test: { + username: process.env.TEST_DB_USER, + password: process.env.TEST_DB_PASS, + database: process.env.TEST_DB_NAME, + host: process.env.TEST_DB_HOST, + port: process.env.TEST_DB_PORT, + dialect: "mysql" + }, + production: { + username: process.env.PROD_DB_USER, + password: process.env.PROD_DB_PASS, + database: process.env.PROD_DB_NAME, + host: process.env.PROD_DB_HOST, + port: process.env.PROD_DB_PORT, + dialect: "mysql" + } +}; +var _default = exports.default = config; \ No newline at end of file diff --git a/api/config/database.js b/api/config/database.js new file mode 100644 index 0000000..f6a85a0 --- /dev/null +++ b/api/config/database.js @@ -0,0 +1,25 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +require("dotenv/config"); +var _sequelize = require("sequelize"); +var _config2 = _interopRequireDefault(require("./config")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const nodeEnv = process.env.NODE_ENV || "development"; +const { + username: user, + password, + database: db, + host, + port +} = _config2.default[nodeEnv]; +const sequelize = new _sequelize.Sequelize(db, user, password, { + host, + port, + dialect: "mysql", + logging: false +}); +var _default = exports.default = sequelize; \ No newline at end of file diff --git a/api/index.js b/api/index.js new file mode 100644 index 0000000..2e86da6 --- /dev/null +++ b/api/index.js @@ -0,0 +1,48 @@ +"use strict"; + +var _express = _interopRequireWildcard(require("express")); +require("dotenv/config"); +var _cors = _interopRequireDefault(require("cors")); +var _cookieParser = _interopRequireDefault(require("cookie-parser")); +var _helmet = _interopRequireDefault(require("helmet")); +var _database = _interopRequireDefault(require("./config/database")); +var _requireSSL = _interopRequireDefault(require("./middleware/requireSSL")); +var _baseRoutes = _interopRequireDefault(require("./routes/baseRoutes")); +var _registerRoutes = _interopRequireDefault(require("./routes/registerRoutes")); +var _loginRoutes = _interopRequireDefault(require("./routes/loginRoutes")); +var _passwordRoutes = _interopRequireDefault(require("./routes/passwordRoutes")); +var _userRoutes = _interopRequireDefault(require("./routes/userRoutes")); +var _refreshRoutes = _interopRequireDefault(require("./routes/refreshRoutes")); +var _totpRoutes = _interopRequireDefault(require("./routes/totpRoutes")); +require("./models/index"); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); } +function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && Object.prototype.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; } +// Routes + +// DB Models + +const PORT = process.env.PORT || 3000; +const app = (0, _express.default)(); +app.set("trust proxy", 1); +app.use((0, _express.json)()); +app.use((0, _cors.default)()); +app.use((0, _helmet.default)()); +app.use((0, _cookieParser.default)()); +app.use(_requireSSL.default); +app.use("/", _baseRoutes.default); +app.use("/register", _registerRoutes.default); +app.use("/login", _loginRoutes.default); +app.use("/password", _passwordRoutes.default); +app.use("/users", _userRoutes.default); +app.use("/refresh", _refreshRoutes.default); +app.use("/totp", _totpRoutes.default); +app.listen(PORT, async () => { + try { + await _database.default.sync(); + } catch (error) { + if (process.env.NODE_ENV !== "development") return; + // eslint-disable-next-line no-console + console.error("Error syncing database:", error.message); + } +}); \ No newline at end of file diff --git a/api/middleware/errorMiddleware.js b/api/middleware/errorMiddleware.js new file mode 100644 index 0000000..4527b4c --- /dev/null +++ b/api/middleware/errorMiddleware.js @@ -0,0 +1,13 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _responseClass = _interopRequireDefault(require("../classes/responseClass")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const errorMiddleware = (err, req, res) => { + const response = new _responseClass.default(req, res); + response.error(err); +}; +var _default = exports.default = errorMiddleware; \ No newline at end of file diff --git a/api/middleware/rateLimiters.js b/api/middleware/rateLimiters.js new file mode 100644 index 0000000..1ed81a4 --- /dev/null +++ b/api/middleware/rateLimiters.js @@ -0,0 +1,22 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.noAuthLimiter = exports.authLimiter = void 0; +var _expressRateLimit = _interopRequireDefault(require("express-rate-limit")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const noAuthLimiter = exports.noAuthLimiter = (0, _expressRateLimit.default)({ + windowMs: 15 * 60 * 1000, + // 15 minutes + max: 50, + standardHeaders: true, + legacyHeaders: false +}); +const authLimiter = exports.authLimiter = (0, _expressRateLimit.default)({ + windowMs: 5 * 60 * 1000, + // 5 minutes + max: 100, + standardHeaders: true, + legacyHeaders: false +}); \ No newline at end of file diff --git a/api/middleware/requireSSL.js b/api/middleware/requireSSL.js new file mode 100644 index 0000000..a3ef724 --- /dev/null +++ b/api/middleware/requireSSL.js @@ -0,0 +1,17 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = requireSSL; +var _responseClass = _interopRequireDefault(require("../classes/responseClass")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function requireSSL(req, res, next) { + const response = new _responseClass.default(req, res); + try { + // if (!req.secure && process.env.NODE_ENV === "production") throw new Error("SSL required") + next(); + } catch (err) { + response.error(err); + } +} \ No newline at end of file diff --git a/api/middleware/verifyUser.js b/api/middleware/verifyUser.js new file mode 100644 index 0000000..69c99dd --- /dev/null +++ b/api/middleware/verifyUser.js @@ -0,0 +1,48 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _isJWT = _interopRequireDefault(require("validator/lib/isJWT")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +var _usersClass = _interopRequireDefault(require("../classes/usersClass")); +var _cookiesClass = _interopRequireDefault(require("../classes/cookiesClass")); +var _responseClass = _interopRequireDefault(require("../classes/responseClass")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +/** + * Middleware to verify a user's session + * @param {...string} capabilities - The capabilities required to access the route + */ + +function verifyUser(...capabilities) { + return async function verifyUserInner(req, res, next) { + const response = new _responseClass.default(req, res); + let token = req.headers?.authorization?.split(" ")?.[1]; // Bearer + + try { + if (!token) throw new _CodedError.default("Session Token is required", 400, "VERIFY|01"); + if (!(0, _isJWT.default)(token)) throw new _CodedError.default("Session Token is invalid", 400, "VERIFY|02"); + const userMethods = new _usersClass.default(); + const userTokenIsValid = await userMethods.checkSessionToken(token); + const tokenContent = JSON.parse(atob(token.split(".")[1])); + if (tokenContent.sessionState !== "full") throw new _CodedError.default("Session Token is invalid", 400, "VERIFY|021"); + if (!userTokenIsValid) { + const cookies = new _cookiesClass.default(req, res); + const refreshToken = cookies.getRefreshToken(); + const newTokens = await userMethods.refreshSessionToken(refreshToken); + token = newTokens.sessionToken; + req.token = newTokens.sessionToken; + cookies.setRefreshToken(newTokens.refreshToken); + } + req.user = JSON.parse(atob(token.split(".")[1])); + if (!capabilities.length) return next(); + const userHasCapabilities = await userMethods.hasCapabilities(req.user.id, ...capabilities); + if (!userHasCapabilities) throw new _CodedError.default("User does not have required capabilities", 403, "VERIFY|03"); + next(); + } catch (error) { + response.error(error); + } + }; +} +var _default = exports.default = verifyUser; \ No newline at end of file diff --git a/api/models/AuthFactor.js b/api/models/AuthFactor.js new file mode 100644 index 0000000..4f95018 --- /dev/null +++ b/api/models/AuthFactor.js @@ -0,0 +1,35 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _sequelize = require("sequelize"); +var _database = _interopRequireDefault(require("../config/database")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +// storing information used in 2-factor authentication (2FA) +const AuthFactor = _database.default.define("AuthFactor", { + id: { + type: _sequelize.DataTypes.UUID, + primaryKey: true, + defaultValue: _sequelize.DataTypes.UUIDV4 + }, + factor: { + type: _sequelize.DataTypes.STRING, + allowNull: false + }, + secret: { + type: _sequelize.DataTypes.STRING, + allowNull: false + }, + verified: { + type: _sequelize.DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + verifiedAt: { + type: _sequelize.DataTypes.DATE, + allowNull: true + } +}); +var _default = exports.default = AuthFactor; \ No newline at end of file diff --git a/api/models/Capability.js b/api/models/Capability.js new file mode 100644 index 0000000..54debbb --- /dev/null +++ b/api/models/Capability.js @@ -0,0 +1,28 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _sequelize = require("sequelize"); +var _database = _interopRequireDefault(require("../config/database")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const Capability = _database.default.define("Capability", { + id: { + type: _sequelize.DataTypes.INTEGER, + // or DataTypes.UUID + primaryKey: true, + autoIncrement: true // set to false if using UUID + }, + + name: { + type: _sequelize.DataTypes.STRING, + allowNull: false, + unique: true + }, + description: { + type: _sequelize.DataTypes.STRING, + allowNull: true + } +}); +var _default = exports.default = Capability; \ No newline at end of file diff --git a/api/models/Role.js b/api/models/Role.js new file mode 100644 index 0000000..225763a --- /dev/null +++ b/api/models/Role.js @@ -0,0 +1,31 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _sequelize = require("sequelize"); +var _database = _interopRequireDefault(require("../config/database")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const Role = _database.default.define("Role", { + id: { + type: _sequelize.DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: _sequelize.DataTypes.STRING, + allowNull: false, + unique: true + }, + description: { + type: _sequelize.DataTypes.STRING, + allowNull: true + } +}, { + indexes: [{ + unique: true, + fields: ["name"] + }] +}); +var _default = exports.default = Role; \ No newline at end of file diff --git a/api/models/Role_Capability.js b/api/models/Role_Capability.js new file mode 100644 index 0000000..3357d86 --- /dev/null +++ b/api/models/Role_Capability.js @@ -0,0 +1,17 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _sequelize = require("sequelize"); +var _database = _interopRequireDefault(require("../config/database")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const RoleCapability = _database.default.define("Role_Capability", { + id: { + type: _sequelize.DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + } +}); +var _default = exports.default = RoleCapability; \ No newline at end of file diff --git a/api/models/Token.js b/api/models/Token.js new file mode 100644 index 0000000..cfa65f4 --- /dev/null +++ b/api/models/Token.js @@ -0,0 +1,30 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _sequelize = require("sequelize"); +var _database = _interopRequireDefault(require("../config/database")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const Token = _database.default.define("Token", { + id: { + type: _sequelize.DataTypes.UUID, + primaryKey: true, + defaultValue: _sequelize.DataTypes.UUIDV4 + }, + token: { + type: _sequelize.DataTypes.STRING(1000), + allowNull: false + }, + expires: { + type: _sequelize.DataTypes.DATE, + allowNull: false + }, + blacklisted: { + type: _sequelize.DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + } +}); +var _default = exports.default = Token; \ No newline at end of file diff --git a/api/models/User.js b/api/models/User.js new file mode 100644 index 0000000..bd23c61 --- /dev/null +++ b/api/models/User.js @@ -0,0 +1,41 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _sequelize = require("sequelize"); +var _database = _interopRequireDefault(require("../config/database")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const User = _database.default.define("User", { + id: { + type: _sequelize.DataTypes.UUID, + primaryKey: true, + defaultValue: _sequelize.DataTypes.UUIDV4 + }, + email: { + type: _sequelize.DataTypes.STRING, + allowNull: false + }, + password: { + type: _sequelize.DataTypes.STRING + }, + active: { + type: _sequelize.DataTypes.BOOLEAN, + defaultValue: true + }, + mfa: { + type: _sequelize.DataTypes.BOOLEAN, + defaultValue: false + }, + primaryAuth: { + type: _sequelize.DataTypes.STRING, + defaultValue: "UNPW" + } +}, { + indexes: [{ + unique: true, + fields: ["email"] + }] +}); +var _default = exports.default = User; \ No newline at end of file diff --git a/api/models/associations.js b/api/models/associations.js new file mode 100644 index 0000000..97ae807 --- /dev/null +++ b/api/models/associations.js @@ -0,0 +1,58 @@ +"use strict"; + +var _User = _interopRequireDefault(require("./User")); +var _Role = _interopRequireDefault(require("./Role")); +var _Capability = _interopRequireDefault(require("./Capability")); +var _AuthFactor = _interopRequireDefault(require("./AuthFactor")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +_User.default.belongsTo(_Role.default, { + foreignKey: "roleId" +}); +_Role.default.hasMany(_User.default, { + foreignKey: "roleId" +}); +_User.default.hasMany(_AuthFactor.default, { + foreignKey: "userId" +}); +_AuthFactor.default.belongsTo(_User.default, { + foreignKey: "userId" +}); +_Role.default.belongsToMany(_Capability.default, { + through: "Role_Capability", + as: "capabilities" +}); +_Capability.default.belongsToMany(_Role.default, { + through: "Role_Capability", + as: "roles" +}); +_Role.default.addScope("defaultScope", { + include: [{ + model: _Capability.default, + as: "capabilities", + attributes: ["id", "name", "description"], + through: { + attributes: [] + } + }] +}, { + override: true +}); +_User.default.addScope("defaultScope", { + include: [{ + model: _Role.default, + attributes: ["name", "description"], + include: [{ + model: _Capability.default, + as: "capabilities", + attributes: ["name", "description"], + through: { + attributes: [] + } + }] + }, { + model: _AuthFactor.default, + attributes: ["factor", "verified", "verifiedAt"] + }] +}, { + override: true +}); \ No newline at end of file diff --git a/api/models/index.js b/api/models/index.js new file mode 100644 index 0000000..3863a72 --- /dev/null +++ b/api/models/index.js @@ -0,0 +1,17 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const basename = path.basename(__filename); +fs.readdirSync(__dirname).filter(file => { + const isJavaScriptFile = file.indexOf(".") !== 0; + const isNotCurrentFile = file !== basename; + const isJavaScriptExtension = file.slice(-3) === ".js"; + const isNotTestFile = file.indexOf(".test.js") === -1; + const isNotAssociationFile = file !== "associations.js"; + return isJavaScriptFile && isNotCurrentFile && isJavaScriptExtension && isNotTestFile && isNotAssociationFile; +}).forEach(file => { + // eslint-disable-next-line import/no-dynamic-require, global-require + require(path.join(__dirname, file)); +}); +require("./associations"); \ No newline at end of file diff --git a/api/routes/baseRoutes.js b/api/routes/baseRoutes.js new file mode 100644 index 0000000..124a3b5 --- /dev/null +++ b/api/routes/baseRoutes.js @@ -0,0 +1,23 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _express = _interopRequireDefault(require("express")); +var _verifyUser = _interopRequireDefault(require("../middleware/verifyUser")); +var _responseClass = _interopRequireDefault(require("../classes/responseClass")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const router = _express.default.Router(); +router.get("/", (0, _verifyUser.default)(), async (req, res) => { + const response = new _responseClass.default(req, res); + try { + response.success({ + message: "Hello, world!" + }); + } catch (error) { + response.error(error); + } +}); +const baseRoutes = router; +var _default = exports.default = baseRoutes; \ No newline at end of file diff --git a/api/routes/loginRoutes.js b/api/routes/loginRoutes.js new file mode 100644 index 0000000..30dae9d --- /dev/null +++ b/api/routes/loginRoutes.js @@ -0,0 +1,59 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _express = _interopRequireDefault(require("express")); +var _isEmail = _interopRequireDefault(require("validator/lib/isEmail")); +var _normalizeEmail = _interopRequireDefault(require("validator/lib/normalizeEmail")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +var _usersClass = _interopRequireDefault(require("../classes/usersClass")); +var _rateLimiters = require("../middleware/rateLimiters"); +var _responseClass = _interopRequireDefault(require("../classes/responseClass")); +var _cookiesClass = _interopRequireDefault(require("../classes/cookiesClass")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const router = _express.default.Router(); +router.post("/", _rateLimiters.noAuthLimiter, async (req, res) => { + let { + email, + password + } = req.body; + const response = new _responseClass.default(req, res); + try { + if (!password) throw new _CodedError.default("Invalid Password", 400, "LOG|02"); + if (!email || !(0, _isEmail.default)(email)) throw new _CodedError.default("Invalid Email", 400, "LOG|01"); + email = (0, _normalizeEmail.default)(email, { + gmail_remove_subaddress: false + }); + const userMethods = new _usersClass.default(); + const user = await userMethods.getUser({ + email + }); + if (!user) throw new _CodedError.default("User not found", 400, "LOG|03"); + const userId = user.id; + const isPasswordValid = await userMethods.checkPassword(userId, password); + if (!isPasswordValid) throw new _CodedError.default("Password is incorrect", 400, "LOG|04"); + let token; + if (user?.dataValues?.mfa) { + token = await userMethods.createHalfSessionToken(userId); + } else { + const cookies = new _cookiesClass.default(req, res); + token = await userMethods.createSessionToken(userId); + cookies.setSessionCookie(token); + const refreshToken = await userMethods.createRefreshToken(userId); + if (!refreshToken) throw new _CodedError.default("Error creating refresh token", 500, "REG|06"); + cookies.setRefreshToken(refreshToken); + } + const tokenBody = JSON.parse(atob(token.split(".")[1])); + response.setToken(token); + response.success({ + message: "Login successful", + data: tokenBody + }); + } catch (error) { + response.error(error); + } +}); +const loginRoutes = router; +var _default = exports.default = loginRoutes; \ No newline at end of file diff --git a/api/routes/passwordRoutes.js b/api/routes/passwordRoutes.js new file mode 100644 index 0000000..03edbe0 --- /dev/null +++ b/api/routes/passwordRoutes.js @@ -0,0 +1,99 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _express = _interopRequireDefault(require("express")); +var _isEmail = _interopRequireDefault(require("validator/lib/isEmail")); +var _isJWT = _interopRequireDefault(require("validator/lib/isJWT")); +var _normalizeEmail = _interopRequireDefault(require("validator/lib/normalizeEmail")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +var _responseClass = _interopRequireDefault(require("../classes/responseClass")); +var _jwtClass = _interopRequireDefault(require("../classes/jwtClass")); +var _usersClass = _interopRequireDefault(require("../classes/usersClass")); +var _sendEmail = _interopRequireDefault(require("../utils/sendEmail")); +var _rateLimiters = require("../middleware/rateLimiters"); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const router = _express.default.Router(); +router.post("/forgot", _rateLimiters.noAuthLimiter, async (req, res) => { + const response = new _responseClass.default(req, res); + let { + email + } = req.body; + try { + if (!email) throw new _CodedError.default("Email is required", 400, "PAS|01"); + if (!(0, _isEmail.default)(email)) throw new _CodedError.default("Invalid Email", 400, "PAS|02"); + email = (0, _normalizeEmail.default)(email, { + gmail_remove_subaddress: false + }); + const userMethods = new _usersClass.default(); + const user = await userMethods.getUser({ + email + }); + if (!user) throw new _CodedError.default("User not found", 400, "PAS|02"); + const token = await userMethods.createPasswordResetToken(email); + const emailSent = await (0, _sendEmail.default)({ + to: email, + subject: "Password Reset", + text: `Click here to reset your password: ${process.env.PASSWORD_RESET_URL}/${token}` + }); + if (!emailSent) throw new _CodedError.default("Email failed to send", 500, "PAS|03"); + response.success({ + message: "Email sent" + }); + } catch (error) { + response.error(error); + } +}); +router.get("/reset/:token", _rateLimiters.noAuthLimiter, async (req, res) => { + const response = new _responseClass.default(req, res); + const { + token + } = req.params; + try { + if (!token) throw new _CodedError.default("Token is required", 400, "PAS|04"); + if (!(0, _isJWT.default)(token)) throw new _CodedError.default("Invalid Token", 400, "PAS|05"); + const jwt = new _jwtClass.default(process.env.PASSWORD_JWT_PUBLIC, process.env.PASSWORD_JWT_PRIVATE); + let decoded; + try { + decoded = await jwt.verify(token); + } catch (error) { + throw new _CodedError.default("Verification Failed", 400, "PAS|05"); + } + const userMethods = new _usersClass.default(); + const user = await userMethods.getUser({ + email: decoded.email + }); + if (!user) throw new _CodedError.default("User not found", 400, "PAS|06"); + response.success({ + message: "Token is valid" + }); + } catch (error) { + response.error(error); + } +}); +router.post("/reset/:token", _rateLimiters.noAuthLimiter, async (req, res) => { + const response = new _responseClass.default(req, res); + const { + token + } = req.params; + const { + password + } = req.body; + try { + if (!token) throw new _CodedError.default("Token is required", 400, "PAS|07"); + if (!(0, _isJWT.default)(token)) throw new _CodedError.default("Invalid Token", 400, "PAS|07"); + if (!password) throw new _CodedError.default("Password is required", 400, "PAS|08"); + const userMethods = new _usersClass.default(); + const resetPassword = await userMethods.resetPassword(token, password); + if (!resetPassword) throw new _CodedError.default("Password reset failed", 500, "PAS|09"); + response.success({ + message: "Password reset successful" + }); + } catch (error) { + response.error(error); + } +}); +const passwordRoutes = router; +var _default = exports.default = passwordRoutes; \ No newline at end of file diff --git a/api/routes/refreshRoutes.js b/api/routes/refreshRoutes.js new file mode 100644 index 0000000..34258b1 --- /dev/null +++ b/api/routes/refreshRoutes.js @@ -0,0 +1,38 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _express = _interopRequireDefault(require("express")); +var _responseClass = _interopRequireDefault(require("../classes/responseClass")); +var _usersClass = _interopRequireDefault(require("../classes/usersClass")); +var _cookiesClass = _interopRequireDefault(require("../classes/cookiesClass")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const router = _express.default.Router(); +router.post("/", async (req, res) => { + const response = new _responseClass.default(req, res); + const { + refreshToken + } = req.body; + try { + const cookies = new _cookiesClass.default(req, res); + if (!refreshToken) throw new _CodedError.default("Token not found", 401, "REFRESH|01"); + const userMethods = new _usersClass.default(); + const { + sessionToken, + refreshToken: newRefreshToken + } = await userMethods.refreshSessionToken(refreshToken); + const refreshTokenSet = cookies.setRefreshToken(newRefreshToken); + if (!refreshTokenSet) throw new _CodedError.default("Token not set", 500, "REFRESH|02"); + response.setToken(sessionToken); + response.success({ + refreshToken: newRefreshToken + }); + } catch (error) { + response.error(error); + } +}); +const refreshRoutes = router; +var _default = exports.default = refreshRoutes; \ No newline at end of file diff --git a/api/routes/registerRoutes.js b/api/routes/registerRoutes.js new file mode 100644 index 0000000..750017b --- /dev/null +++ b/api/routes/registerRoutes.js @@ -0,0 +1,58 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _express = _interopRequireDefault(require("express")); +var _isEmail = _interopRequireDefault(require("validator/lib/isEmail")); +var _normalizeEmail = _interopRequireDefault(require("validator/lib/normalizeEmail")); +var _isStrongPassword = _interopRequireDefault(require("validator/lib/isStrongPassword")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +var _usersClass = _interopRequireDefault(require("../classes/usersClass")); +var _responseClass = _interopRequireDefault(require("../classes/responseClass")); +var _cookiesClass = _interopRequireDefault(require("../classes/cookiesClass")); +var _rateLimiters = require("../middleware/rateLimiters"); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const router = _express.default.Router(); +router.post("/", _rateLimiters.noAuthLimiter, async (req, res) => { + const response = new _responseClass.default(req, res); + let { + email, + password + } = req.body; + try { + const user = new _usersClass.default(); + if (!email || !password) throw new _CodedError.default("Email and password are required", 400, "REG|01"); + if (!(0, _isEmail.default)(email)) throw new _CodedError.default("Invalid Email", 400, "REG|02"); + if (!(0, _isStrongPassword.default)(password)) throw new _CodedError.default("Invalid Password", 400, "REG|02"); + email = (0, _normalizeEmail.default)(email, { + gmail_remove_subaddress: false + }); + const existingUser = await user.getUser({ + email + }); + if (existingUser) throw new _CodedError.default("Email already exists", 500, "REG|03"); + const newUser = await user.createUser({ + email, + password + }); + if (!newUser) throw new _CodedError.default("Error creating user", 500, "REG|04"); + const token = await user.createSessionToken(newUser.id); + if (!token) throw new _CodedError.default("Error creating session token", 500, "REG|05"); + const refreshToken = await user.createRefreshToken(newUser.id); + if (!refreshToken) throw new _CodedError.default("Error creating refresh token", 500, "REG|06"); + const cookie = new _cookiesClass.default(req, res); + const setRefreshCookie = cookie.setRefreshToken(refreshToken); + if (!setRefreshCookie) throw new _CodedError.default("Error setting refresh token", 500, "REG|07"); + response.setToken(token); + response.success({ + message: "User created", + data: newUser + }); + } catch (error) { + response.error(error); + } +}); +const registerRoutes = router; +var _default = exports.default = registerRoutes; \ No newline at end of file diff --git a/api/routes/testRoutes.js b/api/routes/testRoutes.js new file mode 100644 index 0000000..78d626e --- /dev/null +++ b/api/routes/testRoutes.js @@ -0,0 +1,11 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _express = _interopRequireDefault(require("express")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const router = _express.default.Router(); +const testRoutes = router; +var _default = exports.default = testRoutes; \ No newline at end of file diff --git a/api/routes/totpRoutes.js b/api/routes/totpRoutes.js new file mode 100644 index 0000000..8ac5b59 --- /dev/null +++ b/api/routes/totpRoutes.js @@ -0,0 +1,121 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _express = _interopRequireDefault(require("express")); +var _totpClass = _interopRequireDefault(require("../classes/totpClass")); +var _responseClass = _interopRequireDefault(require("../classes/responseClass")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +var _verifyUser = _interopRequireDefault(require("../middleware/verifyUser")); +var _usersClass = _interopRequireDefault(require("../classes/usersClass")); +var _cookiesClass = _interopRequireDefault(require("../classes/cookiesClass")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const router = _express.default.Router(); + +/** + * Create TOTP auth factor for user and return URI for QR code + */ +router.post("/create", (0, _verifyUser.default)(), async (req, res) => { + const response = new _responseClass.default(req, res); + try { + const { + user + } = req; + const totp = new _totpClass.default(); + const totpRecord = await totp.createRecord(user.id); + const totpURI = totp.getURI(user.email, "Authenticator", totpRecord?.secret); + response.success({ + message: "TOTP created", + data: totpURI + }); + } catch (error) { + response.error(error); + } +}); + +/** + * Activate TOTP auth factor for user + */ +router.post("/activate", (0, _verifyUser.default)(), async (req, res) => { + const response = new _responseClass.default(req, res); + const { + code + } = req.body; + try { + const { + user + } = req; + const totp = new _totpClass.default(); + const activated = await totp.activateRecord(user.id, code); + if (!activated) throw new _CodedError.default("Error activating TOTP", 500, "TOTP|07"); + + // give user new session token with mfa flag + const userMethods = new _usersClass.default(); + const sessionToken = await userMethods.createSessionToken(user.id); + response.setToken(sessionToken); + response.success({ + message: "TOTP activated" + }); + } catch (error) { + response.error(error); + } +}); + +/** + * Verify TOTP auth factor for user, and authenticate the user + * - User must have a valid session token with "sessionState": "half" + */ +router.post("/verify", async (req, res) => { + const response = new _responseClass.default(req, res); + const { + code + } = req.body; + try { + const sessionToken = req.headers.authorization.split(" ")[1]; + const userMethods = new _usersClass.default(); + const halfSessionTokenIsValid = await userMethods.checkHalfSessionToken(sessionToken); + if (!halfSessionTokenIsValid) throw new _CodedError.default("Invalid session token", 400, "TOTP|05"); + const userId = JSON.parse(atob(sessionToken.split(".")[1])).id; + const totp = new _totpClass.default(); + const isValid = await totp.verify(userId, code); + if (!isValid) throw new _CodedError.default("Invalid code", 400, "TOTP|08"); + const cookies = new _cookiesClass.default(req, res); + const fullSessionToken = await userMethods.createSessionToken(userId); + if (!sessionToken) throw new _CodedError.default("Error creating session token", 500, "TOTP|09"); + cookies.setSessionCookie(fullSessionToken); + const refreshToken = await userMethods.createRefreshToken(userId); + if (!refreshToken) throw new _CodedError.default("Error creating refresh token", 500, "TOTP|10"); + const setRefreshCookie = cookies.setRefreshToken(refreshToken); + if (!setRefreshCookie) throw new _CodedError.default("Error setting refresh token", 500, "TOTP|11"); + response.setToken(fullSessionToken); + response.success({ + message: "User Session verified" + }); + } catch (error) { + response.error(error); + } +}); + +/** + * Deactivate TOTP auth factor for user + */ +router.post("/disable", (0, _verifyUser.default)(), async (req, res) => { + const response = new _responseClass.default(req, res); + try { + const { + user + } = req; + const totp = new _totpClass.default(); + const deactivated = await totp.deleteRecord(user.id); + if (!deactivated) throw new _CodedError.default("Error deactivating TOTP", 500, "TOTP|12"); + response.success({ + message: "TOTP deactivated" + }); + } catch (error) { + response.error(error); + } +}); +const totpRoutes = router; +var _default = exports.default = totpRoutes; \ No newline at end of file diff --git a/api/routes/userRoutes.js b/api/routes/userRoutes.js new file mode 100644 index 0000000..7babcb7 --- /dev/null +++ b/api/routes/userRoutes.js @@ -0,0 +1,120 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; +var _express = _interopRequireDefault(require("express")); +var _verifyUser = _interopRequireDefault(require("../middleware/verifyUser")); +var _usersClass = _interopRequireDefault(require("../classes/usersClass")); +var _responseClass = _interopRequireDefault(require("../classes/responseClass")); +var _CodedError = _interopRequireDefault(require("../config/CodedError")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const router = _express.default.Router(); +router.get("/:id", (0, _verifyUser.default)("users_read"), async (req, res) => { + const response = new _responseClass.default(req, res); + const { + id + } = req.params; + try { + const userMethods = new _usersClass.default(); + const { + user + } = req; + const userCanReadAllUsers = await userMethods.hasCapabilities(user.id, "users_read_all"); + const users = await userMethods.getUser({ + id + }); + if (!userCanReadAllUsers) { + if (users?.dataValues?.id === user.id) return response.success(users); + throw new _CodedError.default("You are not allowed to read this user", 403, "USERS|01"); + } + response.success(users); + } catch (error) { + response.error(error); + } +}); +router.get("/", (0, _verifyUser.default)("users_read"), async (req, res) => { + const response = new _responseClass.default(req, res); + try { + const userMethods = new _usersClass.default(); + const { + user + } = req; + const userCanReadAllUsers = await userMethods.hasCapabilities(user.id, "users_read_all"); + if (!userCanReadAllUsers) { + const users = await userMethods.getUsers({ + id: user.id + }); + return response.success(users); + } + const users = await userMethods.getUsers(); + response.success(users); + } catch (error) { + response.error(error); + } +}); +router.post("/", (0, _verifyUser.default)("users_create"), async (req, res) => { + const response = new _responseClass.default(req, res); + const { + body + } = req; + try { + const userMethods = new _usersClass.default(); + const { + user + } = req; + delete body.id; + delete body.password; // You can't set the password when creating a user. + + const userCanCreateUsers = await userMethods.hasCapabilities(user.id, "users_create"); + if (!userCanCreateUsers) throw new _CodedError.default("You are not allowed to create users", 403, "USERS|02"); + const newUser = await userMethods.createUser(body); + response.success(newUser); + } catch (error) { + response.error(error); + } +}); +router.put("/:id", (0, _verifyUser.default)("users_update"), async (req, res) => { + const response = new _responseClass.default(req, res); + const { + body + } = req; + const { + id + } = req.params; + try { + const userMethods = new _usersClass.default(); + const { + user + } = req; + delete body.password; + delete body.id; + const userCanUpdateUsers = await userMethods.hasCapabilities(user.id, "users_update"); + if (!userCanUpdateUsers) throw new _CodedError.default("You are not allowed to update users", 403, "USERS|03"); + const updatedUser = await userMethods.updateUser(id, body); + response.success(updatedUser); + } catch (error) { + response.error(error); + } +}); +router.delete("/:id", (0, _verifyUser.default)("users_delete"), async (req, res) => { + const response = new _responseClass.default(req, res); + const { + id + } = req.params; + try { + const userMethods = new _usersClass.default(); + const { + user + } = req; + const userCanDeleteUsers = await userMethods.hasCapabilities(user.id, "users_delete"); + if (!userCanDeleteUsers) throw new _CodedError.default("You are not allowed to delete users", 403, "USERS|04"); + await userMethods.deleteUser(id); + response.success("User deleted"); + } catch (error) { + response.error(error); + } +}); +const baseRoutes = router; +var _default = exports.default = baseRoutes; \ No newline at end of file diff --git a/api/utils/sendEmail.js b/api/utils/sendEmail.js new file mode 100644 index 0000000..3c80d13 --- /dev/null +++ b/api/utils/sendEmail.js @@ -0,0 +1,20 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = sendEmail; +async function sendEmail(email) { + const { + to, + subject, + text, + html + } = email; + console.log("Email sending is not implemented yet"); + console.log(`To: ${to}`); + console.log(`Subject: ${subject}`); + console.log(`Text: ${text}`); + console.log(`HTML: ${html}`); + return true; +} \ No newline at end of file