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:
parent
f836e2f9df
commit
a4d0c7b0c9
19 changed files with 2533 additions and 228 deletions
11
README.md
11
README.md
|
|
@ -32,6 +32,7 @@ Zerobyte is a backup automation tool that helps you save your data across multip
|
|||
- **Flexible scheduling** For automated backup jobs with fine-grained retention policies
|
||||
- **End-to-end encryption** ensuring your data is always protected
|
||||
- **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories
|
||||
- **Two-factor authentication** (TOTP) for enhanced account security
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -70,6 +71,16 @@ docker compose up -d
|
|||
|
||||
Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`.
|
||||
|
||||
### Two-Factor Authentication
|
||||
|
||||
Zerobyte supports TOTP-based two-factor authentication for enhanced account security. You can enable 2FA from the Settings page in the web interface using any authenticator app (Google Authenticator, Authy, 1Password, etc.).
|
||||
|
||||
**Recovery:** If you lose access to your authenticator app, you can disable 2FA via the CLI:
|
||||
|
||||
```bash
|
||||
docker exec -it zerobyte bun run cli 2fa disable -u <username>
|
||||
```
|
||||
|
||||
### Simplified setup (No remote mounts)
|
||||
|
||||
If you only need to back up locally mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`:
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||
|
||||
import { client } from '../client.gen';
|
||||
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||
import { browseFilesystem, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, disable2Fa, doctorRepository, downloadResticPassword, enable2Fa, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getTwoFactorStatus, getVolume, healthCheckVolume, changePassword, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setup2Fa, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume, verify2Fa } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, Disable2FaData, Disable2FaResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, Enable2FaData, Enable2FaResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetTwoFactorStatusData, GetTwoFactorStatusResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ChangePasswordData, ChangePasswordResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, Setup2FaData, Setup2FaResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse, Verify2FaData, Verify2FaResponse } from '../types.gen';
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
|
|
@ -40,6 +40,23 @@ export const loginMutation = (options?: Partial<Options<LoginData>>): UseMutatio
|
|||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify 2FA code and complete login
|
||||
*/
|
||||
export const verify2FaMutation = (options?: Partial<Options<Verify2FaData>>): UseMutationOptions<Verify2FaResponse, DefaultError, Options<Verify2FaData>> => {
|
||||
const mutationOptions: UseMutationOptions<Verify2FaResponse, DefaultError, Options<Verify2FaData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await verify2Fa({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout current user
|
||||
*/
|
||||
|
|
@ -143,6 +160,75 @@ export const changePasswordMutation = (options?: Partial<Options<ChangePasswordD
|
|||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getTwoFactorStatusQueryKey = (options?: Options<GetTwoFactorStatusData>) => createQueryKey('getTwoFactorStatus', options);
|
||||
|
||||
/**
|
||||
* Get 2FA status for current authenticated user
|
||||
*/
|
||||
export const getTwoFactorStatusOptions = (options?: Options<GetTwoFactorStatusData>) => queryOptions<GetTwoFactorStatusResponse, DefaultError, GetTwoFactorStatusResponse, ReturnType<typeof getTwoFactorStatusQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getTwoFactorStatus({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getTwoFactorStatusQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate 2FA setup data (secret and otpauth URI for QR code)
|
||||
*/
|
||||
export const setup2FaMutation = (options?: Partial<Options<Setup2FaData>>): UseMutationOptions<Setup2FaResponse, DefaultError, Options<Setup2FaData>> => {
|
||||
const mutationOptions: UseMutationOptions<Setup2FaResponse, DefaultError, Options<Setup2FaData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await setup2Fa({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify 2FA code and enable 2FA for the account
|
||||
*/
|
||||
export const enable2FaMutation = (options?: Partial<Options<Enable2FaData>>): UseMutationOptions<Enable2FaResponse, DefaultError, Options<Enable2FaData>> => {
|
||||
const mutationOptions: UseMutationOptions<Enable2FaResponse, DefaultError, Options<Enable2FaData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await enable2Fa({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Disable 2FA for the account (requires password and current TOTP code)
|
||||
*/
|
||||
export const disable2FaMutation = (options?: Partial<Options<Disable2FaData>>): UseMutationOptions<Disable2FaResponse, DefaultError, Options<Disable2FaData>> => {
|
||||
const mutationOptions: UseMutationOptions<Disable2FaResponse, DefaultError, Options<Disable2FaData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await disable2Fa({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, Disable2FaData, Disable2FaErrors, Disable2FaResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, Enable2FaData, Enable2FaErrors, Enable2FaResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetTwoFactorStatusData, GetTwoFactorStatusErrors, GetTwoFactorStatusResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ChangePasswordData, ChangePasswordResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, Setup2FaData, Setup2FaErrors, Setup2FaResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses, Verify2FaData, Verify2FaErrors, Verify2FaResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
|
|
@ -42,6 +42,18 @@ export const login = <ThrowOnError extends boolean = false>(options?: Options<Lo
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify 2FA code and complete login
|
||||
*/
|
||||
export const verify2Fa = <ThrowOnError extends boolean = false>(options?: Options<Verify2FaData, ThrowOnError>) => (options?.client ?? client).post<Verify2FaResponses, Verify2FaErrors, ThrowOnError>({
|
||||
url: '/api/v1/auth/verify-2fa',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Logout current user
|
||||
*/
|
||||
|
|
@ -69,6 +81,40 @@ export const changePassword = <ThrowOnError extends boolean = false>(options?: O
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get 2FA status for current authenticated user
|
||||
*/
|
||||
export const getTwoFactorStatus = <ThrowOnError extends boolean = false>(options?: Options<GetTwoFactorStatusData, ThrowOnError>) => (options?.client ?? client).get<GetTwoFactorStatusResponses, GetTwoFactorStatusErrors, ThrowOnError>({ url: '/api/v1/auth/2fa-status', ...options });
|
||||
|
||||
/**
|
||||
* Generate 2FA setup data (secret and otpauth URI for QR code)
|
||||
*/
|
||||
export const setup2Fa = <ThrowOnError extends boolean = false>(options?: Options<Setup2FaData, ThrowOnError>) => (options?.client ?? client).post<Setup2FaResponses, Setup2FaErrors, ThrowOnError>({ url: '/api/v1/auth/2fa/setup', ...options });
|
||||
|
||||
/**
|
||||
* Verify 2FA code and enable 2FA for the account
|
||||
*/
|
||||
export const enable2Fa = <ThrowOnError extends boolean = false>(options?: Options<Enable2FaData, ThrowOnError>) => (options?.client ?? client).post<Enable2FaResponses, Enable2FaErrors, ThrowOnError>({
|
||||
url: '/api/v1/auth/2fa/enable',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Disable 2FA for the account (requires password and current TOTP code)
|
||||
*/
|
||||
export const disable2Fa = <ThrowOnError extends boolean = false>(options?: Options<Disable2FaData, ThrowOnError>) => (options?.client ?? client).post<Disable2FaResponses, Disable2FaErrors, ThrowOnError>({
|
||||
url: '/api/v1/auth/2fa/disable',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List all volumes
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export type RegisterResponses = {
|
|||
201: {
|
||||
message: string;
|
||||
success: boolean;
|
||||
pendingSessionId?: string;
|
||||
requiresTwoFactor?: boolean;
|
||||
user?: {
|
||||
hasDownloadedResticPassword: boolean;
|
||||
id: number;
|
||||
|
|
@ -48,6 +50,8 @@ export type LoginResponses = {
|
|||
200: {
|
||||
message: string;
|
||||
success: boolean;
|
||||
pendingSessionId?: string;
|
||||
requiresTwoFactor?: boolean;
|
||||
user?: {
|
||||
hasDownloadedResticPassword: boolean;
|
||||
id: number;
|
||||
|
|
@ -58,6 +62,40 @@ export type LoginResponses = {
|
|||
|
||||
export type LoginResponse = LoginResponses[keyof LoginResponses];
|
||||
|
||||
export type Verify2FaData = {
|
||||
body?: {
|
||||
code: string;
|
||||
pendingSessionId: string;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/auth/verify-2fa';
|
||||
};
|
||||
|
||||
export type Verify2FaErrors = {
|
||||
/**
|
||||
* Invalid 2FA code or session
|
||||
*/
|
||||
401: unknown;
|
||||
};
|
||||
|
||||
export type Verify2FaResponses = {
|
||||
/**
|
||||
* 2FA verification successful
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
success: boolean;
|
||||
user?: {
|
||||
hasDownloadedResticPassword: boolean;
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type Verify2FaResponse = Verify2FaResponses[keyof Verify2FaResponses];
|
||||
|
||||
export type LogoutData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
|
@ -90,6 +128,8 @@ export type GetMeResponses = {
|
|||
200: {
|
||||
message: string;
|
||||
success: boolean;
|
||||
pendingSessionId?: string;
|
||||
requiresTwoFactor?: boolean;
|
||||
user?: {
|
||||
hasDownloadedResticPassword: boolean;
|
||||
id: number;
|
||||
|
|
@ -140,6 +180,130 @@ export type ChangePasswordResponses = {
|
|||
|
||||
export type ChangePasswordResponse = ChangePasswordResponses[keyof ChangePasswordResponses];
|
||||
|
||||
export type GetTwoFactorStatusData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/auth/2fa-status';
|
||||
};
|
||||
|
||||
export type GetTwoFactorStatusErrors = {
|
||||
/**
|
||||
* Not authenticated
|
||||
*/
|
||||
401: unknown;
|
||||
};
|
||||
|
||||
export type GetTwoFactorStatusResponses = {
|
||||
/**
|
||||
* 2FA status
|
||||
*/
|
||||
200: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetTwoFactorStatusResponse = GetTwoFactorStatusResponses[keyof GetTwoFactorStatusResponses];
|
||||
|
||||
export type Setup2FaData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/auth/2fa/setup';
|
||||
};
|
||||
|
||||
export type Setup2FaErrors = {
|
||||
/**
|
||||
* 2FA already enabled or other error
|
||||
*/
|
||||
400: unknown;
|
||||
/**
|
||||
* Not authenticated
|
||||
*/
|
||||
401: unknown;
|
||||
};
|
||||
|
||||
export type Setup2FaResponses = {
|
||||
/**
|
||||
* 2FA setup data
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
success: boolean;
|
||||
secret?: string;
|
||||
uri?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Setup2FaResponse = Setup2FaResponses[keyof Setup2FaResponses];
|
||||
|
||||
export type Enable2FaData = {
|
||||
body?: {
|
||||
code: string;
|
||||
password: string;
|
||||
secret: string;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/auth/2fa/enable';
|
||||
};
|
||||
|
||||
export type Enable2FaErrors = {
|
||||
/**
|
||||
* Invalid code or 2FA already enabled
|
||||
*/
|
||||
400: unknown;
|
||||
/**
|
||||
* Not authenticated
|
||||
*/
|
||||
401: unknown;
|
||||
};
|
||||
|
||||
export type Enable2FaResponses = {
|
||||
/**
|
||||
* 2FA enabled successfully
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type Enable2FaResponse = Enable2FaResponses[keyof Enable2FaResponses];
|
||||
|
||||
export type Disable2FaData = {
|
||||
body?: {
|
||||
code: string;
|
||||
password: string;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/auth/2fa/disable';
|
||||
};
|
||||
|
||||
export type Disable2FaErrors = {
|
||||
/**
|
||||
* Invalid password or code, or 2FA not enabled
|
||||
*/
|
||||
400: unknown;
|
||||
/**
|
||||
* Not authenticated
|
||||
*/
|
||||
401: unknown;
|
||||
};
|
||||
|
||||
export type Disable2FaResponses = {
|
||||
/**
|
||||
* 2FA disabled successfully
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type Disable2FaResponse = Disable2FaResponses[keyof Disable2FaResponses];
|
||||
|
||||
export type ListVolumesData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
|
@ -2164,6 +2328,7 @@ export type GetScheduleNotificationsResponses = {
|
|||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2263,6 +2428,7 @@ export type UpdateScheduleNotificationsResponses = {
|
|||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2599,6 +2765,7 @@ export type ListNotificationDestinationsResponses = {
|
|||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2669,6 +2836,7 @@ export type CreateNotificationDestinationData = {
|
|||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2738,6 +2906,7 @@ export type CreateNotificationDestinationResponses = {
|
|||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2854,6 +3023,7 @@ export type GetNotificationDestinationResponses = {
|
|||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -2924,6 +3094,7 @@ export type UpdateNotificationDestinationData = {
|
|||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
@ -3003,6 +3174,7 @@ export type UpdateNotificationDestinationResponses = {
|
|||
priority: 'default' | 'high' | 'low' | 'max' | 'min';
|
||||
topic: string;
|
||||
type: 'ntfy';
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ const badgeVariants = cva(
|
|||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
success:
|
||||
"border-transparent bg-green-500 text-white [a&]:hover:bg-green-600 focus-visible:ring-green-500/20 dark:focus-visible:ring-green-500/40 dark:bg-green-600",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
|
|||
42
app/client/modules/auth/components/reset-2fa-dialog.tsx
Normal file
42
app/client/modules/auth/components/reset-2fa-dialog.tsx
Normal 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"><username></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>
|
||||
);
|
||||
};
|
||||
|
|
@ -9,9 +9,11 @@ import { AuthLayout } from "~/client/components/auth-layout";
|
|||
import { Button } from "~/client/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/login";
|
||||
import { loginMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { loginMutation, verify2FaMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Reset2faDialog } from "../components/reset-2fa-dialog";
|
||||
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
|
@ -36,8 +38,11 @@ type LoginFormValues = typeof loginSchema.inferIn;
|
|||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||
const [showReset2faDialog, setShowReset2faDialog] = useState(false);
|
||||
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
|
||||
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||
|
||||
const form = useForm<LoginFormValues>({
|
||||
const loginForm = useForm<LoginFormValues>({
|
||||
resolver: arktypeResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
|
|
@ -45,13 +50,23 @@ export default function LoginPage() {
|
|||
},
|
||||
});
|
||||
|
||||
const handleLoginSuccess = (user: { hasDownloadedResticPassword: boolean }) => {
|
||||
if (!user.hasDownloadedResticPassword) {
|
||||
navigate("/download-recovery-key");
|
||||
} else {
|
||||
navigate("/volumes");
|
||||
}
|
||||
};
|
||||
|
||||
const login = useMutation({
|
||||
...loginMutation(),
|
||||
onSuccess: async (data) => {
|
||||
if (data.user && !data.user.hasDownloadedResticPassword) {
|
||||
navigate("/download-recovery-key");
|
||||
} else {
|
||||
navigate("/volumes");
|
||||
if (data.requiresTwoFactor && data.pendingSessionId) {
|
||||
setPendingSessionId(data.pendingSessionId);
|
||||
return;
|
||||
}
|
||||
if (data.user) {
|
||||
handleLoginSuccess(data.user);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
@ -60,7 +75,20 @@ export default function LoginPage() {
|
|||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: LoginFormValues) => {
|
||||
const verify2fa = useMutation({
|
||||
...verify2FaMutation(),
|
||||
onSuccess: (data) => {
|
||||
if (data.user) {
|
||||
handleLoginSuccess(data.user);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error("Verification failed", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const onLoginSubmit = (values: LoginFormValues) => {
|
||||
login.mutate({
|
||||
body: {
|
||||
username: values.username.trim(),
|
||||
|
|
@ -69,12 +97,73 @@ export default function LoginPage() {
|
|||
});
|
||||
};
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
setPendingSessionId(null);
|
||||
setTwoFactorCode("");
|
||||
};
|
||||
|
||||
const handleTwoFactorSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!pendingSessionId || twoFactorCode.length !== 6) return;
|
||||
verify2fa.mutate({
|
||||
body: {
|
||||
pendingSessionId,
|
||||
code: twoFactorCode.trim(),
|
||||
},
|
||||
} as Parameters<typeof verify2fa.mutate>[0]);
|
||||
};
|
||||
|
||||
// Show 2FA verification form
|
||||
if (pendingSessionId) {
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Two-Factor Authentication"
|
||||
description="Enter the 6-digit code from your authenticator app"
|
||||
>
|
||||
<form onSubmit={handleTwoFactorSubmit} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="2fa-code">Authentication Code</Label>
|
||||
<Input
|
||||
id="2fa-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
disabled={verify2fa.isPending}
|
||||
autoFocus
|
||||
autoComplete="one-time-code"
|
||||
className="font-mono tracking-widest"
|
||||
value={twoFactorCode}
|
||||
onChange={(e) => setTwoFactorCode(e.target.value.replace(/\D/g, ""))}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" loading={verify2fa.isPending} disabled={twoFactorCode.length !== 6}>
|
||||
Verify
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" className="w-full" onClick={handleBackToLogin}>
|
||||
Back to Login
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
onClick={() => setShowReset2faDialog(true)}
|
||||
>
|
||||
Lost access to your authenticator?
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<Reset2faDialog open={showReset2faDialog} onOpenChange={setShowReset2faDialog} />
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Form {...loginForm}>
|
||||
<form onSubmit={loginForm.handleSubmit(onLoginSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
control={loginForm.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
|
|
@ -87,7 +176,7 @@ export default function LoginPage() {
|
|||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
control={loginForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Download, KeyRound, User, X } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Copy, Download, KeyRound, Shield, User, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/client/components/ui/badge";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
||||
import {
|
||||
|
|
@ -20,9 +22,15 @@ import { appContext } from "~/context";
|
|||
import type { Route } from "./+types/settings";
|
||||
import {
|
||||
changePasswordMutation,
|
||||
disable2FaMutation,
|
||||
downloadResticPasswordMutation,
|
||||
enable2FaMutation,
|
||||
getTwoFactorStatusOptions,
|
||||
logoutMutation,
|
||||
setup2FaMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import type { Disable2FaResponse, Enable2FaResponse, Setup2FaResponse } from "~/client/api-client/types.gen";
|
||||
import { copyToClipboard } from "~/utils/clipboard";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Settings" }],
|
||||
|
|
@ -49,8 +57,21 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||
const [downloadPassword, setDownloadPassword] = useState("");
|
||||
const [enable2faDialogOpen, setEnable2faDialogOpen] = useState(false);
|
||||
const [enable2faStep, setEnable2faStep] = useState<"setup" | "verify">("setup");
|
||||
const [enable2faPassword, setEnable2faPassword] = useState("");
|
||||
const [enable2faSecret, setEnable2faSecret] = useState("");
|
||||
const [enable2faUri, setEnable2faUri] = useState("");
|
||||
const [enable2faCode, setEnable2faCode] = useState("");
|
||||
const [disable2faDialogOpen, setDisable2faDialogOpen] = useState(false);
|
||||
const [disable2faPassword, setDisable2faPassword] = useState("");
|
||||
const [disable2faCode, setDisable2faCode] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const twoFactorStatus = useQuery({
|
||||
...getTwoFactorStatusOptions(),
|
||||
});
|
||||
|
||||
const logout = useMutation({
|
||||
...logoutMutation(),
|
||||
onSuccess: () => {
|
||||
|
|
@ -97,6 +118,73 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
},
|
||||
});
|
||||
|
||||
const setup2fa = useMutation({
|
||||
...setup2FaMutation(),
|
||||
onSuccess: (data: Setup2FaResponse) => {
|
||||
if (data.success && data.uri && data.secret) {
|
||||
setEnable2faUri(data.uri);
|
||||
setEnable2faSecret(data.secret);
|
||||
setEnable2faStep("verify");
|
||||
} else {
|
||||
toast.error("Failed to setup 2FA", { description: data.message });
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to setup 2FA", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const enable2fa = useMutation({
|
||||
...enable2FaMutation(),
|
||||
onSuccess: (data: Enable2FaResponse) => {
|
||||
if (data.success) {
|
||||
toast.success("2FA enabled successfully. Please log in again.");
|
||||
setEnable2faDialogOpen(false);
|
||||
resetEnable2faDialog();
|
||||
setTimeout(() => {
|
||||
navigate("/login", { replace: true });
|
||||
}, 1500);
|
||||
} else {
|
||||
toast.error("Failed to enable 2FA", { description: data.message });
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to enable 2FA", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const disable2fa = useMutation({
|
||||
...disable2FaMutation(),
|
||||
onSuccess: (data: Disable2FaResponse) => {
|
||||
if (data.success) {
|
||||
toast.success("2FA disabled successfully. Please log in again.");
|
||||
setDisable2faDialogOpen(false);
|
||||
resetDisable2faDialog();
|
||||
setTimeout(() => {
|
||||
navigate("/login", { replace: true });
|
||||
}, 1500);
|
||||
} else {
|
||||
toast.error("Failed to disable 2FA", { description: data.message });
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to disable 2FA", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const resetEnable2faDialog = () => {
|
||||
setEnable2faStep("setup");
|
||||
setEnable2faPassword("");
|
||||
setEnable2faSecret("");
|
||||
setEnable2faUri("");
|
||||
setEnable2faCode("");
|
||||
};
|
||||
|
||||
const resetDisable2faDialog = () => {
|
||||
setDisable2faPassword("");
|
||||
setDisable2faCode("");
|
||||
};
|
||||
|
||||
const handleChangePassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -133,6 +221,58 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
});
|
||||
};
|
||||
|
||||
const handleStartEnable2fa = () => {
|
||||
setup2fa.mutate({});
|
||||
};
|
||||
|
||||
const handleConfirmEnable2fa = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!enable2faPassword) {
|
||||
toast.error("Password is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enable2faCode || enable2faCode.length !== 6) {
|
||||
toast.error("Please enter a valid 6-digit code");
|
||||
return;
|
||||
}
|
||||
|
||||
enable2fa.mutate({
|
||||
body: {
|
||||
password: enable2faPassword,
|
||||
secret: enable2faSecret,
|
||||
code: enable2faCode,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisable2fa = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!disable2faPassword) {
|
||||
toast.error("Password is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!disable2faCode || disable2faCode.length !== 6) {
|
||||
toast.error("Please enter a valid 6-digit code");
|
||||
return;
|
||||
}
|
||||
|
||||
disable2fa.mutate({
|
||||
body: {
|
||||
password: disable2faPassword,
|
||||
code: disable2faCode,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopySecret = () => {
|
||||
copyToClipboard(enable2faSecret);
|
||||
toast.success("Secret key copied to clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-0 gap-0">
|
||||
<div className="border-b border-border/50 bg-card-header p-6">
|
||||
|
|
@ -201,6 +341,199 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
</form>
|
||||
</CardContent>
|
||||
|
||||
{/* Two-Factor Authentication Section */}
|
||||
<div className="border-t border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="size-5" />
|
||||
Two-Factor Authentication
|
||||
{twoFactorStatus.data && (
|
||||
<Badge variant={twoFactorStatus.data.enabled ? "success" : "secondary"} className="ml-2">
|
||||
{twoFactorStatus.data.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Add an extra layer of security to your account</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground max-w-2xl mb-4">
|
||||
Two-factor authentication adds an additional layer of security by requiring a time-based code from your
|
||||
authenticator app when logging in.
|
||||
</p>
|
||||
|
||||
{twoFactorStatus.data?.enabled ? (
|
||||
<Dialog
|
||||
open={disable2faDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDisable2faDialogOpen(open);
|
||||
if (!open) resetDisable2faDialog();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Shield size={16} className="mr-2" />
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleDisable2fa}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
|
||||
<DialogDescription>
|
||||
To disable 2FA, please enter your password and a code from your authenticator app.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-2fa-password">Your Password</Label>
|
||||
<Input
|
||||
id="disable-2fa-password"
|
||||
type="password"
|
||||
value={disable2faPassword}
|
||||
onChange={(e) => setDisable2faPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-2fa-code">Authenticator Code</Label>
|
||||
<Input
|
||||
id="disable-2fa-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={disable2faCode}
|
||||
onChange={(e) => setDisable2faCode(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="000000"
|
||||
required
|
||||
className="font-mono tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDisable2faDialogOpen(false);
|
||||
resetDisable2faDialog();
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" loading={disable2fa.isPending}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Dialog
|
||||
open={enable2faDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setEnable2faDialogOpen(open);
|
||||
if (!open) resetEnable2faDialog();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" onClick={handleStartEnable2fa}>
|
||||
<Shield size={16} className="mr-2" />
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{enable2faStep === "setup" ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Setting up 2FA...</DialogTitle>
|
||||
<DialogDescription>Please wait while we generate your secret key.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={handleConfirmEnable2fa}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Two-Factor Authentication</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan the QR code with your authenticator app (like Google Authenticator, Authy, or 1Password),
|
||||
then enter the 6-digit code to verify.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-4 bg-white rounded-lg">
|
||||
<QRCodeSVG value={enable2faUri} size={192} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Manual Entry Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={enable2faSecret} readOnly className="font-mono text-sm" />
|
||||
<Button type="button" variant="outline" size="icon" onClick={handleCopySecret}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If you can't scan the QR code, enter this key manually in your authenticator app.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="enable-2fa-password">Your Password</Label>
|
||||
<Input
|
||||
id="enable-2fa-password"
|
||||
type="password"
|
||||
value={enable2faPassword}
|
||||
onChange={(e) => setEnable2faPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="enable-2fa-code">Verification Code</Label>
|
||||
<Input
|
||||
id="enable-2fa-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={enable2faCode}
|
||||
onChange={(e) => setEnable2faCode(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="000000"
|
||||
required
|
||||
className="font-mono tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEnable2faDialogOpen(false);
|
||||
resetEnable2faDialog();
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={enable2fa.isPending}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="size-5" />
|
||||
|
|
|
|||
2
app/drizzle/0029_demonic_namor.sql
Normal file
2
app/drizzle/0029_demonic_namor.sql
Normal 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;
|
||||
861
app/drizzle/meta/0029_snapshot.json
Normal file
861
app/drizzle/meta/0029_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +1,216 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1755765658194,
|
||||
"tag": "0000_known_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1755775437391,
|
||||
"tag": "0001_far_frank_castle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1756930554198,
|
||||
"tag": "0002_cheerful_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1758653407064,
|
||||
"tag": "0003_mature_hellcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1758961535488,
|
||||
"tag": "0004_wealthy_tomas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1759416698274,
|
||||
"tag": "0005_simple_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1760734377440,
|
||||
"tag": "0006_secret_micromacro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1761224911352,
|
||||
"tag": "0007_watery_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1761414054481,
|
||||
"tag": "0008_silent_lady_bullseye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1762095226041,
|
||||
"tag": "0009_little_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1763644043601,
|
||||
"tag": "0011_familiar_stone_men",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1764100562084,
|
||||
"tag": "0012_add_short_ids",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1764182159797,
|
||||
"tag": "0013_elite_sprite",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1764182405089,
|
||||
"tag": "0014_wild_echo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1764182465287,
|
||||
"tag": "0015_jazzy_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1764194697035,
|
||||
"tag": "0016_fix-timestamps-to-ms",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1764357897219,
|
||||
"tag": "0017_fix-compression-modes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1764794371040,
|
||||
"tag": "0018_breezy_invaders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1764839917446,
|
||||
"tag": "0019_secret_nomad",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1764847918249,
|
||||
"tag": "0020_even_dexter_bennett",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "6",
|
||||
"when": 1765307881092,
|
||||
"tag": "0021_steady_viper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "6",
|
||||
"when": 1765794552191,
|
||||
"tag": "0022_woozy_shen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "6",
|
||||
"when": 1766320570509,
|
||||
"tag": "0023_special_thor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "6",
|
||||
"when": 1766325504548,
|
||||
"tag": "0024_schedules-one-fs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "6",
|
||||
"when": 1766431021321,
|
||||
"tag": "0025_remarkable_pete_wisdom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "6",
|
||||
"when": 1766765013108,
|
||||
"tag": "0026_migrate-local-repo-paths",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "6",
|
||||
"when": 1766778073418,
|
||||
"tag": "0027_careful_cammi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "6",
|
||||
"when": 1766778162985,
|
||||
"tag": "0028_third_amazoness",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1755765658194,
|
||||
"tag": "0000_known_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1755775437391,
|
||||
"tag": "0001_far_frank_castle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1756930554198,
|
||||
"tag": "0002_cheerful_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1758653407064,
|
||||
"tag": "0003_mature_hellcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1758961535488,
|
||||
"tag": "0004_wealthy_tomas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1759416698274,
|
||||
"tag": "0005_simple_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1760734377440,
|
||||
"tag": "0006_secret_micromacro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1761224911352,
|
||||
"tag": "0007_watery_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1761414054481,
|
||||
"tag": "0008_silent_lady_bullseye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1762095226041,
|
||||
"tag": "0009_little_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1763644043601,
|
||||
"tag": "0011_familiar_stone_men",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1764100562084,
|
||||
"tag": "0012_add_short_ids",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1764182159797,
|
||||
"tag": "0013_elite_sprite",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1764182405089,
|
||||
"tag": "0014_wild_echo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1764182465287,
|
||||
"tag": "0015_jazzy_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1764194697035,
|
||||
"tag": "0016_fix-timestamps-to-ms",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1764357897219,
|
||||
"tag": "0017_fix-compression-modes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1764794371040,
|
||||
"tag": "0018_breezy_invaders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1764839917446,
|
||||
"tag": "0019_secret_nomad",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1764847918249,
|
||||
"tag": "0020_even_dexter_bennett",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "6",
|
||||
"when": 1765307881092,
|
||||
"tag": "0021_steady_viper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "6",
|
||||
"when": 1765794552191,
|
||||
"tag": "0022_woozy_shen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "6",
|
||||
"when": 1766320570509,
|
||||
"tag": "0023_special_thor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "6",
|
||||
"when": 1766325504548,
|
||||
"tag": "0024_schedules-one-fs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "6",
|
||||
"when": 1766431021321,
|
||||
"tag": "0025_remarkable_pete_wisdom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "6",
|
||||
"when": 1766765013108,
|
||||
"tag": "0026_migrate-local-repo-paths",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "6",
|
||||
"when": 1766778073418,
|
||||
"tag": "0027_careful_cammi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "6",
|
||||
"when": 1766778162985,
|
||||
"tag": "0028_third_amazoness",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "6",
|
||||
"when": 1767366633340,
|
||||
"tag": "0029_demonic_namor",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
52
app/server/cli/commands/2fa.ts
Normal file
52
app/server/cli/commands/2fa.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { Command } from "commander";
|
||||
import { resetPasswordCommand } from "./commands/reset-password";
|
||||
import { twoFactorCommand } from "./commands/2fa";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program.name("zerobyte").description("Zerobyte CLI - Backup automation tool built on top of Restic").version("1.0.0");
|
||||
program.addCommand(resetPasswordCommand);
|
||||
program.addCommand(twoFactorCommand);
|
||||
|
||||
export async function runCLI(argv: string[]): Promise<boolean> {
|
||||
const args = argv.slice(2);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export const usersTable = sqliteTable("users_table", {
|
|||
username: text().notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
|
||||
totpSecret: text("totp_secret"),
|
||||
totpEnabled: int("totp_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,19 +5,32 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|||
import {
|
||||
changePasswordBodySchema,
|
||||
changePasswordDto,
|
||||
disable2faBodySchema,
|
||||
disable2faDto,
|
||||
enable2faBodySchema,
|
||||
enable2faDto,
|
||||
getMeDto,
|
||||
getStatusDto,
|
||||
getTwoFactorStatusDto,
|
||||
loginBodySchema,
|
||||
loginDto,
|
||||
logoutDto,
|
||||
registerBodySchema,
|
||||
registerDto,
|
||||
setup2faDto,
|
||||
verify2faBodySchema,
|
||||
verify2faDto,
|
||||
type ChangePasswordDto,
|
||||
type Disable2faDto,
|
||||
type Enable2faDto,
|
||||
type GetMeDto,
|
||||
type GetStatusDto,
|
||||
type GetTwoFactorStatusDto,
|
||||
type LoginDto,
|
||||
type LogoutDto,
|
||||
type RegisterDto,
|
||||
type Setup2faDto,
|
||||
type Verify2faDto,
|
||||
} from "./auth.dto";
|
||||
import { authService } from "./auth.service";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
|
|
@ -72,14 +85,47 @@ export const authController = new Hono()
|
|||
const body = c.req.valid("json");
|
||||
|
||||
try {
|
||||
const { sessionId, user, expiresAt } = await authService.login(body.username, body.password);
|
||||
const result = await authService.login(body.username, body.password);
|
||||
|
||||
if (result.requiresTwoFactor) {
|
||||
return c.json<LoginDto>({
|
||||
success: true,
|
||||
message: "2FA verification required",
|
||||
requiresTwoFactor: true,
|
||||
pendingSessionId: result.pendingSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
setCookie(c, COOKIE_NAME, result.sessionId, {
|
||||
...COOKIE_OPTIONS,
|
||||
expires: new Date(result.expiresAt),
|
||||
});
|
||||
|
||||
return c.json<LoginDto>({
|
||||
success: true,
|
||||
message: "Login successful",
|
||||
user: {
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
hasDownloadedResticPassword: result.user.hasDownloadedResticPassword,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
|
||||
}
|
||||
})
|
||||
.post("/verify-2fa", authRateLimiter, verify2faDto, validator("json", verify2faBodySchema), async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
|
||||
try {
|
||||
const { sessionId, user, expiresAt } = await authService.verifyTwoFactor(body.pendingSessionId, body.code);
|
||||
|
||||
setCookie(c, COOKIE_NAME, sessionId, {
|
||||
...COOKIE_OPTIONS,
|
||||
expires: new Date(expiresAt),
|
||||
});
|
||||
|
||||
return c.json<LoginDto>({
|
||||
return c.json<Verify2faDto>({
|
||||
success: true,
|
||||
message: "Login successful",
|
||||
user: {
|
||||
|
|
@ -89,7 +135,7 @@ export const authController = new Hono()
|
|||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
|
||||
return c.json<Verify2faDto>({ success: false, message: toMessage(error) }, 401);
|
||||
}
|
||||
})
|
||||
.post("/logout", authRateLimiter, logoutDto, async (c) => {
|
||||
|
|
@ -154,4 +200,103 @@ export const authController = new Hono()
|
|||
return c.json<ChangePasswordDto>({ success: false, message: toMessage(error) }, 400);
|
||||
}
|
||||
},
|
||||
);
|
||||
)
|
||||
.get("/2fa-status", getTwoFactorStatusDto, async (c) => {
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json({ message: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
const session = await authService.verifySession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||
return c.json({ message: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
const enabled = await authService.getTwoFactorStatus(session.user.id);
|
||||
return c.json<GetTwoFactorStatusDto>({ enabled });
|
||||
})
|
||||
.post("/2fa/setup", authRateLimiter, setup2faDto, async (c) => {
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json<Setup2faDto>({ success: false, message: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
const session = await authService.verifySession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||
return c.json<Setup2faDto>({ success: false, message: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const { uri, secret } = await authService.setupTwoFactor(session.user.id);
|
||||
return c.json<Setup2faDto>({
|
||||
success: true,
|
||||
message: "2FA setup data generated",
|
||||
uri,
|
||||
secret,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json<Setup2faDto>({ success: false, message: toMessage(error) }, 400);
|
||||
}
|
||||
})
|
||||
.post("/2fa/enable", authRateLimiter, enable2faDto, validator("json", enable2faBodySchema), async (c) => {
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json<Enable2faDto>({ success: false, message: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
const session = await authService.verifySession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||
return c.json<Enable2faDto>({ success: false, message: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
const body = c.req.valid("json");
|
||||
|
||||
try {
|
||||
await authService.enableTwoFactor(session.user.id, body.password, body.secret, body.code);
|
||||
// Clear the session cookie since all sessions were invalidated
|
||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||
return c.json<Enable2faDto>({
|
||||
success: true,
|
||||
message: "2FA enabled successfully. Please log in again.",
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json<Enable2faDto>({ success: false, message: toMessage(error) }, 400);
|
||||
}
|
||||
})
|
||||
.post("/2fa/disable", authRateLimiter, disable2faDto, validator("json", disable2faBodySchema), async (c) => {
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json<Disable2faDto>({ success: false, message: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
const session = await authService.verifySession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||
return c.json<Disable2faDto>({ success: false, message: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
const body = c.req.valid("json");
|
||||
|
||||
try {
|
||||
await authService.disableTwoFactor(session.user.id, body.password, body.code);
|
||||
// Clear the session cookie since all sessions were invalidated
|
||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||
return c.json<Disable2faDto>({
|
||||
success: true,
|
||||
message: "2FA disabled successfully. Please log in again.",
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json<Disable2faDto>({ success: false, message: toMessage(error) }, 400);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ const loginResponseSchema = type({
|
|||
username: "string",
|
||||
hasDownloadedResticPassword: "boolean",
|
||||
}).optional(),
|
||||
requiresTwoFactor: "boolean?",
|
||||
pendingSessionId: "string?",
|
||||
});
|
||||
|
||||
export const loginDto = describeRoute({
|
||||
|
|
@ -148,6 +150,174 @@ export const changePasswordDto = describeRoute({
|
|||
|
||||
export type ChangePasswordDto = typeof changePasswordResponseSchema.infer;
|
||||
|
||||
// 2FA Status DTO
|
||||
const twoFactorStatusResponseSchema = type({
|
||||
enabled: "boolean",
|
||||
});
|
||||
|
||||
export const getTwoFactorStatusDto = describeRoute({
|
||||
description: "Get 2FA status for current authenticated user",
|
||||
operationId: "getTwoFactorStatus",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "2FA status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(twoFactorStatusResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Not authenticated",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type GetTwoFactorStatusDto = typeof twoFactorStatusResponseSchema.infer;
|
||||
|
||||
// Verify 2FA DTO
|
||||
export const verify2faBodySchema = type({
|
||||
pendingSessionId: "string>0",
|
||||
code: "string==6",
|
||||
});
|
||||
|
||||
const verify2faResponseSchema = type({
|
||||
message: "string",
|
||||
success: "boolean",
|
||||
user: type({
|
||||
id: "number",
|
||||
username: "string",
|
||||
hasDownloadedResticPassword: "boolean",
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const verify2faDto = describeRoute({
|
||||
description: "Verify 2FA code and complete login",
|
||||
operationId: "verify2fa",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "2FA verification successful",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(verify2faResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Invalid 2FA code or session",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type Verify2faDto = typeof verify2faResponseSchema.infer;
|
||||
|
||||
// 2FA Setup DTO (generate secret and return URI for QR code)
|
||||
const setup2faResponseSchema = type({
|
||||
success: "boolean",
|
||||
message: "string",
|
||||
uri: "string?",
|
||||
secret: "string?",
|
||||
});
|
||||
|
||||
export const setup2faDto = describeRoute({
|
||||
description: "Generate 2FA setup data (secret and otpauth URI for QR code)",
|
||||
operationId: "setup2fa",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "2FA setup data",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(setup2faResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "2FA already enabled or other error",
|
||||
},
|
||||
401: {
|
||||
description: "Not authenticated",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type Setup2faDto = typeof setup2faResponseSchema.infer;
|
||||
|
||||
// 2FA Enable DTO (verify code and enable)
|
||||
export const enable2faBodySchema = type({
|
||||
password: "string>0",
|
||||
code: "string==6",
|
||||
secret: "string>0",
|
||||
});
|
||||
|
||||
const enable2faResponseSchema = type({
|
||||
success: "boolean",
|
||||
message: "string",
|
||||
});
|
||||
|
||||
export const enable2faDto = describeRoute({
|
||||
description: "Verify 2FA code and enable 2FA for the account",
|
||||
operationId: "enable2fa",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "2FA enabled successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(enable2faResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid code or 2FA already enabled",
|
||||
},
|
||||
401: {
|
||||
description: "Not authenticated",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type Enable2faDto = typeof enable2faResponseSchema.infer;
|
||||
|
||||
// 2FA Disable DTO (require password and TOTP code)
|
||||
export const disable2faBodySchema = type({
|
||||
password: "string>0",
|
||||
code: "string==6",
|
||||
});
|
||||
|
||||
const disable2faResponseSchema = type({
|
||||
success: "boolean",
|
||||
message: "string",
|
||||
});
|
||||
|
||||
export const disable2faDto = describeRoute({
|
||||
description: "Disable 2FA for the account (requires password and current TOTP code)",
|
||||
operationId: "disable2fa",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "2FA disabled successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(disable2faResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid password or code, or 2FA not enabled",
|
||||
},
|
||||
401: {
|
||||
description: "Not authenticated",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type Disable2faDto = typeof disable2faResponseSchema.infer;
|
||||
|
||||
export type LoginBody = typeof loginBodySchema.infer;
|
||||
export type RegisterBody = typeof registerBodySchema.infer;
|
||||
export type ChangePasswordBody = typeof changePasswordBodySchema.infer;
|
||||
export type Enable2faBody = typeof enable2faBodySchema.infer;
|
||||
export type Disable2faBody = typeof disable2faBodySchema.infer;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
import { eq, lt } from "drizzle-orm";
|
||||
import { db } from "../../db/db";
|
||||
import { sessionsTable, usersTable } from "../../db/schema";
|
||||
import { cryptoUtils } from "../../utils/crypto";
|
||||
import { logger } from "../../utils/logger";
|
||||
import * as OTPAuth from "otpauth";
|
||||
|
||||
const SESSION_DURATION = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds
|
||||
const PENDING_2FA_DURATION = 5 * 60 * 1000; // 5 minutes for 2FA verification
|
||||
|
||||
// In-memory store for pending 2FA sessions (short-lived, no need for DB persistence)
|
||||
interface Pending2faSession {
|
||||
userId: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
const pending2faSessions = new Map<string, Pending2faSession>();
|
||||
|
||||
export class AuthService {
|
||||
/**
|
||||
|
|
@ -51,6 +61,7 @@ export class AuthService {
|
|||
|
||||
/**
|
||||
* Login user with username and password
|
||||
* Returns requiresTwoFactor: true if 2FA is enabled, with a pendingSessionId
|
||||
*/
|
||||
async login(username: string, password: string) {
|
||||
const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username));
|
||||
|
|
@ -65,6 +76,26 @@ export class AuthService {
|
|||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled
|
||||
if (user.totpEnabled && user.totpSecret) {
|
||||
// Create a pending 2FA session in memory
|
||||
const pendingSessionId = crypto.randomUUID();
|
||||
const expiresAt = Date.now() + PENDING_2FA_DURATION;
|
||||
|
||||
pending2faSessions.set(pendingSessionId, {
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
logger.info(`2FA required for user: ${username}`);
|
||||
|
||||
return {
|
||||
requiresTwoFactor: true as const,
|
||||
pendingSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
// No 2FA - create full session
|
||||
const sessionId = crypto.randomUUID();
|
||||
const expiresAt = Date.now() + SESSION_DURATION;
|
||||
|
||||
|
|
@ -77,6 +108,7 @@ export class AuthService {
|
|||
logger.info(`User logged in: ${username}`);
|
||||
|
||||
return {
|
||||
requiresTwoFactor: false as const,
|
||||
sessionId,
|
||||
user: {
|
||||
id: user.id,
|
||||
|
|
@ -87,6 +119,96 @@ export class AuthService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify 2FA code and complete login
|
||||
*/
|
||||
async verifyTwoFactor(pendingSessionId: string, code: string) {
|
||||
// Find the pending session from memory
|
||||
const pendingSession = pending2faSessions.get(pendingSessionId);
|
||||
|
||||
if (!pendingSession) {
|
||||
throw new Error("Invalid or expired 2FA session");
|
||||
}
|
||||
|
||||
// Check if session expired
|
||||
if (pendingSession.expiresAt < Date.now()) {
|
||||
pending2faSessions.delete(pendingSessionId);
|
||||
throw new Error("2FA session expired. Please login again.");
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, pendingSession.userId));
|
||||
|
||||
if (!user) {
|
||||
pending2faSessions.delete(pendingSessionId);
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
if (!user.totpSecret) {
|
||||
throw new Error("2FA not configured for this user");
|
||||
}
|
||||
|
||||
// Decrypt the TOTP secret
|
||||
const decryptedSecret = await cryptoUtils.resolveSecret(user.totpSecret);
|
||||
|
||||
// Verify TOTP code
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: "Zerobyte",
|
||||
label: user.username,
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(decryptedSecret),
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token: code, window: 1 });
|
||||
if (delta === null) {
|
||||
throw new Error("Invalid 2FA code");
|
||||
}
|
||||
|
||||
// Delete the pending session from memory
|
||||
pending2faSessions.delete(pendingSessionId);
|
||||
|
||||
// Create full session
|
||||
const sessionId = crypto.randomUUID();
|
||||
const expiresAt = Date.now() + SESSION_DURATION;
|
||||
|
||||
await db.insert(sessionsTable).values({
|
||||
id: sessionId,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
logger.info(`User logged in with 2FA: ${user.username}`);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||
},
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired pending 2FA sessions from memory
|
||||
*/
|
||||
cleanupExpiredPending2faSessions() {
|
||||
const now = Date.now();
|
||||
let count = 0;
|
||||
for (const [id, session] of pending2faSessions) {
|
||||
if (session.expiresAt < now) {
|
||||
pending2faSessions.delete(id);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count > 0) {
|
||||
logger.info(`Cleaned up ${count} expired pending 2FA sessions`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user by deleting their session
|
||||
*/
|
||||
|
|
@ -174,6 +296,157 @@ export class AuthService {
|
|||
|
||||
logger.info(`Password changed for user: ${user.username}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get 2FA status for a user
|
||||
*/
|
||||
async getTwoFactorStatus(userId: number): Promise<boolean> {
|
||||
const [user] = await db
|
||||
.select({ totpEnabled: usersTable.totpEnabled })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId));
|
||||
return user?.totpEnabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate 2FA setup data (secret and otpauth URI for QR code)
|
||||
*/
|
||||
async setupTwoFactor(userId: number): Promise<{ uri: string; secret: string }> {
|
||||
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
if (user.totpEnabled) {
|
||||
throw new Error("2FA is already enabled for this account");
|
||||
}
|
||||
|
||||
// Generate a new TOTP secret
|
||||
const secret = new OTPAuth.Secret({ size: 20 });
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: "Zerobyte",
|
||||
label: user.username,
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: secret,
|
||||
});
|
||||
|
||||
return {
|
||||
uri: totp.toString(),
|
||||
secret: secret.base32,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable 2FA after verifying password and code
|
||||
*/
|
||||
async enableTwoFactor(userId: number, password: string, secret: string, code: string): Promise<void> {
|
||||
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
if (user.totpEnabled) {
|
||||
throw new Error("2FA is already enabled for this account");
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await Bun.password.verify(password, user.passwordHash);
|
||||
if (!isPasswordValid) {
|
||||
throw new Error("Invalid password");
|
||||
}
|
||||
|
||||
// Verify the code with the provided secret
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: "Zerobyte",
|
||||
label: user.username,
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(secret),
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token: code, window: 1 });
|
||||
if (delta === null) {
|
||||
throw new Error("Invalid verification code");
|
||||
}
|
||||
|
||||
// Encrypt and save the secret
|
||||
const encryptedSecret = await cryptoUtils.sealSecret(secret);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(usersTable)
|
||||
.set({
|
||||
totpSecret: encryptedSecret,
|
||||
totpEnabled: true,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(usersTable.id, userId));
|
||||
|
||||
// Invalidate all sessions except current (user will need to re-login on other devices)
|
||||
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, userId));
|
||||
});
|
||||
|
||||
logger.info(`2FA enabled for user: ${user.username}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable 2FA after verifying password and TOTP code
|
||||
*/
|
||||
async disableTwoFactor(userId: number, password: string, code: string): Promise<void> {
|
||||
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
if (!user.totpEnabled || !user.totpSecret) {
|
||||
throw new Error("2FA is not enabled for this account");
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await Bun.password.verify(password, user.passwordHash);
|
||||
if (!isPasswordValid) {
|
||||
throw new Error("Invalid password");
|
||||
}
|
||||
|
||||
// Decrypt and verify TOTP code
|
||||
const decryptedSecret = await cryptoUtils.resolveSecret(user.totpSecret);
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: "Zerobyte",
|
||||
label: user.username,
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(decryptedSecret),
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token: code, window: 1 });
|
||||
if (delta === null) {
|
||||
throw new Error("Invalid 2FA code");
|
||||
}
|
||||
|
||||
// Disable 2FA
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(usersTable)
|
||||
.set({
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(usersTable.id, userId));
|
||||
|
||||
// Invalidate all sessions
|
||||
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, userId));
|
||||
});
|
||||
|
||||
logger.info(`2FA disabled for user: ${user.username}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
|
|
|
|||
8
bun.lock
8
bun.lock
|
|
@ -45,6 +45,8 @@
|
|||
"lucide-react": "^0.555.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"otpauth": "^9.4.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-hook-form": "^7.68.0",
|
||||
|
|
@ -306,6 +308,8 @@
|
|||
|
||||
"@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
|
@ -924,6 +928,8 @@
|
|||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"otpauth": ["otpauth@9.4.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw=="],
|
||||
|
||||
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
|
@ -950,6 +956,8 @@
|
|||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
|
||||
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
||||
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@
|
|||
"lucide-react": "^0.555.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"otpauth": "^9.4.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-hook-form": "^7.68.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue