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; **Flexible scheduling** For automated backup jobs with fine-grained retention policies
- &nbsp; **End-to-end encryption** ensuring your data is always protected - &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; **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories
- &nbsp; **Two-factor authentication** (TOTP) for enhanced account security
## Installation ## 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`. 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) ### 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`: 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 { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen'; 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 { 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, 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 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 * Register a new user
@ -40,6 +40,23 @@ export const loginMutation = (options?: Partial<Options<LoginData>>): UseMutatio
return mutationOptions; 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 * Logout current user
*/ */
@ -143,6 +160,75 @@ export const changePasswordMutation = (options?: Partial<Options<ChangePasswordD
return mutationOptions; 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); 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 type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen'; 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> & { 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 * 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 * List all volumes
*/ */

View file

@ -21,6 +21,8 @@ export type RegisterResponses = {
201: { 201: {
message: string; message: string;
success: boolean; success: boolean;
pendingSessionId?: string;
requiresTwoFactor?: boolean;
user?: { user?: {
hasDownloadedResticPassword: boolean; hasDownloadedResticPassword: boolean;
id: number; id: number;
@ -48,6 +50,8 @@ export type LoginResponses = {
200: { 200: {
message: string; message: string;
success: boolean; success: boolean;
pendingSessionId?: string;
requiresTwoFactor?: boolean;
user?: { user?: {
hasDownloadedResticPassword: boolean; hasDownloadedResticPassword: boolean;
id: number; id: number;
@ -58,6 +62,40 @@ export type LoginResponses = {
export type LoginResponse = LoginResponses[keyof 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 = { export type LogoutData = {
body?: never; body?: never;
path?: never; path?: never;
@ -90,6 +128,8 @@ export type GetMeResponses = {
200: { 200: {
message: string; message: string;
success: boolean; success: boolean;
pendingSessionId?: string;
requiresTwoFactor?: boolean;
user?: { user?: {
hasDownloadedResticPassword: boolean; hasDownloadedResticPassword: boolean;
id: number; id: number;
@ -140,6 +180,130 @@ export type ChangePasswordResponses = {
export type ChangePasswordResponse = ChangePasswordResponses[keyof 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 = { export type ListVolumesData = {
body?: never; body?: never;
path?: never; path?: never;
@ -2164,6 +2328,7 @@ export type GetScheduleNotificationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
accessToken?: string;
password?: string; password?: string;
serverUrl?: string; serverUrl?: string;
username?: string; username?: string;
@ -2263,6 +2428,7 @@ export type UpdateScheduleNotificationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
accessToken?: string;
password?: string; password?: string;
serverUrl?: string; serverUrl?: string;
username?: string; username?: string;
@ -2599,6 +2765,7 @@ export type ListNotificationDestinationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
accessToken?: string;
password?: string; password?: string;
serverUrl?: string; serverUrl?: string;
username?: string; username?: string;
@ -2669,6 +2836,7 @@ export type CreateNotificationDestinationData = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
accessToken?: string;
password?: string; password?: string;
serverUrl?: string; serverUrl?: string;
username?: string; username?: string;
@ -2738,6 +2906,7 @@ export type CreateNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
accessToken?: string;
password?: string; password?: string;
serverUrl?: string; serverUrl?: string;
username?: string; username?: string;
@ -2854,6 +3023,7 @@ export type GetNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
accessToken?: string;
password?: string; password?: string;
serverUrl?: string; serverUrl?: string;
username?: string; username?: string;
@ -2924,6 +3094,7 @@ export type UpdateNotificationDestinationData = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
accessToken?: string;
password?: string; password?: string;
serverUrl?: string; serverUrl?: string;
username?: string; username?: string;
@ -3003,6 +3174,7 @@ export type UpdateNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
accessToken?: string;
password?: string; password?: string;
serverUrl?: string; serverUrl?: string;
username?: string; username?: string;

View file

@ -14,6 +14,8 @@ const badgeVariants = cva(
destructive: 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", "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", 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: { 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 { Button } from "~/client/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { authMiddleware } from "~/middleware/auth"; import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/login"; 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"; import { ResetPasswordDialog } from "../components/reset-password-dialog";
export const clientMiddleware = [authMiddleware]; export const clientMiddleware = [authMiddleware];
@ -36,8 +38,11 @@ type LoginFormValues = typeof loginSchema.inferIn;
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [showResetDialog, setShowResetDialog] = useState(false); 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), resolver: arktypeResolver(loginSchema),
defaultValues: { defaultValues: {
username: "", 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({ const login = useMutation({
...loginMutation(), ...loginMutation(),
onSuccess: async (data) => { onSuccess: async (data) => {
if (data.user && !data.user.hasDownloadedResticPassword) { if (data.requiresTwoFactor && data.pendingSessionId) {
navigate("/download-recovery-key"); setPendingSessionId(data.pendingSessionId);
} else { return;
navigate("/volumes"); }
if (data.user) {
handleLoginSuccess(data.user);
} }
}, },
onError: (error) => { 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({ login.mutate({
body: { body: {
username: values.username.trim(), 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 ( return (
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account"> <AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
<Form {...form}> <Form {...loginForm}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={loginForm.handleSubmit(onLoginSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={loginForm.control}
name="username" name="username"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
@ -87,7 +176,7 @@ export default function LoginPage() {
)} )}
/> />
<FormField <FormField
control={form.control} control={loginForm.control}
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>

View file

@ -1,8 +1,10 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Download, KeyRound, User, X } from "lucide-react"; import { Copy, Download, KeyRound, Shield, User, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { QRCodeSVG } from "qrcode.react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "~/client/components/ui/badge";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import { import {
@ -20,9 +22,15 @@ import { appContext } from "~/context";
import type { Route } from "./+types/settings"; import type { Route } from "./+types/settings";
import { import {
changePasswordMutation, changePasswordMutation,
disable2FaMutation,
downloadResticPasswordMutation, downloadResticPasswordMutation,
enable2FaMutation,
getTwoFactorStatusOptions,
logoutMutation, logoutMutation,
setup2FaMutation,
} from "~/client/api-client/@tanstack/react-query.gen"; } 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 = { export const handle = {
breadcrumb: () => [{ label: "Settings" }], breadcrumb: () => [{ label: "Settings" }],
@ -49,8 +57,21 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [downloadPassword, setDownloadPassword] = useState(""); 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 navigate = useNavigate();
const twoFactorStatus = useQuery({
...getTwoFactorStatusOptions(),
});
const logout = useMutation({ const logout = useMutation({
...logoutMutation(), ...logoutMutation(),
onSuccess: () => { 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) => { const handleChangePassword = (e: React.FormEvent) => {
e.preventDefault(); 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 ( return (
<Card className="p-0 gap-0"> <Card className="p-0 gap-0">
<div className="border-b border-border/50 bg-card-header p-6"> <div className="border-b border-border/50 bg-card-header p-6">
@ -201,6 +341,199 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
</form> </form>
</CardContent> </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"> <div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Download className="size-5" /> <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", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1755765658194, "when": 1755765658194,
"tag": "0000_known_madelyne_pryor", "tag": "0000_known_madelyne_pryor",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "6", "version": "6",
"when": 1755775437391, "when": 1755775437391,
"tag": "0001_far_frank_castle", "tag": "0001_far_frank_castle",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "6", "version": "6",
"when": 1756930554198, "when": 1756930554198,
"tag": "0002_cheerful_randall", "tag": "0002_cheerful_randall",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 3, "idx": 3,
"version": "6", "version": "6",
"when": 1758653407064, "when": 1758653407064,
"tag": "0003_mature_hellcat", "tag": "0003_mature_hellcat",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 4, "idx": 4,
"version": "6", "version": "6",
"when": 1758961535488, "when": 1758961535488,
"tag": "0004_wealthy_tomas", "tag": "0004_wealthy_tomas",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 5, "idx": 5,
"version": "6", "version": "6",
"when": 1759416698274, "when": 1759416698274,
"tag": "0005_simple_alice", "tag": "0005_simple_alice",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 6, "idx": 6,
"version": "6", "version": "6",
"when": 1760734377440, "when": 1760734377440,
"tag": "0006_secret_micromacro", "tag": "0006_secret_micromacro",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 7, "idx": 7,
"version": "6", "version": "6",
"when": 1761224911352, "when": 1761224911352,
"tag": "0007_watery_sersi", "tag": "0007_watery_sersi",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 8, "idx": 8,
"version": "6", "version": "6",
"when": 1761414054481, "when": 1761414054481,
"tag": "0008_silent_lady_bullseye", "tag": "0008_silent_lady_bullseye",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 9, "idx": 9,
"version": "6", "version": "6",
"when": 1762095226041, "when": 1762095226041,
"tag": "0009_little_adam_warlock", "tag": "0009_little_adam_warlock",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 10, "idx": 10,
"version": "6", "version": "6",
"when": 1762610065889, "when": 1762610065889,
"tag": "0010_perfect_proemial_gods", "tag": "0010_perfect_proemial_gods",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 11, "idx": 11,
"version": "6", "version": "6",
"when": 1763644043601, "when": 1763644043601,
"tag": "0011_familiar_stone_men", "tag": "0011_familiar_stone_men",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 12, "idx": 12,
"version": "6", "version": "6",
"when": 1764100562084, "when": 1764100562084,
"tag": "0012_add_short_ids", "tag": "0012_add_short_ids",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 13, "idx": 13,
"version": "6", "version": "6",
"when": 1764182159797, "when": 1764182159797,
"tag": "0013_elite_sprite", "tag": "0013_elite_sprite",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 14, "idx": 14,
"version": "6", "version": "6",
"when": 1764182405089, "when": 1764182405089,
"tag": "0014_wild_echo", "tag": "0014_wild_echo",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 15, "idx": 15,
"version": "6", "version": "6",
"when": 1764182465287, "when": 1764182465287,
"tag": "0015_jazzy_sersi", "tag": "0015_jazzy_sersi",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 16, "idx": 16,
"version": "6", "version": "6",
"when": 1764194697035, "when": 1764194697035,
"tag": "0016_fix-timestamps-to-ms", "tag": "0016_fix-timestamps-to-ms",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 17, "idx": 17,
"version": "6", "version": "6",
"when": 1764357897219, "when": 1764357897219,
"tag": "0017_fix-compression-modes", "tag": "0017_fix-compression-modes",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 18, "idx": 18,
"version": "6", "version": "6",
"when": 1764794371040, "when": 1764794371040,
"tag": "0018_breezy_invaders", "tag": "0018_breezy_invaders",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 19, "idx": 19,
"version": "6", "version": "6",
"when": 1764839917446, "when": 1764839917446,
"tag": "0019_secret_nomad", "tag": "0019_secret_nomad",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 20, "idx": 20,
"version": "6", "version": "6",
"when": 1764847918249, "when": 1764847918249,
"tag": "0020_even_dexter_bennett", "tag": "0020_even_dexter_bennett",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 21, "idx": 21,
"version": "6", "version": "6",
"when": 1765307881092, "when": 1765307881092,
"tag": "0021_steady_viper", "tag": "0021_steady_viper",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 22, "idx": 22,
"version": "6", "version": "6",
"when": 1765794552191, "when": 1765794552191,
"tag": "0022_woozy_shen", "tag": "0022_woozy_shen",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 23, "idx": 23,
"version": "6", "version": "6",
"when": 1766320570509, "when": 1766320570509,
"tag": "0023_special_thor", "tag": "0023_special_thor",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 24, "idx": 24,
"version": "6", "version": "6",
"when": 1766325504548, "when": 1766325504548,
"tag": "0024_schedules-one-fs", "tag": "0024_schedules-one-fs",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 25, "idx": 25,
"version": "6", "version": "6",
"when": 1766431021321, "when": 1766431021321,
"tag": "0025_remarkable_pete_wisdom", "tag": "0025_remarkable_pete_wisdom",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 26, "idx": 26,
"version": "6", "version": "6",
"when": 1766765013108, "when": 1766765013108,
"tag": "0026_migrate-local-repo-paths", "tag": "0026_migrate-local-repo-paths",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 27, "idx": 27,
"version": "6", "version": "6",
"when": 1766778073418, "when": 1766778073418,
"tag": "0027_careful_cammi", "tag": "0027_careful_cammi",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 28, "idx": 28,
"version": "6", "version": "6",
"when": 1766778162985, "when": 1766778162985,
"tag": "0028_third_amazoness", "tag": "0028_third_amazoness",
"breakpoints": true "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 { Command } from "commander";
import { resetPasswordCommand } from "./commands/reset-password"; import { resetPasswordCommand } from "./commands/reset-password";
import { twoFactorCommand } from "./commands/2fa";
const program = new Command(); const program = new Command();
program.name("zerobyte").description("Zerobyte CLI - Backup automation tool built on top of Restic").version("1.0.0"); program.name("zerobyte").description("Zerobyte CLI - Backup automation tool built on top of Restic").version("1.0.0");
program.addCommand(resetPasswordCommand); program.addCommand(resetPasswordCommand);
program.addCommand(twoFactorCommand);
export async function runCLI(argv: string[]): Promise<boolean> { export async function runCLI(argv: string[]): Promise<boolean> {
const args = argv.slice(2); const args = argv.slice(2);

View file

@ -31,6 +31,8 @@ export const usersTable = sqliteTable("users_table", {
username: text().notNull().unique(), username: text().notNull().unique(),
passwordHash: text("password_hash").notNull(), passwordHash: text("password_hash").notNull(),
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false), 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)`), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_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 { import {
changePasswordBodySchema, changePasswordBodySchema,
changePasswordDto, changePasswordDto,
disable2faBodySchema,
disable2faDto,
enable2faBodySchema,
enable2faDto,
getMeDto, getMeDto,
getStatusDto, getStatusDto,
getTwoFactorStatusDto,
loginBodySchema, loginBodySchema,
loginDto, loginDto,
logoutDto, logoutDto,
registerBodySchema, registerBodySchema,
registerDto, registerDto,
setup2faDto,
verify2faBodySchema,
verify2faDto,
type ChangePasswordDto, type ChangePasswordDto,
type Disable2faDto,
type Enable2faDto,
type GetMeDto, type GetMeDto,
type GetStatusDto, type GetStatusDto,
type GetTwoFactorStatusDto,
type LoginDto, type LoginDto,
type LogoutDto, type LogoutDto,
type RegisterDto, type RegisterDto,
type Setup2faDto,
type Verify2faDto,
} from "./auth.dto"; } from "./auth.dto";
import { authService } from "./auth.service"; import { authService } from "./auth.service";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
@ -72,14 +85,47 @@ export const authController = new Hono()
const body = c.req.valid("json"); const body = c.req.valid("json");
try { 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, { setCookie(c, COOKIE_NAME, sessionId, {
...COOKIE_OPTIONS, ...COOKIE_OPTIONS,
expires: new Date(expiresAt), expires: new Date(expiresAt),
}); });
return c.json<LoginDto>({ return c.json<Verify2faDto>({
success: true, success: true,
message: "Login successful", message: "Login successful",
user: { user: {
@ -89,7 +135,7 @@ export const authController = new Hono()
}, },
}); });
} catch (error) { } 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) => { .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); 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", username: "string",
hasDownloadedResticPassword: "boolean", hasDownloadedResticPassword: "boolean",
}).optional(), }).optional(),
requiresTwoFactor: "boolean?",
pendingSessionId: "string?",
}); });
export const loginDto = describeRoute({ export const loginDto = describeRoute({
@ -148,6 +150,174 @@ export const changePasswordDto = describeRoute({
export type ChangePasswordDto = typeof changePasswordResponseSchema.infer; 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 LoginBody = typeof loginBodySchema.infer;
export type RegisterBody = typeof registerBodySchema.infer; export type RegisterBody = typeof registerBodySchema.infer;
export type ChangePasswordBody = typeof changePasswordBodySchema.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 { eq, lt } from "drizzle-orm";
import { db } from "../../db/db"; import { db } from "../../db/db";
import { sessionsTable, usersTable } from "../../db/schema"; import { sessionsTable, usersTable } from "../../db/schema";
import { cryptoUtils } from "../../utils/crypto";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import * as OTPAuth from "otpauth";
const SESSION_DURATION = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds 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 { export class AuthService {
/** /**
@ -51,6 +61,7 @@ export class AuthService {
/** /**
* Login user with username and password * Login user with username and password
* Returns requiresTwoFactor: true if 2FA is enabled, with a pendingSessionId
*/ */
async login(username: string, password: string) { async login(username: string, password: string) {
const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username)); const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username));
@ -65,6 +76,26 @@ export class AuthService {
throw new Error("Invalid credentials"); 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 sessionId = crypto.randomUUID();
const expiresAt = Date.now() + SESSION_DURATION; const expiresAt = Date.now() + SESSION_DURATION;
@ -77,6 +108,7 @@ export class AuthService {
logger.info(`User logged in: ${username}`); logger.info(`User logged in: ${username}`);
return { return {
requiresTwoFactor: false as const,
sessionId, sessionId,
user: { user: {
id: user.id, 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 * Logout user by deleting their session
*/ */
@ -174,6 +296,157 @@ export class AuthService {
logger.info(`Password changed for user: ${user.username}`); 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(); export const authService = new AuthService();

View file

@ -45,6 +45,8 @@
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"otpauth": "^9.4.1",
"qrcode.react": "^4.2.0",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"react-hook-form": "^7.68.0", "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=="], "@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/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=="], "@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=="], "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=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "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=="], "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=="], "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=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],

View file

@ -63,6 +63,8 @@
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"otpauth": "^9.4.1",
"qrcode.react": "^4.2.0",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"react-hook-form": "^7.68.0", "react-hook-form": "^7.68.0",