diff --git a/package.json b/package.json index 28705962f..4e220f664 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "staart-manager", - "version": "1.0.97", + "version": "1.0.98", "main": "index.js", "repository": "git@github.com:AnandChowdhary/staart.git", "author": "Anand Chowdhary ", @@ -135,5 +135,5 @@ "setup" ], "snyk": true, - "staart-version": "1.0.97" + "staart-version": "1.0.98" } \ No newline at end of file diff --git a/src/controllers/user.ts b/src/controllers/user.ts index e2239d329..597b9c1cd 100644 --- a/src/controllers/user.ts +++ b/src/controllers/user.ts @@ -13,7 +13,12 @@ import { verify2FAForUser, getBackupCodesForUser, regenerateBackupCodesForUser, - updatePasswordForUser + updatePasswordForUser, + deleteAccessTokenForUser, + updateAccessTokenForUser, + getUserAccessTokenForUser, + createAccessTokenForUser, + getUserAccessTokensForUser } from "../rest/user"; import { Get, @@ -36,7 +41,7 @@ import { } from "../rest/email"; import { CREATED } from "http-status-codes"; import asyncHandler from "express-async-handler"; -import { joiValidate } from "../helpers/utils"; +import { joiValidate, userUsernameToId } from "../helpers/utils"; import Joi from "@hapi/joi"; @Controller("users") @@ -45,8 +50,7 @@ import Joi from "@hapi/joi"; export class UserController { @Get(":id") async get(req: Request, res: Response) { - let id = req.body.id || req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); joiValidate( { id: [Joi.string().required(), Joi.number().required()] }, { id } @@ -80,8 +84,7 @@ export class UserController { ) ) async patch(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); joiValidate( { id: [Joi.string().required(), Joi.number().required()] }, { id } @@ -92,8 +95,7 @@ export class UserController { @Delete(":id") async delete(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); joiValidate( { id: [Joi.string().required(), Joi.number().required()] }, { id } @@ -116,8 +118,7 @@ export class UserController { ) ) async updatePassword(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); const oldPassword = req.body.oldPassword; const newPassword = req.body.newPassword; joiValidate( @@ -138,8 +139,7 @@ export class UserController { @Get(":id/events") async getRecentEvents(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); joiValidate( { id: [Joi.string().required(), Joi.number().required()] }, { id } @@ -149,8 +149,7 @@ export class UserController { @Get(":id/memberships") async getMemberships(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); joiValidate( { id: [Joi.string().required(), Joi.number().required()] }, { id } @@ -160,8 +159,7 @@ export class UserController { @Get(":id/data") async getUserData(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); joiValidate( { id: [Joi.string().required(), Joi.number().required()] }, { id } @@ -171,8 +169,7 @@ export class UserController { @Get(":id/emails") async getEmails(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); joiValidate( { id: [Joi.string().required(), Joi.number().required()] }, { id } @@ -182,8 +179,7 @@ export class UserController { @Put(":id/emails") async putEmails(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); const email = req.body.email; joiValidate( { @@ -200,8 +196,7 @@ export class UserController { @Get(":id/emails/:emailId") async getEmail(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); const emailId = req.params.emailId; joiValidate( { @@ -215,8 +210,7 @@ export class UserController { @Post(":id/emails/:emailId/resend") async postResend(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); const emailId = req.params.emailId; joiValidate( { @@ -231,8 +225,7 @@ export class UserController { @Delete(":id/emails/:emailId") async deleteEmail(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); const emailId = req.params.emailId; joiValidate( { @@ -252,8 +245,7 @@ export class UserController { @Get(":id/notifications") async getUserNotifications(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); joiValidate( { id: [Joi.string().required(), Joi.number().required()] }, { id } @@ -263,8 +255,7 @@ export class UserController { @Patch(":id/notifications/:notificationId") async updateUserNotification(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); const notificationId = req.params.notificationId; joiValidate( { @@ -285,8 +276,7 @@ export class UserController { @Get(":id/2fa/enable") async getEnable2FA(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); joiValidate( { id: [Joi.string().required(), Joi.number().required()] }, { id } @@ -296,8 +286,7 @@ export class UserController { @Post(":id/2fa/verify") async postVerify2FA(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); const code = req.body.code; joiValidate( { @@ -313,8 +302,7 @@ export class UserController { @Delete(":id/2fa") async delete2FA(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); joiValidate( { id: [Joi.string().required(), Joi.number().required()] }, { id } @@ -324,8 +312,7 @@ export class UserController { @Get(":id/backup-codes") async getBackupCodes(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); joiValidate( { id: [Joi.string().required(), Joi.number().required()] }, { id } @@ -335,12 +322,133 @@ export class UserController { @Get(":id/backup-codes/regenerate") async getRegenerateBackupCodes(req: Request, res: Response) { - let id = req.params.id; - if (id === "me") id = res.locals.token.id; + const id = await userUsernameToId(req.params.id, res.locals.token.id); joiValidate( { id: [Joi.string().required(), Joi.number().required()] }, { id } ); res.json(await regenerateBackupCodesForUser(res.locals.token.id, id)); } + + @Get(":id/access-tokens") + async getUserAccessTokens(req: Request, res: Response) { + const id = await userUsernameToId(req.params.id, res.locals.token.id); + joiValidate( + { id: [Joi.string().required(), Joi.number().required()] }, + { id } + ); + const accessTokenParams = { ...req.query }; + joiValidate( + { + start: Joi.string(), + itemsPerPage: Joi.number() + }, + accessTokenParams + ); + res.json( + await getUserAccessTokensForUser( + res.locals.token.id, + id, + accessTokenParams + ) + ); + } + + @Put(":id/access-tokens") + @Middleware( + validator( + { + scopes: Joi.string(), + name: Joi.string(), + description: Joi.string() + }, + "body" + ) + ) + async putUserAccessTokens(req: Request, res: Response) { + const id = await userUsernameToId(req.params.id, res.locals.token.id); + joiValidate( + { id: [Joi.string().required(), Joi.number().required()] }, + { id } + ); + res + .status(CREATED) + .json( + await createAccessTokenForUser( + res.locals.token.id, + id, + req.body, + res.locals + ) + ); + } + + @Get(":id/access-tokens/:accessTokenId") + async getUserAccessToken(req: Request, res: Response) { + const id = await userUsernameToId(req.params.id, res.locals.token.id); + const accessTokenId = req.params.accessTokenId; + joiValidate( + { + id: [Joi.string().required(), Joi.number().required()], + accessTokenId: Joi.number().required() + }, + { id, accessTokenId } + ); + res.json( + await getUserAccessTokenForUser(res.locals.token.id, id, accessTokenId) + ); + } + + @Patch(":id/access-tokens/:accessTokenId") + @Middleware( + validator( + { + scopes: Joi.string().allow(""), + name: Joi.string().allow(""), + description: Joi.string().allow("") + }, + "body" + ) + ) + async patchUserAccessToken(req: Request, res: Response) { + const id = await userUsernameToId(req.params.id, res.locals.token.id); + const accessTokenId = req.params.accessTokenId; + joiValidate( + { + id: [Joi.string().required(), Joi.number().required()], + accessTokenId: Joi.number().required() + }, + { id, accessTokenId } + ); + res.json( + await updateAccessTokenForUser( + res.locals.token.id, + id, + accessTokenId, + req.body, + res.locals + ) + ); + } + + @Delete(":id/access-tokens/:accessTokenId") + async deleteUserAccessToken(req: Request, res: Response) { + const id = await userUsernameToId(req.params.id, res.locals.token.id); + const accessTokenId = req.params.accessTokenId; + joiValidate( + { + id: [Joi.string().required(), Joi.number().required()], + accessTokenId: Joi.number().required() + }, + { id, accessTokenId } + ); + res.json( + await deleteAccessTokenForUser( + res.locals.token.id, + id, + accessTokenId, + res.locals + ) + ); + } } diff --git a/src/crud/organization.ts b/src/crud/organization.ts index 7271e57a1..8a580ec2b 100644 --- a/src/crud/organization.ts +++ b/src/crud/organization.ts @@ -75,11 +75,19 @@ export const updateOrganization = async ( organization.updatedAt = new Date(); organization = removeReadOnlyValues(organization); const originalOrganization = await getOrganization(id); - if (organization.username && originalOrganization.username) { + if ( + organization.username && + originalOrganization.username && + organization.username !== originalOrganization.username + ) { const currentOwner = await getOrganizationIdFromUsername( originalOrganization.username ); if (currentOwner != id) throw new Error(ErrorCode.USERNAME_EXISTS); + deleteItemFromCache( + CacheCategories.ORGANIZATION_USERNAME, + originalOrganization.username + ); } deleteItemFromCache(CacheCategories.ORGANIZATION, id); return await query( @@ -130,9 +138,7 @@ export const getOrganizationApiKeys = async ( */ export const getApiKey = async (organizationId: number, apiKeyId: number) => { return (( - await cachedQuery( - CacheCategories.API_KEY_ORG, - `${organizationId}_${apiKeyId}`, + await query( `SELECT * FROM ${tableName( "api-keys" )} WHERE id = ? AND organizationId = ? LIMIT 1`, @@ -168,11 +174,6 @@ export const updateApiKey = async ( const apiKey = await getApiKey(organizationId, apiKeyId); if (apiKey.jwtApiKey) await invalidateToken(apiKey.jwtApiKey); data.jwtApiKey = await apiKeyToken({ ...apiKey, ...data }); - deleteItemFromCache(CacheCategories.API_KEY, apiKeyId); - deleteItemFromCache( - CacheCategories.API_KEY_ORG, - `${organizationId}_${apiKeyId}` - ); return await query( `UPDATE ${tableName("api-keys")} SET ${setValues( data @@ -188,11 +189,6 @@ export const deleteApiKey = async ( organizationId: number, apiKeyId: number ) => { - deleteItemFromCache(CacheCategories.API_KEY, apiKeyId); - deleteItemFromCache( - CacheCategories.API_KEY_ORG, - `${organizationId}_${apiKeyId}` - ); const currentApiKey = await getApiKey(organizationId, apiKeyId); if (currentApiKey.jwtApiKey) await invalidateToken(currentApiKey.jwtApiKey); return await query( diff --git a/src/crud/user.ts b/src/crud/user.ts index 357259466..da731368c 100644 --- a/src/crud/user.ts +++ b/src/crud/user.ts @@ -5,7 +5,12 @@ import { removeReadOnlyValues, tableName } from "../helpers/mysql"; -import { User, ApprovedLocation, BackupCode } from "../interfaces/tables/user"; +import { + User, + ApprovedLocation, + BackupCode, + AccessToken +} from "../interfaces/tables/user"; import { capitalizeFirstAndLastLetter, deleteSensitiveInfoUser, @@ -22,6 +27,9 @@ import { getEmail, getVerifiedEmailObject } from "./email"; import { cachedQuery, deleteItemFromCache } from "../helpers/cache"; import md5 from "md5"; import randomInt from "random-int"; +import { getPaginatedData } from "./data"; +import { accessToken, invalidateToken } from "../helpers/jwt"; +import { TOKEN_EXPIRY_API_KEY_MAX } from "../config"; /** * Get a list of all ${tableName("users")} @@ -86,6 +94,22 @@ export const getUserByEmail = async (email: string, secureOrigin = false) => { return await getUser(emailObject.userId, secureOrigin); }; +/* + * Get user ID from a username + */ +export const getUserIdFromUsername = async (username: string) => { + const user = (( + await cachedQuery( + CacheCategories.USER_USERNAME, + username, + `SELECT id FROM ${tableName("users")} WHERE username = ? LIMIT 1`, + [username] + ) + ))[0]; + if (user && user.id) return user.id; + throw new Error(ErrorCode.USER_NOT_FOUND); +}; + /** * Update a user's details */ @@ -118,6 +142,8 @@ export const updateUser = async (id: number, user: KeyValue) => { usernameOwner.id != originalUser.id ) throw new Error(ErrorCode.USERNAME_EXISTS); + if (originalUser.username && user.username !== originalUser.username) + deleteItemFromCache(CacheCategories.USER_USERNAME, originalUser.username); } deleteItemFromCache(CacheCategories.USER, id); return await query( @@ -278,3 +304,84 @@ export const getUserBackupCode = async (userId: number, backupCode: number) => { ) ))[0]; }; +/** + * Get a list of all approved locations of a user + */ +export const getUserAccessTokens = async (userId: number, query: KeyValue) => { + return await getPaginatedData({ + table: "access-tokens", + conditions: { + userId + }, + ...query + }); +}; + +/** + * Get an API key + */ +export const getAccessToken = async (userId: number, accessTokenId: number) => { + return (( + await query( + `SELECT * FROM ${tableName( + "access-tokens" + )} WHERE id = ? AND userId = ? LIMIT 1`, + [accessTokenId, userId] + ) + ))[0]; +}; + +/** + * Create an API key + */ +export const createAccessToken = async (newAccessToken: AccessToken) => { + newAccessToken.expiresAt = + newAccessToken.expiresAt || new Date(TOKEN_EXPIRY_API_KEY_MAX); + newAccessToken.createdAt = new Date(); + newAccessToken.updatedAt = newAccessToken.createdAt; + newAccessToken.jwtAccessToken = await accessToken(newAccessToken); + return await query( + `INSERT INTO ${tableName("access-tokens")} ${tableValues(newAccessToken)}`, + Object.values(newAccessToken) + ); +}; + +/** + * Update a user's details + */ +export const updateAccessToken = async ( + userId: number, + accessTokenId: number, + data: KeyValue +) => { + data.updatedAt = new Date(); + data = removeReadOnlyValues(data); + const newAccessToken = await getAccessToken(userId, accessTokenId); + if (newAccessToken.jwtAccessToken) + await invalidateToken(newAccessToken.jwtAccessToken); + data.jwtAccessToken = await accessToken({ ...newAccessToken, ...data }); + return await query( + `UPDATE ${tableName("access-tokens")} SET ${setValues( + data + )} WHERE id = ? AND userId = ?`, + [...Object.values(data), accessTokenId, userId] + ); +}; + +/** + * Delete an API key + */ +export const deleteAccessToken = async ( + userId: number, + accessTokenId: number +) => { + const currentAccessToken = await getAccessToken(userId, accessTokenId); + if (currentAccessToken.jwtAccessToken) + await invalidateToken(currentAccessToken.jwtAccessToken); + return await query( + `DELETE FROM ${tableName( + "access-tokens" + )} WHERE id = ? AND userId = ? LIMIT 1`, + [accessTokenId, userId] + ); +}; diff --git a/src/helpers/jwt.ts b/src/helpers/jwt.ts index f006cff00..54f09a5b6 100644 --- a/src/helpers/jwt.ts +++ b/src/helpers/jwt.ts @@ -10,7 +10,7 @@ import { TOKEN_EXPIRY_API_KEY_MAX, REDIS_URL } from "../config"; -import { User } from "../interfaces/tables/user"; +import { User, AccessToken } from "../interfaces/tables/user"; import { Tokens, ErrorCode, EventType, Templates } from "../interfaces/enum"; import { deleteSensitiveInfoUser, @@ -129,6 +129,25 @@ export const apiKeyToken = (apiKey: ApiKey) => { Tokens.API_KEY ); }; +/** + * Generate an access token + */ +export const accessToken = (accessToken: AccessToken) => { + const createAccessToken = { ...removeFalsyValues(accessToken) }; + delete createAccessToken.createdAt; + delete createAccessToken.jwtAccessToken; + delete createAccessToken.updatedAt; + delete createAccessToken.name; + delete createAccessToken.description; + delete createAccessToken.expiresAt; + return generateToken( + createAccessToken, + (accessToken.expiresAt + ? accessToken.expiresAt.getTime() + : TOKEN_EXPIRY_API_KEY_MAX) - new Date().getTime(), + Tokens.API_KEY + ); +}; /** * Generate a new approve location JWT diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index 739f09446..649c56291 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -9,6 +9,7 @@ import cryptoRandomString from "crypto-random-string"; import { Tokens } from "../interfaces/enum"; import { ApiKeyResponse } from "./jwt"; import { isMatch } from "matcher"; +import { getUserIdFromUsername } from "../crud/user"; /** * Capitalize each first letter in a string @@ -68,6 +69,16 @@ export const organizationUsernameToId = async (id: string) => { } }; +export const userUsernameToId = async (id: string, tokenUserId: number) => { + if (id === "me") { + return tokenUserId; + } else if (isNaN(Number(id))) { + return await getUserIdFromUsername(id); + } else { + return parseInt(id); + } +}; + export const localsToTokenOrKey = (res: Response) => { if (res.locals.token.sub == Tokens.API_KEY) { return res.locals.token as ApiKeyResponse; diff --git a/src/interfaces/enum.ts b/src/interfaces/enum.ts index 4ea63a423..5904291d9 100644 --- a/src/interfaces/enum.ts +++ b/src/interfaces/enum.ts @@ -114,10 +114,9 @@ export enum CacheCategories { USER_MEMBERSHIPS = "user-memberships", MEMBERSHIP = "membership", ORGANIZATION = "organization", + USER_USERNAME = "user-username", ORGANIZATION_USERNAME = "organization-username", - IP_LOOKUP = "ip-lookup", - API_KEY = "api-key", - API_KEY_ORG = "api-key-org" + IP_LOOKUP = "ip-lookup" } export enum Authorizations { diff --git a/src/interfaces/tables/user.ts b/src/interfaces/tables/user.ts index 204281978..c83f8d4c3 100644 --- a/src/interfaces/tables/user.ts +++ b/src/interfaces/tables/user.ts @@ -4,9 +4,9 @@ import { Genders, NotificationCategories } from "../enum"; +import { IdRow, Row } from "../general"; -export interface User { - id?: number; +export interface User extends IdRow { name: string; username?: string; nickname?: string; @@ -23,8 +23,6 @@ export interface User { gender?: Genders; role?: UserRole; profilePicture?: string; - createdAt?: Date; - updatedAt?: Date; // email is only used for JWT email?: string; @@ -37,21 +35,25 @@ export interface ApprovedLocation { createdAt?: Date; } -export interface Notification { - id?: number; +export interface Notification extends IdRow { userId: number; category: NotificationCategories; text: string; link: string; read?: boolean; - createdAt?: Date; - updatedAt?: Date; } -export interface BackupCode { +export interface BackupCode extends Row { code: number; userId: number; used?: boolean; - createdAt?: Date; - updatedAt?: Date; +} + +export interface AccessToken extends IdRow { + name?: string; + description?: string; + jwtAccessToken?: string; + scopes?: string; + userId: number; + expiresAt?: Date; } diff --git a/src/rest/user.ts b/src/rest/user.ts index b6e3703f2..6dbc55a91 100644 --- a/src/rest/user.ts +++ b/src/rest/user.ts @@ -7,7 +7,12 @@ import { deleteAllUserApprovedLocations, createBackupCodes, deleteUserBackupCodes, - getUserBackupCodes + getUserBackupCodes, + getUserAccessTokens, + getAccessToken, + updateAccessToken, + createAccessToken, + deleteAccessToken } from "../crud/user"; import { deleteAllUserMemberships, @@ -229,3 +234,62 @@ export const regenerateBackupCodesForUser = async ( await createBackupCodes(userId, 10); return await getUserBackupCodes(userId); }; +export const getUserAccessTokensForUser = async ( + tokenUserId: number, + userId: number, + query: KeyValue +) => { + if (await can(tokenUserId, Authorizations.READ_SECURE, "user", userId)) + return await getUserAccessTokens(userId, query); + throw new Error(ErrorCode.INSUFFICIENT_PERMISSION); +}; + +export const getUserAccessTokenForUser = async ( + tokenUserId: number, + userId: number, + accessTokenId: number +) => { + if (await can(tokenUserId, Authorizations.READ_SECURE, "user", userId)) + return await getAccessToken(userId, accessTokenId); + throw new Error(ErrorCode.INSUFFICIENT_PERMISSION); +}; + +export const updateAccessTokenForUser = async ( + tokenUserId: number, + userId: number, + accessTokenId: number, + data: KeyValue, + locals: Locals +) => { + if (await can(tokenUserId, Authorizations.UPDATE_SECURE, "user", userId)) { + await updateAccessToken(userId, accessTokenId, data); + return; + } + throw new Error(ErrorCode.INSUFFICIENT_PERMISSION); +}; + +export const createAccessTokenForUser = async ( + tokenUserId: number, + userId: number, + accessToken: KeyValue, + locals: Locals +) => { + if (await can(tokenUserId, Authorizations.CREATE_SECURE, "user", userId)) { + const key = await createAccessToken({ userId, ...accessToken }); + return; + } + throw new Error(ErrorCode.INSUFFICIENT_PERMISSION); +}; + +export const deleteAccessTokenForUser = async ( + tokenUserId: number, + userId: number, + accessTokenId: number, + locals: Locals +) => { + if (await can(tokenUserId, Authorizations.DELETE_SECURE, "user", userId)) { + await deleteAccessToken(userId, accessTokenId); + return; + } + throw new Error(ErrorCode.INSUFFICIENT_PERMISSION); +};