feat: add two-factor authentication (TOTP) support

- Add 2FA endpoints for setup, enable, disable, and verify
- Encrypt TOTP secrets with AES-256-GCM before storing
- Require password for both enabling and disabling 2FA
- Invalidate all sessions when 2FA status changes
- Add QR code display using qrcode.react (client-side)
- Add 2FA verification step in login flow
- Add CLI recovery command: 2fa disable -u <username>
- Add success variant to Badge component
- Update README with 2FA documentation
This commit is contained in:
Jakub Trávník 2026-01-03 00:19:04 +01:00
parent f836e2f9df
commit a4d0c7b0c9
19 changed files with 2533 additions and 228 deletions

View file

@ -32,6 +32,7 @@ Zerobyte is a backup automation tool that helps you save your data across multip
- &nbsp; **Flexible scheduling** For automated backup jobs with fine-grained retention policies
- &nbsp; **End-to-end encryption** ensuring your data is always protected
- &nbsp; **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories
- &nbsp; **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://<your-server-ip>: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 <username>
```
### 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`:

View file

@ -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<Options<LoginData>>): UseMutatio
return mutationOptions;
};
/**
* Verify 2FA code and complete login
*/
export const verify2FaMutation = (options?: Partial<Options<Verify2FaData>>): UseMutationOptions<Verify2FaResponse, DefaultError, Options<Verify2FaData>> => {
const mutationOptions: UseMutationOptions<Verify2FaResponse, DefaultError, Options<Verify2FaData>> = {
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<Options<ChangePasswordD
return mutationOptions;
};
export const getTwoFactorStatusQueryKey = (options?: Options<GetTwoFactorStatusData>) => createQueryKey('getTwoFactorStatus', options);
/**
* Get 2FA status for current authenticated user
*/
export const getTwoFactorStatusOptions = (options?: Options<GetTwoFactorStatusData>) => queryOptions<GetTwoFactorStatusResponse, DefaultError, GetTwoFactorStatusResponse, ReturnType<typeof getTwoFactorStatusQueryKey>>({
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<Options<Setup2FaData>>): UseMutationOptions<Setup2FaResponse, DefaultError, Options<Setup2FaData>> => {
const mutationOptions: UseMutationOptions<Setup2FaResponse, DefaultError, Options<Setup2FaData>> = {
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<Options<Enable2FaData>>): UseMutationOptions<Enable2FaResponse, DefaultError, Options<Enable2FaData>> => {
const mutationOptions: UseMutationOptions<Enable2FaResponse, DefaultError, Options<Enable2FaData>> = {
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<Options<Disable2FaData>>): UseMutationOptions<Disable2FaResponse, DefaultError, Options<Disable2FaData>> => {
const mutationOptions: UseMutationOptions<Disable2FaResponse, DefaultError, Options<Disable2FaData>> = {
mutationFn: async (fnOptions) => {
const { data } = await disable2Fa({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options);
/**

View file

@ -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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@ -42,6 +42,18 @@ export const login = <ThrowOnError extends boolean = false>(options?: Options<Lo
}
});
/**
* Verify 2FA code and complete login
*/
export const verify2Fa = <ThrowOnError extends boolean = false>(options?: Options<Verify2FaData, ThrowOnError>) => (options?.client ?? client).post<Verify2FaResponses, Verify2FaErrors, ThrowOnError>({
url: '/api/v1/auth/verify-2fa',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Logout current user
*/
@ -69,6 +81,40 @@ export const changePassword = <ThrowOnError extends boolean = false>(options?: O
}
});
/**
* Get 2FA status for current authenticated user
*/
export const getTwoFactorStatus = <ThrowOnError extends boolean = false>(options?: Options<GetTwoFactorStatusData, ThrowOnError>) => (options?.client ?? client).get<GetTwoFactorStatusResponses, GetTwoFactorStatusErrors, ThrowOnError>({ url: '/api/v1/auth/2fa-status', ...options });
/**
* Generate 2FA setup data (secret and otpauth URI for QR code)
*/
export const setup2Fa = <ThrowOnError extends boolean = false>(options?: Options<Setup2FaData, ThrowOnError>) => (options?.client ?? client).post<Setup2FaResponses, Setup2FaErrors, ThrowOnError>({ url: '/api/v1/auth/2fa/setup', ...options });
/**
* Verify 2FA code and enable 2FA for the account
*/
export const enable2Fa = <ThrowOnError extends boolean = false>(options?: Options<Enable2FaData, ThrowOnError>) => (options?.client ?? client).post<Enable2FaResponses, Enable2FaErrors, ThrowOnError>({
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 = <ThrowOnError extends boolean = false>(options?: Options<Disable2FaData, ThrowOnError>) => (options?.client ?? client).post<Disable2FaResponses, Disable2FaErrors, ThrowOnError>({
url: '/api/v1/auth/2fa/disable',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* List all volumes
*/

View file

@ -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;

View file

@ -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: {

View file

@ -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 <username>";
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Reset Two-Factor Authentication</DialogTitle>
<DialogDescription>
Lost access to your authenticator app? Run the following command on the server where Zerobyte is installed
to disable 2FA for your account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md bg-muted p-4 font-mono text-sm break-all">{RESET_2FA_COMMAND}</div>
<p className="text-sm text-muted-foreground">
Replace <code className="bg-muted px-1 rounded">&lt;username&gt;</code> 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.
</p>
<Button onClick={handleCopy} variant="outline" className="w-full">
Copy Command
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -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<string | null>(null);
const [twoFactorCode, setTwoFactorCode] = useState("");
const form = useForm<LoginFormValues>({
const loginForm = useForm<LoginFormValues>({
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<typeof verify2fa.mutate>[0]);
};
// Show 2FA verification form
if (pendingSessionId) {
return (
<AuthLayout
title="Two-Factor Authentication"
description="Enter the 6-digit code from your authenticator app"
>
<form onSubmit={handleTwoFactorSubmit} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="2fa-code">Authentication Code</Label>
<Input
id="2fa-code"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="000000"
disabled={verify2fa.isPending}
autoFocus
autoComplete="one-time-code"
className="font-mono tracking-widest"
value={twoFactorCode}
onChange={(e) => setTwoFactorCode(e.target.value.replace(/\D/g, ""))}
/>
</div>
<Button type="submit" className="w-full" loading={verify2fa.isPending} disabled={twoFactorCode.length !== 6}>
Verify
</Button>
<Button type="button" variant="ghost" className="w-full" onClick={handleBackToLogin}>
Back to Login
</Button>
<div className="text-center">
<button
type="button"
className="text-xs text-muted-foreground hover:underline"
onClick={() => setShowReset2faDialog(true)}
>
Lost access to your authenticator?
</button>
</div>
</form>
<Reset2faDialog open={showReset2faDialog} onOpenChange={setShowReset2faDialog} />
</AuthLayout>
);
}
return (
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<Form {...loginForm}>
<form onSubmit={loginForm.handleSubmit(onLoginSubmit)} className="space-y-4">
<FormField
control={form.control}
control={loginForm.control}
name="username"
render={({ field }) => (
<FormItem>
@ -87,7 +176,7 @@ export default function LoginPage() {
)}
/>
<FormField
control={form.control}
control={loginForm.control}
name="password"
render={({ field }) => (
<FormItem>

View file

@ -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 (
<Card className="p-0 gap-0">
<div className="border-b border-border/50 bg-card-header p-6">
@ -201,6 +341,199 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
</form>
</CardContent>
{/* Two-Factor Authentication Section */}
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Shield className="size-5" />
Two-Factor Authentication
{twoFactorStatus.data && (
<Badge variant={twoFactorStatus.data.enabled ? "success" : "secondary"} className="ml-2">
{twoFactorStatus.data.enabled ? "Enabled" : "Disabled"}
</Badge>
)}
</CardTitle>
<CardDescription className="mt-1.5">Add an extra layer of security to your account</CardDescription>
</div>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground max-w-2xl mb-4">
Two-factor authentication adds an additional layer of security by requiring a time-based code from your
authenticator app when logging in.
</p>
{twoFactorStatus.data?.enabled ? (
<Dialog
open={disable2faDialogOpen}
onOpenChange={(open) => {
setDisable2faDialogOpen(open);
if (!open) resetDisable2faDialog();
}}
>
<DialogTrigger asChild>
<Button variant="destructive">
<Shield size={16} className="mr-2" />
Disable 2FA
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleDisable2fa}>
<DialogHeader>
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
<DialogDescription>
To disable 2FA, please enter your password and a code from your authenticator app.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="disable-2fa-password">Your Password</Label>
<Input
id="disable-2fa-password"
type="password"
value={disable2faPassword}
onChange={(e) => setDisable2faPassword(e.target.value)}
placeholder="Enter your password"
required
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="disable-2fa-code">Authenticator Code</Label>
<Input
id="disable-2fa-code"
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={disable2faCode}
onChange={(e) => setDisable2faCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
required
className="font-mono tracking-widest"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setDisable2faDialogOpen(false);
resetDisable2faDialog();
}}
>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button type="submit" variant="destructive" loading={disable2fa.isPending}>
<Shield className="h-4 w-4 mr-2" />
Disable 2FA
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
) : (
<Dialog
open={enable2faDialogOpen}
onOpenChange={(open) => {
setEnable2faDialogOpen(open);
if (!open) resetEnable2faDialog();
}}
>
<DialogTrigger asChild>
<Button variant="outline" onClick={handleStartEnable2fa}>
<Shield size={16} className="mr-2" />
Enable 2FA
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
{enable2faStep === "setup" ? (
<>
<DialogHeader>
<DialogTitle>Setting up 2FA...</DialogTitle>
<DialogDescription>Please wait while we generate your secret key.</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
</>
) : (
<form onSubmit={handleConfirmEnable2fa}>
<DialogHeader>
<DialogTitle>Enable Two-Factor Authentication</DialogTitle>
<DialogDescription>
Scan the QR code with your authenticator app (like Google Authenticator, Authy, or 1Password),
then enter the 6-digit code to verify.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex justify-center">
<div className="p-4 bg-white rounded-lg">
<QRCodeSVG value={enable2faUri} size={192} />
</div>
</div>
<div className="space-y-2">
<Label>Manual Entry Key</Label>
<div className="flex gap-2">
<Input value={enable2faSecret} readOnly className="font-mono text-sm" />
<Button type="button" variant="outline" size="icon" onClick={handleCopySecret}>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
If you can't scan the QR code, enter this key manually in your authenticator app.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="enable-2fa-password">Your Password</Label>
<Input
id="enable-2fa-password"
type="password"
value={enable2faPassword}
onChange={(e) => setEnable2faPassword(e.target.value)}
placeholder="Enter your password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="enable-2fa-code">Verification Code</Label>
<Input
id="enable-2fa-code"
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={enable2faCode}
onChange={(e) => setEnable2faCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
required
className="font-mono tracking-widest"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setEnable2faDialogOpen(false);
resetEnable2faDialog();
}}
>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button type="submit" loading={enable2fa.isPending}>
<Shield className="h-4 w-4 mr-2" />
Enable 2FA
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
)}
</CardContent>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Download className="size-5" />

View file

@ -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;

View file

@ -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": {}
}
}

View file

@ -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
}
]
}

View file

@ -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<void> => {
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>", "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);
}),
);

View file

@ -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<boolean> {
const args = argv.slice(2);

View file

@ -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)`),
});

View file

@ -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<LoginDto>({
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<LoginDto>({
success: true,
message: "Login successful",
user: {
id: result.user.id,
username: result.user.username,
hasDownloadedResticPassword: result.user.hasDownloadedResticPassword,
},
});
} catch (error) {
return c.json<LoginDto>({ 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<LoginDto>({
return c.json<Verify2faDto>({
success: true,
message: "Login successful",
user: {
@ -89,7 +135,7 @@ export const authController = new Hono()
},
});
} catch (error) {
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
return c.json<Verify2faDto>({ success: false, message: toMessage(error) }, 401);
}
})
.post("/logout", authRateLimiter, logoutDto, async (c) => {
@ -154,4 +200,103 @@ export const authController = new Hono()
return c.json<ChangePasswordDto>({ 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<GetTwoFactorStatusDto>({ enabled });
})
.post("/2fa/setup", authRateLimiter, setup2faDto, async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json<Setup2faDto>({ success: false, message: "Not authenticated" }, 401);
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return c.json<Setup2faDto>({ success: false, message: "Not authenticated" }, 401);
}
try {
const { uri, secret } = await authService.setupTwoFactor(session.user.id);
return c.json<Setup2faDto>({
success: true,
message: "2FA setup data generated",
uri,
secret,
});
} catch (error) {
return c.json<Setup2faDto>({ 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<Enable2faDto>({ success: false, message: "Not authenticated" }, 401);
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return c.json<Enable2faDto>({ 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<Enable2faDto>({
success: true,
message: "2FA enabled successfully. Please log in again.",
});
} catch (error) {
return c.json<Enable2faDto>({ 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<Disable2faDto>({ success: false, message: "Not authenticated" }, 401);
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return c.json<Disable2faDto>({ 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<Disable2faDto>({
success: true,
message: "2FA disabled successfully. Please log in again.",
});
} catch (error) {
return c.json<Disable2faDto>({ success: false, message: toMessage(error) }, 400);
}
});

View file

@ -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;

View file

@ -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<string, Pending2faSession>();
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<boolean> {
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<void> {
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<void> {
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();

View file

@ -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=="],

View file

@ -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",