From 7fa5e70d5b5e4e6cded08f343f5db0f42e8be324 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sun, 4 Aug 2024 21:44:51 +0200 Subject: [PATCH] feat: reset password cli (#903) * feat: add password reset cli * feat: add homarr cli to docker image --- Dockerfile | 18 +++++++- package.json | 2 + .../router/test/docker/docker-router.spec.ts | 1 + packages/auth/session.ts | 4 +- packages/auth/test/session.spec.ts | 7 ++- packages/cli/eslint.config.js | 9 ++++ packages/cli/index.ts | 1 + packages/cli/package.json | 39 ++++++++++++++++ packages/cli/src/commands/reset-password.ts | 46 +++++++++++++++++++ packages/cli/src/index.ts | 10 ++++ packages/cli/tsconfig.json | 8 ++++ packages/common/package.json | 1 + packages/common/src/security.ts | 10 ++++ packages/common/src/server.ts | 1 + pnpm-lock.yaml | 39 ++++++++++++++++ turbo/generators/templates/package.json.hbs | 4 +- 16 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 packages/cli/eslint.config.js create mode 100644 packages/cli/index.ts create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/commands/reset-password.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/common/src/security.ts create mode 100644 packages/common/src/server.ts diff --git a/Dockerfile b/Dockerfile index 845d978c0..4cb3283da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ RUN turbo prune @homarr/nextjs --docker --out-dir ./next-out RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out RUN turbo prune @homarr/db --docker --out-dir ./migration-out +RUN turbo prune @homarr/cli --docker --out-dir ./cli-out # Add lockfile and package.json's of isolated subworkspace FROM base AS installer @@ -34,6 +35,10 @@ COPY --from=builder /app/migration-out/json/ . COPY --from=builder /app/migration-out/pnpm-lock.yaml ./pnpm-lock.yaml RUN corepack enable pnpm && pnpm install +COPY --from=builder /app/cli-out/json/ . +COPY --from=builder /app/cli-out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN corepack enable pnpm && pnpm install + COPY --from=builder /app/next-out/json/ . COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml RUN corepack enable pnpm && pnpm install @@ -45,6 +50,7 @@ COPY --from=builder /app/tasks-out/full/ . COPY --from=builder /app/websocket-out/full/ . COPY --from=builder /app/next-out/full/ . COPY --from=builder /app/migration-out/full/ . +COPY --from=builder /app/cli-out/full/ . # Copy static data as it is not part of the build COPY static-data ./static-data @@ -55,15 +61,23 @@ RUN corepack enable pnpm && pnpm build FROM base AS runner WORKDIR /app -RUN apk add --no-cache redis +RUN apk add --no-cache redis bash RUN mkdir /appdata RUN mkdir /appdata/db RUN mkdir /appdata/redis VOLUME /appdata -# Don't run production as root + + RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs + +# Enable homarr cli +COPY --from=installer --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs +RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr +RUN chmod +x /usr/bin/homarr + +# Don't run production as root RUN chown -R nextjs:nodejs /appdata USER nextjs diff --git a/package.json b/package.json index 5e001f92d..a966aafae 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "db:migration:mysql:generate": "pnpm -F db migration:mysql:generate", "db:migration:sqlite:run": "pnpm -F db migration:sqlite:run", "db:migration:mysql:run": "pnpm -F db migration:mysql:run", + "cli": "pnpm with-env tsx packages/cli/index.ts", + "with-env": "dotenv -e .env --", "dev": "turbo dev --parallel", "docker:dev": "docker compose -f ./development/development.docker-compose.yml up", "format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache", diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts index a166b46a2..c92a0bbfc 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -43,6 +43,7 @@ const validInputs: { stopAll: { ids: ["1"] }, restartAll: { ids: ["1"] }, removeAll: { ids: ["1"] }, + invalidate: undefined, }; describe("All procedures should only be accessible for users with admin permission", () => { diff --git a/packages/auth/session.ts b/packages/auth/session.ts index 02ef573df..249c3b1e1 100644 --- a/packages/auth/session.ts +++ b/packages/auth/session.ts @@ -1,6 +1,6 @@ -import { randomUUID } from "crypto"; import type { Session } from "next-auth"; +import { generateSecureRandomToken } from "@homarr/common/server"; import type { Database } from "@homarr/db"; import { getCurrentUserPermissionsAsync } from "./callbacks"; @@ -13,7 +13,7 @@ export const expireDateAfter = (seconds: number) => { }; export const generateSessionToken = () => { - return randomUUID(); + return generateSecureRandomToken(48); }; export const getSessionFromTokenAsync = async (db: Database, token: string | undefined): Promise => { diff --git a/packages/auth/test/session.spec.ts b/packages/auth/test/session.spec.ts index 27adfef2a..4f90f44a0 100644 --- a/packages/auth/test/session.spec.ts +++ b/packages/auth/test/session.spec.ts @@ -30,7 +30,12 @@ describe("expireDateAfter should calculate date after specified seconds", () => describe("generateSessionToken should return a random UUID", () => { it("should return a random UUID", () => { const result = generateSessionToken(); - expect(z.string().uuid().safeParse(result).success).toBe(true); + expect( + z + .string() + .regex(/^[a-f0-9]+$/) + .safeParse(result).success, + ).toBe(true); }); it("should return a different token each time", () => { const result1 = generateSessionToken(); diff --git a/packages/cli/eslint.config.js b/packages/cli/eslint.config.js new file mode 100644 index 000000000..5b19b6f8a --- /dev/null +++ b/packages/cli/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@homarr/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [], + }, + ...baseConfig, +]; diff --git a/packages/cli/index.ts b/packages/cli/index.ts new file mode 100644 index 000000000..3bd16e178 --- /dev/null +++ b/packages/cli/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 000000000..62706c30a --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,39 @@ +{ + "name": "@homarr/cli", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./index.ts" + }, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "lint": "eslint", + "build": "esbuild src/index.ts --bundle --platform=node --outfile=cli.cjs --external:bcrypt --external:cpu-features --loader:.html=text --loader:.node=text", + "format": "prettier --check . --ignore-path ../../.gitignore", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@drizzle-team/brocli": "^0.9.2", + "@homarr/db": "workspace:^0.1.0", + "@homarr/common": "workspace:^0.1.0", + "@homarr/auth": "workspace:^0.1.0", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@homarr/eslint-config": "workspace:^0.2.0", + "@homarr/prettier-config": "workspace:^0.1.0", + "@homarr/tsconfig": "workspace:^0.1.0", + "eslint": "^9.8.0", + "typescript": "^5.5.4" + }, + "prettier": "@homarr/prettier-config" +} diff --git a/packages/cli/src/commands/reset-password.ts b/packages/cli/src/commands/reset-password.ts new file mode 100644 index 000000000..a2a31cac0 --- /dev/null +++ b/packages/cli/src/commands/reset-password.ts @@ -0,0 +1,46 @@ +import { command, string } from "@drizzle-team/brocli"; + +import { hashPasswordAsync } from "@homarr/auth"; +import { generateSecureRandomToken } from "@homarr/common/server"; +import { and, db, eq } from "@homarr/db"; +import { sessions, users } from "@homarr/db/schema/sqlite"; + +export const resetPassword = command({ + name: "reset-password", + desc: "Reset password for a user", + options: { + username: string("username").required().alias("u").desc("Name of the user"), + }, + // eslint-disable-next-line no-restricted-syntax + handler: async (options) => { + if (!process.env.AUTH_PROVIDERS?.toLowerCase().includes("credentials")) { + console.error("Credentials provider is not enabled"); + return; + } + + const user = await db.query.users.findFirst({ + where: and(eq(users.name, options.username), eq(users.provider, "credentials")), + }); + + if (!user?.salt) { + console.error(`User ${options.username} not found`); + return; + } + + // Generates a new password with 48 characters + const newPassword = generateSecureRandomToken(24); + + await db + .update(users) + .set({ + password: await hashPasswordAsync(newPassword, user.salt), + }) + .where(eq(users.id, user.id)); + + await db.delete(sessions).where(eq(sessions.userId, user.id)); + console.log(`All sessions for user ${options.username} have been deleted`); + + console.log("You can now login with the new password"); + console.log(`New password for user ${options.username}: ${newPassword}`); + }, +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 000000000..cdda88527 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,10 @@ +import { run } from "@drizzle-team/brocli"; + +import { resetPassword } from "./commands/reset-password"; + +const commands = [resetPassword]; + +void run(commands, { + cliName: "homarr-cli", + version: "1.0.0", +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 000000000..cbe8483d9 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@homarr/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/common/package.json b/packages/common/package.json index 04e20d694..100d1bea9 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./index.ts", + "./server": "./src/server.ts", "./types": "./src/types.ts" }, "typesVersions": { diff --git a/packages/common/src/security.ts b/packages/common/src/security.ts new file mode 100644 index 000000000..b3a0ee758 --- /dev/null +++ b/packages/common/src/security.ts @@ -0,0 +1,10 @@ +import { randomBytes } from "crypto"; + +/** + * Generates a random hex token twice the size of the given size + * @param size amount of bytes to generate + * @returns a random hex token twice the length of the given size + */ +export const generateSecureRandomToken = (size: number) => { + return randomBytes(size).toString("hex"); +}; diff --git a/packages/common/src/server.ts b/packages/common/src/server.ts new file mode 100644 index 000000000..549972b93 --- /dev/null +++ b/packages/common/src/server.ts @@ -0,0 +1 @@ +export * from "./security"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32348d921..d90c0f1ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -591,6 +591,40 @@ importers: specifier: ^5.5.4 version: 5.5.4 + packages/cli: + dependencies: + '@drizzle-team/brocli': + specifier: ^0.9.2 + version: 0.9.2 + '@homarr/auth': + specifier: workspace:^0.1.0 + version: link:../auth + '@homarr/common': + specifier: workspace:^0.1.0 + version: link:../common + '@homarr/db': + specifier: workspace:^0.1.0 + version: link:../db + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + devDependencies: + '@homarr/eslint-config': + specifier: workspace:^0.2.0 + version: link:../../tooling/eslint + '@homarr/prettier-config': + specifier: workspace:^0.1.0 + version: link:../../tooling/prettier + '@homarr/tsconfig': + specifier: workspace:^0.1.0 + version: link:../../tooling/typescript + eslint: + specifier: ^9.8.0 + version: 9.8.0 + typescript: + specifier: ^5.5.4 + version: 5.5.4 + packages/common: dependencies: dayjs: @@ -1608,6 +1642,9 @@ packages: '@drizzle-team/brocli@0.8.2': resolution: {integrity: sha512-zTrFENsqGvOkBOuHDC1pXCkDXNd2UhP4lI3gYGhQ1R1SPeAAfqzPsV1dcpMy4uNU6kB5VpU5NGhvwxVNETR02A==} + '@drizzle-team/brocli@0.9.2': + resolution: {integrity: sha512-yOrIWbgYMTcXuG+4sTn5OX1UtNVmtHqIqlTyp7OpYOQgIIOjLUdiG8GBVmMOwV6qelcojDB/9MHOL0POIRLMug==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} @@ -6885,6 +6922,8 @@ snapshots: '@drizzle-team/brocli@0.8.2': {} + '@drizzle-team/brocli@0.9.2': {} + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 diff --git a/turbo/generators/templates/package.json.hbs b/turbo/generators/templates/package.json.hbs index 0b52369f4..cd72632c4 100644 --- a/turbo/generators/templates/package.json.hbs +++ b/turbo/generators/templates/package.json.hbs @@ -24,8 +24,8 @@ "@homarr/eslint-config": "workspace:^0.2.0", "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", - "eslint": "^9.5.0", - "typescript": "^5.5.2" + "eslint": "^9.8.0", + "typescript": "^5.5.4" }, "prettier": "@homarr/prettier-config" } \ No newline at end of file