diff --git a/README.md b/README.md index b77f57ec..a8eda7ac 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Zerobyte is a backup automation tool that helps you save your data across multip -   **Flexible scheduling** For automated backup jobs with fine-grained retention policies -   **End-to-end encryption** ensuring your data is always protected -   **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories +-   **Two-factor authentication** (TOTP) for enhanced account security ## Installation @@ -70,6 +71,16 @@ docker compose up -d Once the container is running, you can access the web interface at `http://:4096`. +### Two-Factor Authentication + +Zerobyte supports TOTP-based two-factor authentication for enhanced account security. You can enable 2FA from the Settings page in the web interface using any authenticator app (Google Authenticator, Authy, 1Password, etc.). + +**Recovery:** If you lose access to your authenticator app, you can disable 2FA via the CLI: + +```bash +docker exec -it zerobyte bun run cli 2fa disable -u +``` + ### Simplified setup (No remote mounts) If you only need to back up locally mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`: diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index cb1ffead..feb34cf2 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, disable2Fa, doctorRepository, downloadResticPassword, enable2Fa, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getTwoFactorStatus, getVolume, healthCheckVolume, changePassword, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setup2Fa, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume, verify2Fa } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, Disable2FaData, Disable2FaResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, Enable2FaData, Enable2FaResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetTwoFactorStatusData, GetTwoFactorStatusResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ChangePasswordData, ChangePasswordResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, Setup2FaData, Setup2FaResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse, Verify2FaData, Verify2FaResponse } from '../types.gen'; /** * Register a new user @@ -40,6 +40,23 @@ export const loginMutation = (options?: Partial>): UseMutatio return mutationOptions; }; +/** + * Verify 2FA code and complete login + */ +export const verify2FaMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await verify2Fa({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + /** * Logout current user */ @@ -143,6 +160,75 @@ export const changePasswordMutation = (options?: Partial) => createQueryKey('getTwoFactorStatus', options); + +/** + * Get 2FA status for current authenticated user + */ +export const getTwoFactorStatusOptions = (options?: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getTwoFactorStatus({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getTwoFactorStatusQueryKey(options) +}); + +/** + * Generate 2FA setup data (secret and otpauth URI for QR code) + */ +export const setup2FaMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await setup2Fa({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +/** + * Verify 2FA code and enable 2FA for the account + */ +export const enable2FaMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await enable2Fa({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +/** + * Disable 2FA for the account (requires password and current TOTP code) + */ +export const disable2FaMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await disable2Fa({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const listVolumesQueryKey = (options?: Options) => createQueryKey('listVolumes', options); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 6c1978ce..33635680 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, Disable2FaData, Disable2FaErrors, Disable2FaResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, Enable2FaData, Enable2FaErrors, Enable2FaResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetTwoFactorStatusData, GetTwoFactorStatusErrors, GetTwoFactorStatusResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ChangePasswordData, ChangePasswordResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, Setup2FaData, Setup2FaErrors, Setup2FaResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses, Verify2FaData, Verify2FaErrors, Verify2FaResponses } from './types.gen'; export type Options = Options2 & { /** @@ -42,6 +42,18 @@ export const login = (options?: Options(options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/auth/verify-2fa', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + /** * Logout current user */ @@ -69,6 +81,40 @@ export const changePassword = (options?: O } }); +/** + * Get 2FA status for current authenticated user + */ +export const getTwoFactorStatus = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/2fa-status', ...options }); + +/** + * Generate 2FA setup data (secret and otpauth URI for QR code) + */ +export const setup2Fa = (options?: Options) => (options?.client ?? client).post({ url: '/api/v1/auth/2fa/setup', ...options }); + +/** + * Verify 2FA code and enable 2FA for the account + */ +export const enable2Fa = (options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/auth/2fa/enable', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Disable 2FA for the account (requires password and current TOTP code) + */ +export const disable2Fa = (options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/auth/2fa/disable', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + /** * List all volumes */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 3abd1800..11044047 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -21,6 +21,8 @@ export type RegisterResponses = { 201: { message: string; success: boolean; + pendingSessionId?: string; + requiresTwoFactor?: boolean; user?: { hasDownloadedResticPassword: boolean; id: number; @@ -48,6 +50,8 @@ export type LoginResponses = { 200: { message: string; success: boolean; + pendingSessionId?: string; + requiresTwoFactor?: boolean; user?: { hasDownloadedResticPassword: boolean; id: number; @@ -58,6 +62,40 @@ export type LoginResponses = { export type LoginResponse = LoginResponses[keyof LoginResponses]; +export type Verify2FaData = { + body?: { + code: string; + pendingSessionId: string; + }; + path?: never; + query?: never; + url: '/api/v1/auth/verify-2fa'; +}; + +export type Verify2FaErrors = { + /** + * Invalid 2FA code or session + */ + 401: unknown; +}; + +export type Verify2FaResponses = { + /** + * 2FA verification successful + */ + 200: { + message: string; + success: boolean; + user?: { + hasDownloadedResticPassword: boolean; + id: number; + username: string; + }; + }; +}; + +export type Verify2FaResponse = Verify2FaResponses[keyof Verify2FaResponses]; + export type LogoutData = { body?: never; path?: never; @@ -90,6 +128,8 @@ export type GetMeResponses = { 200: { message: string; success: boolean; + pendingSessionId?: string; + requiresTwoFactor?: boolean; user?: { hasDownloadedResticPassword: boolean; id: number; @@ -140,6 +180,130 @@ export type ChangePasswordResponses = { export type ChangePasswordResponse = ChangePasswordResponses[keyof ChangePasswordResponses]; +export type GetTwoFactorStatusData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/auth/2fa-status'; +}; + +export type GetTwoFactorStatusErrors = { + /** + * Not authenticated + */ + 401: unknown; +}; + +export type GetTwoFactorStatusResponses = { + /** + * 2FA status + */ + 200: { + enabled: boolean; + }; +}; + +export type GetTwoFactorStatusResponse = GetTwoFactorStatusResponses[keyof GetTwoFactorStatusResponses]; + +export type Setup2FaData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/auth/2fa/setup'; +}; + +export type Setup2FaErrors = { + /** + * 2FA already enabled or other error + */ + 400: unknown; + /** + * Not authenticated + */ + 401: unknown; +}; + +export type Setup2FaResponses = { + /** + * 2FA setup data + */ + 200: { + message: string; + success: boolean; + secret?: string; + uri?: string; + }; +}; + +export type Setup2FaResponse = Setup2FaResponses[keyof Setup2FaResponses]; + +export type Enable2FaData = { + body?: { + code: string; + password: string; + secret: string; + }; + path?: never; + query?: never; + url: '/api/v1/auth/2fa/enable'; +}; + +export type Enable2FaErrors = { + /** + * Invalid code or 2FA already enabled + */ + 400: unknown; + /** + * Not authenticated + */ + 401: unknown; +}; + +export type Enable2FaResponses = { + /** + * 2FA enabled successfully + */ + 200: { + message: string; + success: boolean; + }; +}; + +export type Enable2FaResponse = Enable2FaResponses[keyof Enable2FaResponses]; + +export type Disable2FaData = { + body?: { + code: string; + password: string; + }; + path?: never; + query?: never; + url: '/api/v1/auth/2fa/disable'; +}; + +export type Disable2FaErrors = { + /** + * Invalid password or code, or 2FA not enabled + */ + 400: unknown; + /** + * Not authenticated + */ + 401: unknown; +}; + +export type Disable2FaResponses = { + /** + * 2FA disabled successfully + */ + 200: { + message: string; + success: boolean; + }; +}; + +export type Disable2FaResponse = Disable2FaResponses[keyof Disable2FaResponses]; + export type ListVolumesData = { body?: never; path?: never; @@ -2164,6 +2328,7 @@ export type GetScheduleNotificationsResponses = { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2263,6 +2428,7 @@ export type UpdateScheduleNotificationsResponses = { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2599,6 +2765,7 @@ export type ListNotificationDestinationsResponses = { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2669,6 +2836,7 @@ export type CreateNotificationDestinationData = { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2738,6 +2906,7 @@ export type CreateNotificationDestinationResponses = { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2854,6 +3023,7 @@ export type GetNotificationDestinationResponses = { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2924,6 +3094,7 @@ export type UpdateNotificationDestinationData = { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -3003,6 +3174,7 @@ export type UpdateNotificationDestinationResponses = { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; diff --git a/app/client/components/ui/badge.tsx b/app/client/components/ui/badge.tsx index e98a53b9..604d394c 100644 --- a/app/client/components/ui/badge.tsx +++ b/app/client/components/ui/badge.tsx @@ -14,6 +14,8 @@ const badgeVariants = cva( destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + success: + "border-transparent bg-green-500 text-white [a&]:hover:bg-green-600 focus-visible:ring-green-500/20 dark:focus-visible:ring-green-500/40 dark:bg-green-600", }, }, defaultVariants: { diff --git a/app/client/modules/auth/components/reset-2fa-dialog.tsx b/app/client/modules/auth/components/reset-2fa-dialog.tsx new file mode 100644 index 00000000..28456822 --- /dev/null +++ b/app/client/modules/auth/components/reset-2fa-dialog.tsx @@ -0,0 +1,42 @@ +import { toast } from "sonner"; +import { Button } from "~/client/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog"; +import { copyToClipboard } from "~/utils/clipboard"; + +const RESET_2FA_COMMAND = "docker exec -it zerobyte bun run cli 2fa disable -u "; + +type Reset2faDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export const Reset2faDialog = ({ open, onOpenChange }: Reset2faDialogProps) => { + const handleCopy = async () => { + await copyToClipboard(RESET_2FA_COMMAND); + toast.success("Command copied to clipboard"); + }; + + return ( + + + + Reset Two-Factor Authentication + + Lost access to your authenticator app? Run the following command on the server where Zerobyte is installed + to disable 2FA for your account. + + +
+
{RESET_2FA_COMMAND}
+

+ Replace <username> with your actual username. After + running this command, you'll be able to log in without 2FA and can set it up again from settings. +

+ +
+
+
+ ); +}; diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index b62e0cca..90dd6637 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -9,9 +9,11 @@ import { AuthLayout } from "~/client/components/auth-layout"; import { Button } from "~/client/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; import { Input } from "~/client/components/ui/input"; +import { Label } from "~/client/components/ui/label"; import { authMiddleware } from "~/middleware/auth"; import type { Route } from "./+types/login"; -import { loginMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { loginMutation, verify2FaMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { Reset2faDialog } from "../components/reset-2fa-dialog"; import { ResetPasswordDialog } from "../components/reset-password-dialog"; export const clientMiddleware = [authMiddleware]; @@ -36,8 +38,11 @@ type LoginFormValues = typeof loginSchema.inferIn; export default function LoginPage() { const navigate = useNavigate(); const [showResetDialog, setShowResetDialog] = useState(false); + const [showReset2faDialog, setShowReset2faDialog] = useState(false); + const [pendingSessionId, setPendingSessionId] = useState(null); + const [twoFactorCode, setTwoFactorCode] = useState(""); - const form = useForm({ + const loginForm = useForm({ resolver: arktypeResolver(loginSchema), defaultValues: { username: "", @@ -45,13 +50,23 @@ export default function LoginPage() { }, }); + const handleLoginSuccess = (user: { hasDownloadedResticPassword: boolean }) => { + if (!user.hasDownloadedResticPassword) { + navigate("/download-recovery-key"); + } else { + navigate("/volumes"); + } + }; + const login = useMutation({ ...loginMutation(), onSuccess: async (data) => { - if (data.user && !data.user.hasDownloadedResticPassword) { - navigate("/download-recovery-key"); - } else { - navigate("/volumes"); + if (data.requiresTwoFactor && data.pendingSessionId) { + setPendingSessionId(data.pendingSessionId); + return; + } + if (data.user) { + handleLoginSuccess(data.user); } }, onError: (error) => { @@ -60,7 +75,20 @@ export default function LoginPage() { }, }); - const onSubmit = (values: LoginFormValues) => { + const verify2fa = useMutation({ + ...verify2FaMutation(), + onSuccess: (data) => { + if (data.user) { + handleLoginSuccess(data.user); + } + }, + onError: (error) => { + console.error(error); + toast.error("Verification failed", { description: error.message }); + }, + }); + + const onLoginSubmit = (values: LoginFormValues) => { login.mutate({ body: { username: values.username.trim(), @@ -69,12 +97,73 @@ export default function LoginPage() { }); }; + const handleBackToLogin = () => { + setPendingSessionId(null); + setTwoFactorCode(""); + }; + + const handleTwoFactorSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!pendingSessionId || twoFactorCode.length !== 6) return; + verify2fa.mutate({ + body: { + pendingSessionId, + code: twoFactorCode.trim(), + }, + } as Parameters[0]); + }; + + // Show 2FA verification form + if (pendingSessionId) { + return ( + +
+
+ + setTwoFactorCode(e.target.value.replace(/\D/g, ""))} + /> +
+ + +
+ +
+
+ +
+ ); + } + return ( -
- + + ( @@ -87,7 +176,7 @@ export default function LoginPage() { )} /> ( diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 7bbd0dfd..182c7139 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -1,8 +1,10 @@ -import { useMutation } from "@tanstack/react-query"; -import { Download, KeyRound, User, X } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Copy, Download, KeyRound, Shield, User, X } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router"; +import { QRCodeSVG } from "qrcode.react"; import { toast } from "sonner"; +import { Badge } from "~/client/components/ui/badge"; import { Button } from "~/client/components/ui/button"; import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card"; import { @@ -20,9 +22,15 @@ import { appContext } from "~/context"; import type { Route } from "./+types/settings"; import { changePasswordMutation, + disable2FaMutation, downloadResticPasswordMutation, + enable2FaMutation, + getTwoFactorStatusOptions, logoutMutation, + setup2FaMutation, } from "~/client/api-client/@tanstack/react-query.gen"; +import type { Disable2FaResponse, Enable2FaResponse, Setup2FaResponse } from "~/client/api-client/types.gen"; +import { copyToClipboard } from "~/utils/clipboard"; export const handle = { breadcrumb: () => [{ label: "Settings" }], @@ -49,8 +57,21 @@ export default function Settings({ loaderData }: Route.ComponentProps) { const [confirmPassword, setConfirmPassword] = useState(""); const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); const [downloadPassword, setDownloadPassword] = useState(""); + const [enable2faDialogOpen, setEnable2faDialogOpen] = useState(false); + const [enable2faStep, setEnable2faStep] = useState<"setup" | "verify">("setup"); + const [enable2faPassword, setEnable2faPassword] = useState(""); + const [enable2faSecret, setEnable2faSecret] = useState(""); + const [enable2faUri, setEnable2faUri] = useState(""); + const [enable2faCode, setEnable2faCode] = useState(""); + const [disable2faDialogOpen, setDisable2faDialogOpen] = useState(false); + const [disable2faPassword, setDisable2faPassword] = useState(""); + const [disable2faCode, setDisable2faCode] = useState(""); const navigate = useNavigate(); + const twoFactorStatus = useQuery({ + ...getTwoFactorStatusOptions(), + }); + const logout = useMutation({ ...logoutMutation(), onSuccess: () => { @@ -97,6 +118,73 @@ export default function Settings({ loaderData }: Route.ComponentProps) { }, }); + const setup2fa = useMutation({ + ...setup2FaMutation(), + onSuccess: (data: Setup2FaResponse) => { + if (data.success && data.uri && data.secret) { + setEnable2faUri(data.uri); + setEnable2faSecret(data.secret); + setEnable2faStep("verify"); + } else { + toast.error("Failed to setup 2FA", { description: data.message }); + } + }, + onError: (error) => { + toast.error("Failed to setup 2FA", { description: error.message }); + }, + }); + + const enable2fa = useMutation({ + ...enable2FaMutation(), + onSuccess: (data: Enable2FaResponse) => { + if (data.success) { + toast.success("2FA enabled successfully. Please log in again."); + setEnable2faDialogOpen(false); + resetEnable2faDialog(); + setTimeout(() => { + navigate("/login", { replace: true }); + }, 1500); + } else { + toast.error("Failed to enable 2FA", { description: data.message }); + } + }, + onError: (error) => { + toast.error("Failed to enable 2FA", { description: error.message }); + }, + }); + + const disable2fa = useMutation({ + ...disable2FaMutation(), + onSuccess: (data: Disable2FaResponse) => { + if (data.success) { + toast.success("2FA disabled successfully. Please log in again."); + setDisable2faDialogOpen(false); + resetDisable2faDialog(); + setTimeout(() => { + navigate("/login", { replace: true }); + }, 1500); + } else { + toast.error("Failed to disable 2FA", { description: data.message }); + } + }, + onError: (error) => { + toast.error("Failed to disable 2FA", { description: error.message }); + }, + }); + + const resetEnable2faDialog = () => { + setEnable2faStep("setup"); + setEnable2faPassword(""); + setEnable2faSecret(""); + setEnable2faUri(""); + setEnable2faCode(""); + }; + + const resetDisable2faDialog = () => { + setDisable2faPassword(""); + setDisable2faCode(""); + }; + const handleChangePassword = (e: React.FormEvent) => { e.preventDefault(); @@ -133,6 +221,58 @@ export default function Settings({ loaderData }: Route.ComponentProps) { }); }; + const handleStartEnable2fa = () => { + setup2fa.mutate({}); + }; + + const handleConfirmEnable2fa = (e: React.FormEvent) => { + e.preventDefault(); + + if (!enable2faPassword) { + toast.error("Password is required"); + return; + } + + if (!enable2faCode || enable2faCode.length !== 6) { + toast.error("Please enter a valid 6-digit code"); + return; + } + + enable2fa.mutate({ + body: { + password: enable2faPassword, + secret: enable2faSecret, + code: enable2faCode, + }, + }); + }; + + const handleDisable2fa = (e: React.FormEvent) => { + e.preventDefault(); + + if (!disable2faPassword) { + toast.error("Password is required"); + return; + } + + if (!disable2faCode || disable2faCode.length !== 6) { + toast.error("Please enter a valid 6-digit code"); + return; + } + + disable2fa.mutate({ + body: { + password: disable2faPassword, + code: disable2faCode, + }, + }); + }; + + const handleCopySecret = () => { + copyToClipboard(enable2faSecret); + toast.success("Secret key copied to clipboard"); + }; + return (
@@ -201,6 +341,199 @@ export default function Settings({ loaderData }: Route.ComponentProps) { + {/* Two-Factor Authentication Section */} +
+ + + Two-Factor Authentication + {twoFactorStatus.data && ( + + {twoFactorStatus.data.enabled ? "Enabled" : "Disabled"} + + )} + + Add an extra layer of security to your account +
+ +

+ Two-factor authentication adds an additional layer of security by requiring a time-based code from your + authenticator app when logging in. +

+ + {twoFactorStatus.data?.enabled ? ( + { + setDisable2faDialogOpen(open); + if (!open) resetDisable2faDialog(); + }} + > + + + + +
+ + Disable Two-Factor Authentication + + To disable 2FA, please enter your password and a code from your authenticator app. + + +
+
+ + setDisable2faPassword(e.target.value)} + placeholder="Enter your password" + required + autoFocus + /> +
+
+ + setDisable2faCode(e.target.value.replace(/\D/g, ""))} + placeholder="000000" + required + className="font-mono tracking-widest" + /> +
+
+ + + + +
+
+
+ ) : ( + { + setEnable2faDialogOpen(open); + if (!open) resetEnable2faDialog(); + }} + > + + + + + {enable2faStep === "setup" ? ( + <> + + Setting up 2FA... + Please wait while we generate your secret key. + +
+
+
+ + ) : ( +
+ + Enable Two-Factor Authentication + + Scan the QR code with your authenticator app (like Google Authenticator, Authy, or 1Password), + then enter the 6-digit code to verify. + + +
+
+
+ +
+
+
+ +
+ + +
+

+ If you can't scan the QR code, enter this key manually in your authenticator app. +

+
+
+ + setEnable2faPassword(e.target.value)} + placeholder="Enter your password" + required + /> +
+
+ + setEnable2faCode(e.target.value.replace(/\D/g, ""))} + placeholder="000000" + required + className="font-mono tracking-widest" + /> +
+
+ + + + +
+ )} + +
+ )} +
+
diff --git a/app/drizzle/0029_demonic_namor.sql b/app/drizzle/0029_demonic_namor.sql new file mode 100644 index 00000000..e2c1d094 --- /dev/null +++ b/app/drizzle/0029_demonic_namor.sql @@ -0,0 +1,2 @@ +ALTER TABLE `users_table` ADD `totp_secret` text;--> statement-breakpoint +ALTER TABLE `users_table` ADD `totp_enabled` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/app/drizzle/meta/0029_snapshot.json b/app/drizzle/meta/0029_snapshot.json new file mode 100644 index 00000000..a0f6126f --- /dev/null +++ b/app/drizzle/meta/0029_snapshot.json @@ -0,0 +1,861 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "50419b95-e105-4be6-936a-070457ec43a7", + "prevId": "2837bed4-34fb-4d16-b331-7b6d483979bc", + "tables": { + "app_metadata": { + "name": "app_metadata", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedule_mirrors_table": { + "name": "backup_schedule_mirrors_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_copy_at": { + "name": "last_copy_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_copy_status": { + "name": "last_copy_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_copy_error": { + "name": "last_copy_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "backup_schedule_mirrors_table_schedule_id_repository_id_unique": { + "name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique", + "columns": [ + "schedule_id", + "repository_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": { + "name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk", + "tableFrom": "backup_schedule_mirrors_table", + "tableTo": "backup_schedules_table", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedule_mirrors_table", + "tableTo": "repositories_table", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedule_notifications_table": { + "name": "backup_schedule_notifications_table", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination_id": { + "name": "destination_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notify_on_start": { + "name": "notify_on_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_on_success": { + "name": "notify_on_success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_on_warning": { + "name": "notify_on_warning", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_on_failure": { + "name": "notify_on_failure", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": { + "name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk", + "tableFrom": "backup_schedule_notifications_table", + "tableTo": "backup_schedules_table", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": { + "name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk", + "tableFrom": "backup_schedule_notifications_table", + "tableTo": "notification_destinations_table", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "backup_schedule_notifications_table_schedule_id_destination_id_pk": { + "columns": [ + "schedule_id", + "destination_id" + ], + "name": "backup_schedule_notifications_table_schedule_id_destination_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedules_table": { + "name": "backup_schedules_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "volume_id": { + "name": "volume_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_policy": { + "name": "retention_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "exclude_if_present": { + "name": "exclude_if_present", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "include_patterns": { + "name": "include_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_status": { + "name": "last_backup_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_error": { + "name": "last_backup_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_backup_at": { + "name": "next_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "one_file_system": { + "name": "one_file_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "backup_schedules_table_short_id_unique": { + "name": "backup_schedules_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "backup_schedules_table_name_unique": { + "name": "backup_schedules_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "backup_schedules_table_volume_id_volumes_table_id_fk": { + "name": "backup_schedules_table_volume_id_volumes_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "volumes_table", + "columnsFrom": [ + "volume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedules_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "repositories_table", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_destinations_table": { + "name": "notification_destinations_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "notification_destinations_table_name_unique": { + "name": "notification_destinations_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories_table": { + "name": "repositories_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compression_mode": { + "name": "compression_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'auto'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "repositories_table_short_id_unique": { + "name": "repositories_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions_table": { + "name": "sessions_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_table_user_id_users_table_id_fk": { + "name": "sessions_table_user_id_users_table_id_fk", + "tableFrom": "sessions_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_downloaded_restic_password": { + "name": "has_downloaded_restic_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "volumes_table": { + "name": "volumes_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unmounted'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_remount": { + "name": "auto_remount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "volumes_table_short_id_unique": { + "name": "volumes_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "volumes_table_name_unique": { + "name": "volumes_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/app/drizzle/meta/_journal.json b/app/drizzle/meta/_journal.json index afa41ba8..eb4451c6 100644 --- a/app/drizzle/meta/_journal.json +++ b/app/drizzle/meta/_journal.json @@ -1,209 +1,216 @@ { - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1755765658194, - "tag": "0000_known_madelyne_pryor", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1755775437391, - "tag": "0001_far_frank_castle", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1756930554198, - "tag": "0002_cheerful_randall", - "breakpoints": true - }, - { - "idx": 3, - "version": "6", - "when": 1758653407064, - "tag": "0003_mature_hellcat", - "breakpoints": true - }, - { - "idx": 4, - "version": "6", - "when": 1758961535488, - "tag": "0004_wealthy_tomas", - "breakpoints": true - }, - { - "idx": 5, - "version": "6", - "when": 1759416698274, - "tag": "0005_simple_alice", - "breakpoints": true - }, - { - "idx": 6, - "version": "6", - "when": 1760734377440, - "tag": "0006_secret_micromacro", - "breakpoints": true - }, - { - "idx": 7, - "version": "6", - "when": 1761224911352, - "tag": "0007_watery_sersi", - "breakpoints": true - }, - { - "idx": 8, - "version": "6", - "when": 1761414054481, - "tag": "0008_silent_lady_bullseye", - "breakpoints": true - }, - { - "idx": 9, - "version": "6", - "when": 1762095226041, - "tag": "0009_little_adam_warlock", - "breakpoints": true - }, - { - "idx": 10, - "version": "6", - "when": 1762610065889, - "tag": "0010_perfect_proemial_gods", - "breakpoints": true - }, - { - "idx": 11, - "version": "6", - "when": 1763644043601, - "tag": "0011_familiar_stone_men", - "breakpoints": true - }, - { - "idx": 12, - "version": "6", - "when": 1764100562084, - "tag": "0012_add_short_ids", - "breakpoints": true - }, - { - "idx": 13, - "version": "6", - "when": 1764182159797, - "tag": "0013_elite_sprite", - "breakpoints": true - }, - { - "idx": 14, - "version": "6", - "when": 1764182405089, - "tag": "0014_wild_echo", - "breakpoints": true - }, - { - "idx": 15, - "version": "6", - "when": 1764182465287, - "tag": "0015_jazzy_sersi", - "breakpoints": true - }, - { - "idx": 16, - "version": "6", - "when": 1764194697035, - "tag": "0016_fix-timestamps-to-ms", - "breakpoints": true - }, - { - "idx": 17, - "version": "6", - "when": 1764357897219, - "tag": "0017_fix-compression-modes", - "breakpoints": true - }, - { - "idx": 18, - "version": "6", - "when": 1764794371040, - "tag": "0018_breezy_invaders", - "breakpoints": true - }, - { - "idx": 19, - "version": "6", - "when": 1764839917446, - "tag": "0019_secret_nomad", - "breakpoints": true - }, - { - "idx": 20, - "version": "6", - "when": 1764847918249, - "tag": "0020_even_dexter_bennett", - "breakpoints": true - }, - { - "idx": 21, - "version": "6", - "when": 1765307881092, - "tag": "0021_steady_viper", - "breakpoints": true - }, - { - "idx": 22, - "version": "6", - "when": 1765794552191, - "tag": "0022_woozy_shen", - "breakpoints": true - }, - { - "idx": 23, - "version": "6", - "when": 1766320570509, - "tag": "0023_special_thor", - "breakpoints": true - }, - { - "idx": 24, - "version": "6", - "when": 1766325504548, - "tag": "0024_schedules-one-fs", - "breakpoints": true - }, - { - "idx": 25, - "version": "6", - "when": 1766431021321, - "tag": "0025_remarkable_pete_wisdom", - "breakpoints": true - }, - { - "idx": 26, - "version": "6", - "when": 1766765013108, - "tag": "0026_migrate-local-repo-paths", - "breakpoints": true - }, - { - "idx": 27, - "version": "6", - "when": 1766778073418, - "tag": "0027_careful_cammi", - "breakpoints": true - }, - { - "idx": 28, - "version": "6", - "when": 1766778162985, - "tag": "0028_third_amazoness", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1755765658194, + "tag": "0000_known_madelyne_pryor", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1755775437391, + "tag": "0001_far_frank_castle", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1756930554198, + "tag": "0002_cheerful_randall", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1758653407064, + "tag": "0003_mature_hellcat", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1758961535488, + "tag": "0004_wealthy_tomas", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1759416698274, + "tag": "0005_simple_alice", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1760734377440, + "tag": "0006_secret_micromacro", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1761224911352, + "tag": "0007_watery_sersi", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1761414054481, + "tag": "0008_silent_lady_bullseye", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1762095226041, + "tag": "0009_little_adam_warlock", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1762610065889, + "tag": "0010_perfect_proemial_gods", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1763644043601, + "tag": "0011_familiar_stone_men", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1764100562084, + "tag": "0012_add_short_ids", + "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1764182159797, + "tag": "0013_elite_sprite", + "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1764182405089, + "tag": "0014_wild_echo", + "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1764182465287, + "tag": "0015_jazzy_sersi", + "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1764194697035, + "tag": "0016_fix-timestamps-to-ms", + "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1764357897219, + "tag": "0017_fix-compression-modes", + "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1764794371040, + "tag": "0018_breezy_invaders", + "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1764839917446, + "tag": "0019_secret_nomad", + "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1764847918249, + "tag": "0020_even_dexter_bennett", + "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1765307881092, + "tag": "0021_steady_viper", + "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1765794552191, + "tag": "0022_woozy_shen", + "breakpoints": true + }, + { + "idx": 23, + "version": "6", + "when": 1766320570509, + "tag": "0023_special_thor", + "breakpoints": true + }, + { + "idx": 24, + "version": "6", + "when": 1766325504548, + "tag": "0024_schedules-one-fs", + "breakpoints": true + }, + { + "idx": 25, + "version": "6", + "when": 1766431021321, + "tag": "0025_remarkable_pete_wisdom", + "breakpoints": true + }, + { + "idx": 26, + "version": "6", + "when": 1766765013108, + "tag": "0026_migrate-local-repo-paths", + "breakpoints": true + }, + { + "idx": 27, + "version": "6", + "when": 1766778073418, + "tag": "0027_careful_cammi", + "breakpoints": true + }, + { + "idx": 28, + "version": "6", + "when": 1766778162985, + "tag": "0028_third_amazoness", + "breakpoints": true + }, + { + "idx": 29, + "version": "6", + "when": 1767366633340, + "tag": "0029_demonic_namor", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/app/server/cli/commands/2fa.ts b/app/server/cli/commands/2fa.ts new file mode 100644 index 00000000..54b31ed0 --- /dev/null +++ b/app/server/cli/commands/2fa.ts @@ -0,0 +1,52 @@ +import { Command } from "commander"; +import { eq } from "drizzle-orm"; +import { db } from "../../db/db"; +import { sessionsTable, usersTable } from "../../db/schema"; + +const disableTwoFactor = async (username: string): Promise => { + const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username)); + + if (!user) { + throw new Error(`User "${username}" not found`); + } + + if (!user.totpEnabled || !user.totpSecret) { + throw new Error(`2FA is not enabled for user "${username}"`); + } + + await db.transaction(async (tx) => { + await tx + .update(usersTable) + .set({ + totpSecret: null, + totpEnabled: false, + updatedAt: Date.now(), + }) + .where(eq(usersTable.id, user.id)); + + await tx.delete(sessionsTable).where(eq(sessionsTable.userId, user.id)); + }); + + console.log(`\nāœ… 2FA has been disabled for user "${username}".`); + console.log(" All existing sessions have been invalidated."); +}; + +export const twoFactorCommand = new Command("2fa") + .description("Two-factor authentication recovery") + .addCommand( + new Command("disable") + .description("Disable 2FA for a user (recovery method when authenticator access is lost)") + .requiredOption("-u, --username ", "Username of the account") + .action(async (options) => { + console.log("\nšŸ” Zerobyte Two-Factor Authentication Recovery\n"); + + try { + await disableTwoFactor(options.username); + } catch (error) { + console.error(`\nāŒ ${error instanceof Error ? error.message : "Unknown error"}`); + process.exit(1); + } + + process.exit(0); + }), + ); diff --git a/app/server/cli/index.ts b/app/server/cli/index.ts index 9a4be586..52633677 100644 --- a/app/server/cli/index.ts +++ b/app/server/cli/index.ts @@ -1,10 +1,12 @@ import { Command } from "commander"; import { resetPasswordCommand } from "./commands/reset-password"; +import { twoFactorCommand } from "./commands/2fa"; const program = new Command(); program.name("zerobyte").description("Zerobyte CLI - Backup automation tool built on top of Restic").version("1.0.0"); program.addCommand(resetPasswordCommand); +program.addCommand(twoFactorCommand); export async function runCLI(argv: string[]): Promise { const args = argv.slice(2); diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 61c82982..44fabd8b 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -31,6 +31,8 @@ export const usersTable = sqliteTable("users_table", { username: text().notNull().unique(), passwordHash: text("password_hash").notNull(), hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false), + totpSecret: text("totp_secret"), + totpEnabled: int("totp_enabled", { mode: "boolean" }).notNull().default(false), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), }); diff --git a/app/server/modules/auth/auth.controller.ts b/app/server/modules/auth/auth.controller.ts index 45ba5518..a30d4982 100644 --- a/app/server/modules/auth/auth.controller.ts +++ b/app/server/modules/auth/auth.controller.ts @@ -5,19 +5,32 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { changePasswordBodySchema, changePasswordDto, + disable2faBodySchema, + disable2faDto, + enable2faBodySchema, + enable2faDto, getMeDto, getStatusDto, + getTwoFactorStatusDto, loginBodySchema, loginDto, logoutDto, registerBodySchema, registerDto, + setup2faDto, + verify2faBodySchema, + verify2faDto, type ChangePasswordDto, + type Disable2faDto, + type Enable2faDto, type GetMeDto, type GetStatusDto, + type GetTwoFactorStatusDto, type LoginDto, type LogoutDto, type RegisterDto, + type Setup2faDto, + type Verify2faDto, } from "./auth.dto"; import { authService } from "./auth.service"; import { toMessage } from "../../utils/errors"; @@ -72,14 +85,47 @@ export const authController = new Hono() const body = c.req.valid("json"); try { - const { sessionId, user, expiresAt } = await authService.login(body.username, body.password); + const result = await authService.login(body.username, body.password); + + if (result.requiresTwoFactor) { + return c.json({ + success: true, + message: "2FA verification required", + requiresTwoFactor: true, + pendingSessionId: result.pendingSessionId, + }); + } + + setCookie(c, COOKIE_NAME, result.sessionId, { + ...COOKIE_OPTIONS, + expires: new Date(result.expiresAt), + }); + + return c.json({ + success: true, + message: "Login successful", + user: { + id: result.user.id, + username: result.user.username, + hasDownloadedResticPassword: result.user.hasDownloadedResticPassword, + }, + }); + } catch (error) { + return c.json({ success: false, message: toMessage(error) }, 401); + } + }) + .post("/verify-2fa", authRateLimiter, verify2faDto, validator("json", verify2faBodySchema), async (c) => { + const body = c.req.valid("json"); + + try { + const { sessionId, user, expiresAt } = await authService.verifyTwoFactor(body.pendingSessionId, body.code); setCookie(c, COOKIE_NAME, sessionId, { ...COOKIE_OPTIONS, expires: new Date(expiresAt), }); - return c.json({ + return c.json({ success: true, message: "Login successful", user: { @@ -89,7 +135,7 @@ export const authController = new Hono() }, }); } catch (error) { - return c.json({ success: false, message: toMessage(error) }, 401); + return c.json({ success: false, message: toMessage(error) }, 401); } }) .post("/logout", authRateLimiter, logoutDto, async (c) => { @@ -154,4 +200,103 @@ export const authController = new Hono() return c.json({ success: false, message: toMessage(error) }, 400); } }, - ); + ) + .get("/2fa-status", getTwoFactorStatusDto, async (c) => { + const sessionId = getCookie(c, COOKIE_NAME); + + if (!sessionId) { + return c.json({ message: "Not authenticated" }, 401); + } + + const session = await authService.verifySession(sessionId); + + if (!session) { + deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); + return c.json({ message: "Not authenticated" }, 401); + } + + const enabled = await authService.getTwoFactorStatus(session.user.id); + return c.json({ enabled }); + }) + .post("/2fa/setup", authRateLimiter, setup2faDto, async (c) => { + const sessionId = getCookie(c, COOKIE_NAME); + + if (!sessionId) { + return c.json({ success: false, message: "Not authenticated" }, 401); + } + + const session = await authService.verifySession(sessionId); + + if (!session) { + deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); + return c.json({ success: false, message: "Not authenticated" }, 401); + } + + try { + const { uri, secret } = await authService.setupTwoFactor(session.user.id); + return c.json({ + success: true, + message: "2FA setup data generated", + uri, + secret, + }); + } catch (error) { + return c.json({ success: false, message: toMessage(error) }, 400); + } + }) + .post("/2fa/enable", authRateLimiter, enable2faDto, validator("json", enable2faBodySchema), async (c) => { + const sessionId = getCookie(c, COOKIE_NAME); + + if (!sessionId) { + return c.json({ success: false, message: "Not authenticated" }, 401); + } + + const session = await authService.verifySession(sessionId); + + if (!session) { + deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); + return c.json({ success: false, message: "Not authenticated" }, 401); + } + + const body = c.req.valid("json"); + + try { + await authService.enableTwoFactor(session.user.id, body.password, body.secret, body.code); + // Clear the session cookie since all sessions were invalidated + deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); + return c.json({ + success: true, + message: "2FA enabled successfully. Please log in again.", + }); + } catch (error) { + return c.json({ success: false, message: toMessage(error) }, 400); + } + }) + .post("/2fa/disable", authRateLimiter, disable2faDto, validator("json", disable2faBodySchema), async (c) => { + const sessionId = getCookie(c, COOKIE_NAME); + + if (!sessionId) { + return c.json({ success: false, message: "Not authenticated" }, 401); + } + + const session = await authService.verifySession(sessionId); + + if (!session) { + deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); + return c.json({ success: false, message: "Not authenticated" }, 401); + } + + const body = c.req.valid("json"); + + try { + await authService.disableTwoFactor(session.user.id, body.password, body.code); + // Clear the session cookie since all sessions were invalidated + deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); + return c.json({ + success: true, + message: "2FA disabled successfully. Please log in again.", + }); + } catch (error) { + return c.json({ success: false, message: toMessage(error) }, 400); + } + }); diff --git a/app/server/modules/auth/auth.dto.ts b/app/server/modules/auth/auth.dto.ts index f305f9c1..0cddc70d 100644 --- a/app/server/modules/auth/auth.dto.ts +++ b/app/server/modules/auth/auth.dto.ts @@ -20,6 +20,8 @@ const loginResponseSchema = type({ username: "string", hasDownloadedResticPassword: "boolean", }).optional(), + requiresTwoFactor: "boolean?", + pendingSessionId: "string?", }); export const loginDto = describeRoute({ @@ -148,6 +150,174 @@ export const changePasswordDto = describeRoute({ export type ChangePasswordDto = typeof changePasswordResponseSchema.infer; +// 2FA Status DTO +const twoFactorStatusResponseSchema = type({ + enabled: "boolean", +}); + +export const getTwoFactorStatusDto = describeRoute({ + description: "Get 2FA status for current authenticated user", + operationId: "getTwoFactorStatus", + tags: ["Auth"], + responses: { + 200: { + description: "2FA status", + content: { + "application/json": { + schema: resolver(twoFactorStatusResponseSchema), + }, + }, + }, + 401: { + description: "Not authenticated", + }, + }, +}); + +export type GetTwoFactorStatusDto = typeof twoFactorStatusResponseSchema.infer; + +// Verify 2FA DTO +export const verify2faBodySchema = type({ + pendingSessionId: "string>0", + code: "string==6", +}); + +const verify2faResponseSchema = type({ + message: "string", + success: "boolean", + user: type({ + id: "number", + username: "string", + hasDownloadedResticPassword: "boolean", + }).optional(), +}); + +export const verify2faDto = describeRoute({ + description: "Verify 2FA code and complete login", + operationId: "verify2fa", + tags: ["Auth"], + responses: { + 200: { + description: "2FA verification successful", + content: { + "application/json": { + schema: resolver(verify2faResponseSchema), + }, + }, + }, + 401: { + description: "Invalid 2FA code or session", + }, + }, +}); + +export type Verify2faDto = typeof verify2faResponseSchema.infer; + +// 2FA Setup DTO (generate secret and return URI for QR code) +const setup2faResponseSchema = type({ + success: "boolean", + message: "string", + uri: "string?", + secret: "string?", +}); + +export const setup2faDto = describeRoute({ + description: "Generate 2FA setup data (secret and otpauth URI for QR code)", + operationId: "setup2fa", + tags: ["Auth"], + responses: { + 200: { + description: "2FA setup data", + content: { + "application/json": { + schema: resolver(setup2faResponseSchema), + }, + }, + }, + 400: { + description: "2FA already enabled or other error", + }, + 401: { + description: "Not authenticated", + }, + }, +}); + +export type Setup2faDto = typeof setup2faResponseSchema.infer; + +// 2FA Enable DTO (verify code and enable) +export const enable2faBodySchema = type({ + password: "string>0", + code: "string==6", + secret: "string>0", +}); + +const enable2faResponseSchema = type({ + success: "boolean", + message: "string", +}); + +export const enable2faDto = describeRoute({ + description: "Verify 2FA code and enable 2FA for the account", + operationId: "enable2fa", + tags: ["Auth"], + responses: { + 200: { + description: "2FA enabled successfully", + content: { + "application/json": { + schema: resolver(enable2faResponseSchema), + }, + }, + }, + 400: { + description: "Invalid code or 2FA already enabled", + }, + 401: { + description: "Not authenticated", + }, + }, +}); + +export type Enable2faDto = typeof enable2faResponseSchema.infer; + +// 2FA Disable DTO (require password and TOTP code) +export const disable2faBodySchema = type({ + password: "string>0", + code: "string==6", +}); + +const disable2faResponseSchema = type({ + success: "boolean", + message: "string", +}); + +export const disable2faDto = describeRoute({ + description: "Disable 2FA for the account (requires password and current TOTP code)", + operationId: "disable2fa", + tags: ["Auth"], + responses: { + 200: { + description: "2FA disabled successfully", + content: { + "application/json": { + schema: resolver(disable2faResponseSchema), + }, + }, + }, + 400: { + description: "Invalid password or code, or 2FA not enabled", + }, + 401: { + description: "Not authenticated", + }, + }, +}); + +export type Disable2faDto = typeof disable2faResponseSchema.infer; + export type LoginBody = typeof loginBodySchema.infer; export type RegisterBody = typeof registerBodySchema.infer; export type ChangePasswordBody = typeof changePasswordBodySchema.infer; +export type Enable2faBody = typeof enable2faBodySchema.infer; +export type Disable2faBody = typeof disable2faBodySchema.infer; diff --git a/app/server/modules/auth/auth.service.ts b/app/server/modules/auth/auth.service.ts index 4a97c0df..9938d47e 100644 --- a/app/server/modules/auth/auth.service.ts +++ b/app/server/modules/auth/auth.service.ts @@ -1,9 +1,19 @@ import { eq, lt } from "drizzle-orm"; import { db } from "../../db/db"; import { sessionsTable, usersTable } from "../../db/schema"; +import { cryptoUtils } from "../../utils/crypto"; import { logger } from "../../utils/logger"; +import * as OTPAuth from "otpauth"; const SESSION_DURATION = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds +const PENDING_2FA_DURATION = 5 * 60 * 1000; // 5 minutes for 2FA verification + +// In-memory store for pending 2FA sessions (short-lived, no need for DB persistence) +interface Pending2faSession { + userId: number; + expiresAt: number; +} +const pending2faSessions = new Map(); export class AuthService { /** @@ -51,6 +61,7 @@ export class AuthService { /** * Login user with username and password + * Returns requiresTwoFactor: true if 2FA is enabled, with a pendingSessionId */ async login(username: string, password: string) { const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username)); @@ -65,6 +76,26 @@ export class AuthService { throw new Error("Invalid credentials"); } + // Check if 2FA is enabled + if (user.totpEnabled && user.totpSecret) { + // Create a pending 2FA session in memory + const pendingSessionId = crypto.randomUUID(); + const expiresAt = Date.now() + PENDING_2FA_DURATION; + + pending2faSessions.set(pendingSessionId, { + userId: user.id, + expiresAt, + }); + + logger.info(`2FA required for user: ${username}`); + + return { + requiresTwoFactor: true as const, + pendingSessionId, + }; + } + + // No 2FA - create full session const sessionId = crypto.randomUUID(); const expiresAt = Date.now() + SESSION_DURATION; @@ -77,6 +108,7 @@ export class AuthService { logger.info(`User logged in: ${username}`); return { + requiresTwoFactor: false as const, sessionId, user: { id: user.id, @@ -87,6 +119,96 @@ export class AuthService { }; } + /** + * Verify 2FA code and complete login + */ + async verifyTwoFactor(pendingSessionId: string, code: string) { + // Find the pending session from memory + const pendingSession = pending2faSessions.get(pendingSessionId); + + if (!pendingSession) { + throw new Error("Invalid or expired 2FA session"); + } + + // Check if session expired + if (pendingSession.expiresAt < Date.now()) { + pending2faSessions.delete(pendingSessionId); + throw new Error("2FA session expired. Please login again."); + } + + // Get user from database + const [user] = await db.select().from(usersTable).where(eq(usersTable.id, pendingSession.userId)); + + if (!user) { + pending2faSessions.delete(pendingSessionId); + throw new Error("User not found"); + } + + if (!user.totpSecret) { + throw new Error("2FA not configured for this user"); + } + + // Decrypt the TOTP secret + const decryptedSecret = await cryptoUtils.resolveSecret(user.totpSecret); + + // Verify TOTP code + const totp = new OTPAuth.TOTP({ + issuer: "Zerobyte", + label: user.username, + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(decryptedSecret), + }); + + const delta = totp.validate({ token: code, window: 1 }); + if (delta === null) { + throw new Error("Invalid 2FA code"); + } + + // Delete the pending session from memory + pending2faSessions.delete(pendingSessionId); + + // Create full session + const sessionId = crypto.randomUUID(); + const expiresAt = Date.now() + SESSION_DURATION; + + await db.insert(sessionsTable).values({ + id: sessionId, + userId: user.id, + expiresAt, + }); + + logger.info(`User logged in with 2FA: ${user.username}`); + + return { + sessionId, + user: { + id: user.id, + username: user.username, + hasDownloadedResticPassword: user.hasDownloadedResticPassword, + }, + expiresAt, + }; + } + + /** + * Clean up expired pending 2FA sessions from memory + */ + cleanupExpiredPending2faSessions() { + const now = Date.now(); + let count = 0; + for (const [id, session] of pending2faSessions) { + if (session.expiresAt < now) { + pending2faSessions.delete(id); + count++; + } + } + if (count > 0) { + logger.info(`Cleaned up ${count} expired pending 2FA sessions`); + } + } + /** * Logout user by deleting their session */ @@ -174,6 +296,157 @@ export class AuthService { logger.info(`Password changed for user: ${user.username}`); } + + /** + * Get 2FA status for a user + */ + async getTwoFactorStatus(userId: number): Promise { + const [user] = await db + .select({ totpEnabled: usersTable.totpEnabled }) + .from(usersTable) + .where(eq(usersTable.id, userId)); + return user?.totpEnabled ?? false; + } + + /** + * Generate 2FA setup data (secret and otpauth URI for QR code) + */ + async setupTwoFactor(userId: number): Promise<{ uri: string; secret: string }> { + const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId)); + + if (!user) { + throw new Error("User not found"); + } + + if (user.totpEnabled) { + throw new Error("2FA is already enabled for this account"); + } + + // Generate a new TOTP secret + const secret = new OTPAuth.Secret({ size: 20 }); + const totp = new OTPAuth.TOTP({ + issuer: "Zerobyte", + label: user.username, + algorithm: "SHA1", + digits: 6, + period: 30, + secret: secret, + }); + + return { + uri: totp.toString(), + secret: secret.base32, + }; + } + + /** + * Enable 2FA after verifying password and code + */ + async enableTwoFactor(userId: number, password: string, secret: string, code: string): Promise { + const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId)); + + if (!user) { + throw new Error("User not found"); + } + + if (user.totpEnabled) { + throw new Error("2FA is already enabled for this account"); + } + + // Verify password + const isPasswordValid = await Bun.password.verify(password, user.passwordHash); + if (!isPasswordValid) { + throw new Error("Invalid password"); + } + + // Verify the code with the provided secret + const totp = new OTPAuth.TOTP({ + issuer: "Zerobyte", + label: user.username, + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(secret), + }); + + const delta = totp.validate({ token: code, window: 1 }); + if (delta === null) { + throw new Error("Invalid verification code"); + } + + // Encrypt and save the secret + const encryptedSecret = await cryptoUtils.sealSecret(secret); + + await db.transaction(async (tx) => { + await tx + .update(usersTable) + .set({ + totpSecret: encryptedSecret, + totpEnabled: true, + updatedAt: Date.now(), + }) + .where(eq(usersTable.id, userId)); + + // Invalidate all sessions except current (user will need to re-login on other devices) + await tx.delete(sessionsTable).where(eq(sessionsTable.userId, userId)); + }); + + logger.info(`2FA enabled for user: ${user.username}`); + } + + /** + * Disable 2FA after verifying password and TOTP code + */ + async disableTwoFactor(userId: number, password: string, code: string): Promise { + const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId)); + + if (!user) { + throw new Error("User not found"); + } + + if (!user.totpEnabled || !user.totpSecret) { + throw new Error("2FA is not enabled for this account"); + } + + // Verify password + const isPasswordValid = await Bun.password.verify(password, user.passwordHash); + if (!isPasswordValid) { + throw new Error("Invalid password"); + } + + // Decrypt and verify TOTP code + const decryptedSecret = await cryptoUtils.resolveSecret(user.totpSecret); + const totp = new OTPAuth.TOTP({ + issuer: "Zerobyte", + label: user.username, + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(decryptedSecret), + }); + + const delta = totp.validate({ token: code, window: 1 }); + if (delta === null) { + throw new Error("Invalid 2FA code"); + } + + // Disable 2FA + await db.transaction(async (tx) => { + await tx + .update(usersTable) + .set({ + totpSecret: null, + totpEnabled: false, + updatedAt: Date.now(), + }) + .where(eq(usersTable.id, userId)); + + // Invalidate all sessions + await tx.delete(sessionsTable).where(eq(sessionsTable.userId, userId)); + }); + + logger.info(`2FA disabled for user: ${user.username}`); + } } export const authService = new AuthService(); diff --git a/bun.lock b/bun.lock index 73092752..cdd03711 100644 --- a/bun.lock +++ b/bun.lock @@ -45,6 +45,8 @@ "lucide-react": "^0.555.0", "next-themes": "^0.4.6", "node-cron": "^4.2.1", + "otpauth": "^9.4.1", + "qrcode.react": "^4.2.0", "react": "^19.2.1", "react-dom": "^19.2.1", "react-hook-form": "^7.68.0", @@ -306,6 +308,8 @@ "@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="], + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -924,6 +928,8 @@ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "otpauth": ["otpauth@9.4.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw=="], + "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -950,6 +956,8 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], diff --git a/package.json b/package.json index 4b6861b1..6dc48882 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,8 @@ "lucide-react": "^0.555.0", "next-themes": "^0.4.6", "node-cron": "^4.2.1", + "otpauth": "^9.4.1", + "qrcode.react": "^4.2.0", "react": "^19.2.1", "react-dom": "^19.2.1", "react-hook-form": "^7.68.0",