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