diff --git a/cmd/root.go b/cmd/root.go index 59329c5c..d9a81d9c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "crypto/tls" + "encoding/base64" "errors" "io" "io/fs" @@ -23,6 +24,7 @@ import ( "github.com/filebrowser/filebrowser/v2/auth" "github.com/filebrowser/filebrowser/v2/diskcache" + fbErrors "github.com/filebrowser/filebrowser/v2/errors" "github.com/filebrowser/filebrowser/v2/frontend" fbhttp "github.com/filebrowser/filebrowser/v2/http" "github.com/filebrowser/filebrowser/v2/img" @@ -65,6 +67,7 @@ func addServerFlags(flags *pflag.FlagSet) { flags.StringP("baseurl", "b", "", "base url") flags.String("cache-dir", "", "file cache directory (disabled if empty)") flags.String("token-expiration-time", "2h", "user session timeout") + flags.String("totp-token-exiration-time", "2m", "user totp sesstion timeout to login") flags.Int("img-processors", 4, "image processors count") //nolint:gomnd flags.Bool("disable-thumbnails", false, "disable image thumbnails") flags.Bool("disable-preview-resize", false, "disable resize of image previews") @@ -142,6 +145,8 @@ user created with the credentials from options "username" and "password".`, checkErr(err) server.Root = root + setTOTPEncryptionKey(server) + adr := server.Address + ":" + server.Port var listener net.Listener @@ -425,3 +430,12 @@ func initConfig() { cfgFile = "Using config file: " + v.ConfigFileUsed() } } + +func setTOTPEncryptionKey(server *settings.Server) { + totpEK, err := base64.StdEncoding.DecodeString(v.GetString("totp.encryption.key")) + checkErr(err) + if len(totpEK) != 32 { + checkErr(fbErrors.ErrInvalidEncryptionKey) + } + server.TOTPEncryptionKey = totpEK +} diff --git a/errors/errors.go b/errors/errors.go index 5ec364c0..7743ed92 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -3,6 +3,7 @@ package errors import "errors" var ( + ErrInvalidEncryptionKey = errors.New("The TOTP encryption key should be a 32-byte string encoded in Base64") ErrEmptyKey = errors.New("empty key") ErrExist = errors.New("the resource already exists") ErrNotExist = errors.New("the resource does not exist") diff --git a/frontend/package.json b/frontend/package.json index 86c2f88d..15b00427 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@chenfengyuan/vue-number-input": "^2.0.1", + "@scure/base": "^1.2.4", "@vueuse/core": "^12.5.0", "@vueuse/integrations": "^12.5.0", "ace-builds": "^1.37.5", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2cf5b620..e016522b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@chenfengyuan/vue-number-input': specifier: ^2.0.1 version: 2.0.1(vue@3.5.13(typescript@5.6.3)) + '@scure/base': + specifier: ^1.2.4 + version: 1.2.4 '@vueuse/core': specifier: ^12.5.0 version: 12.5.0(typescript@5.6.3) @@ -934,22 +937,26 @@ packages: resolution: {integrity: sha512-nmG512G8QOABsserleechwHGZxzKSAlggGf9hQX0nltvSwyKNVuB/4o6iFeG2OnjXK253r8p8eSDOZf8PgFdWw==} engines: {node: '>= 16'} - '@intlify/message-compiler@11.0.0-rc.1': - resolution: {integrity: sha512-TGw2uBfuTFTegZf/BHtUQBEKxl7Q/dVGLoqRIdw8lFsp9g/53sYn5iD+0HxIzdYjbWL6BTJMXCPUHp9PxDTRPw==} - engines: {node: '>= 16'} - '@intlify/message-compiler@11.1.2': resolution: {integrity: sha512-T/xbNDzi+Yv0Qn2Dfz2CWCAJiwNgU5d95EhhAEf4YmOgjCKktpfpiUSmLcBvK1CtLpPQ85AMMQk/2NCcXnNj1g==} engines: {node: '>= 16'} - '@intlify/shared@11.0.0-rc.1': - resolution: {integrity: sha512-8tR1xe7ZEbkabTuE/tNhzpolygUn9OaYp9yuYAF4MgDNZg06C3Qny80bes2/e9/Wm3aVkPUlCw6WgU7mQd0yEg==} + '@intlify/message-compiler@12.0.0-alpha.2': + resolution: {integrity: sha512-PD9C+oQbb7BF52hec0+vLnScaFkvnfX+R7zSbODYuRo/E2niAtGmHd0wPvEMsDhf9Z9b8f/qyDsVeZnD/ya9Ug==} engines: {node: '>= 16'} '@intlify/shared@11.1.2': resolution: {integrity: sha512-dF2iMMy8P9uKVHV/20LA1ulFLL+MKSbfMiixSmn6fpwqzvix38OIc7ebgnFbBqElvghZCW9ACtzKTGKsTGTWGA==} engines: {node: '>= 16'} + '@intlify/shared@11.1.3': + resolution: {integrity: sha512-pTFBgqa/99JRA2H1qfyqv97MKWJrYngXBA/I0elZcYxvJgcCw3mApAoPW3mJ7vx3j+Ti0FyKUFZ4hWxdjKaxvA==} + engines: {node: '>= 16'} + + '@intlify/shared@12.0.0-alpha.2': + resolution: {integrity: sha512-P2DULVX9nz3y8zKNqLw9Es1aAgQ1JGC+kgpx5q7yLmrnAKkPR5MybQWoEhxanefNJgUY5ehsgo+GKif59SrncA==} + engines: {node: '>= 16'} + '@intlify/unplugin-vue-i18n@6.0.3': resolution: {integrity: sha512-9ZDjBlhUHtgjRl23TVcgfJttgu8cNepwVhWvOv3mUMRDAhjW0pur1mWKEUKr1I8PNwE4Gvv2IQ1xcl4RL0nG0g==} engines: {node: '>= 18'} @@ -1140,6 +1147,9 @@ packages: cpu: [x64] os: [win32] + '@scure/base@1.2.4': + resolution: {integrity: sha512-5Yy9czTO47mqz+/J8GM6GIId4umdCk1wc1q8rKERQulIoc8VP9pzDcghv10Tl2E7R96ZUx/PhND3ESYUQX8NuQ==} + '@tsconfig/node22@22.0.0': resolution: {integrity: sha512-twLQ77zevtxobBOD4ToAtVmuYrpeYUh3qh+TEp+08IWhpsrIflVHqQ1F1CiPxQGL7doCdBIOOCF+1Tm833faNg==} @@ -3578,8 +3588,8 @@ snapshots: '@intlify/bundle-utils@10.0.0(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))': dependencies: - '@intlify/message-compiler': 11.0.0-rc.1 - '@intlify/shared': 11.0.0-rc.1 + '@intlify/message-compiler': 12.0.0-alpha.2 + '@intlify/shared': 12.0.0-alpha.2 acorn: 8.14.0 escodegen: 2.1.0 estree-walker: 2.0.2 @@ -3595,26 +3605,28 @@ snapshots: '@intlify/message-compiler': 11.1.2 '@intlify/shared': 11.1.2 - '@intlify/message-compiler@11.0.0-rc.1': - dependencies: - '@intlify/shared': 11.0.0-rc.1 - source-map-js: 1.2.1 - '@intlify/message-compiler@11.1.2': dependencies: '@intlify/shared': 11.1.2 source-map-js: 1.2.1 - '@intlify/shared@11.0.0-rc.1': {} + '@intlify/message-compiler@12.0.0-alpha.2': + dependencies: + '@intlify/shared': 12.0.0-alpha.2 + source-map-js: 1.2.1 '@intlify/shared@11.1.2': {} + '@intlify/shared@11.1.3': {} + + '@intlify/shared@12.0.0-alpha.2': {} + '@intlify/unplugin-vue-i18n@6.0.3(@vue/compiler-dom@3.5.13)(eslint@9.19.0)(rollup@4.32.0)(typescript@5.6.3)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3))': dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0) '@intlify/bundle-utils': 10.0.0(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3))) - '@intlify/shared': 11.1.2 - '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.1.2)(@vue/compiler-dom@3.5.13)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3)) + '@intlify/shared': 11.1.3 + '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.1.3)(@vue/compiler-dom@3.5.13)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3)) '@rollup/pluginutils': 5.1.4(rollup@4.32.0) '@typescript-eslint/scope-manager': 8.21.0 '@typescript-eslint/typescript-estree': 8.21.0(typescript@5.6.3) @@ -3636,11 +3648,11 @@ snapshots: - supports-color - typescript - '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.1.2)(@vue/compiler-dom@3.5.13)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3))': + '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.1.3)(@vue/compiler-dom@3.5.13)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3))': dependencies: '@babel/parser': 7.26.7 optionalDependencies: - '@intlify/shared': 11.1.2 + '@intlify/shared': 11.1.3 '@vue/compiler-dom': 3.5.13 vue: 3.5.13(typescript@5.6.3) vue-i18n: 11.1.2(vue@3.5.13(typescript@5.6.3)) @@ -3764,6 +3776,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.32.0': optional: true + '@scure/base@1.2.4': {} + '@tsconfig/node22@22.0.0': {} '@types/estree@1.0.6': {} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 78096b49..e863bd0a 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -41,3 +41,45 @@ export async function remove(id: number) { method: "DELETE", }); } + +export async function enableOTP(id: number, password: string) { + const res = await fetchURL(`/api/users/${id}/otp`, { + method: "POST", + body: JSON.stringify({ + password, + }), + }); + const payload: IOtpSetupKey = await res.json(); + + return payload; +} + +export async function checkOtp(id: number, code: string) { + return fetchURL(`/api/users/${id}/otp/check`, { + method: "POST", + body: JSON.stringify({ + code, + }), + }); +} + +export async function getOtpInfo(id: number, code: string) { + const res = await fetchURL(`/api/users/${id}/otp`, { + method: "GET", + headers: { + "X-TOTP-CODE": code, + }, + }); + const payload: IOtpSetupKey = await res.json(); + + return payload; +} + +export async function disableOtp(id: number, code: string) { + return fetchURL(`/api/users/${id}/otp`, { + method: "DELETE", + headers: { + "X-TOTP-CODE": code, + }, + }); +} diff --git a/frontend/src/components/prompts/Otp.vue b/frontend/src/components/prompts/Otp.vue new file mode 100644 index 00000000..769ab190 --- /dev/null +++ b/frontend/src/components/prompts/Otp.vue @@ -0,0 +1,85 @@ + + + diff --git a/frontend/src/components/prompts/Prompts.vue b/frontend/src/components/prompts/Prompts.vue index 71e4e753..273cb9ce 100644 --- a/frontend/src/components/prompts/Prompts.vue +++ b/frontend/src/components/prompts/Prompts.vue @@ -25,6 +25,7 @@ import Share from "./Share.vue"; import ShareDelete from "./ShareDelete.vue"; import Upload from "./Upload.vue"; import DiscardEditorChanges from "./DiscardEditorChanges.vue"; +import Otp from "./Otp.vue"; const layoutStore = useLayoutStore(); @@ -47,6 +48,7 @@ const components = new Map([ ["share-delete", ShareDelete], ["deleteUser", DeleteUser], ["discardEditorChanges", DiscardEditorChanges], + ["otp", Otp], ]); watch(currentPromptName, (newValue) => { diff --git a/frontend/src/components/settings/Profile2FA.vue b/frontend/src/components/settings/Profile2FA.vue new file mode 100644 index 00000000..88b87cc0 --- /dev/null +++ b/frontend/src/components/settings/Profile2FA.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/frontend/src/css/otp-modal.css b/frontend/src/css/otp-modal.css new file mode 100644 index 00000000..e5afb00b --- /dev/null +++ b/frontend/src/css/otp-modal.css @@ -0,0 +1,29 @@ +.otp-modal .card-title { + display: flex; + flex-direction: column; +} + +.otp-modal .card-title h2 { + text-align: center; + margin: 0 !important; +} + +.otp-modal .card-title p { + text-align: center; + color: var(--blue); + text-transform: lowercase; + font-weight: 500; + font-size: 0.9rem; + margin-top: 0.5rem; +} + +.otp-modal .card-content input { + font-size: 1.2em; + text-align: center; + letter-spacing: 0.5em; + transition: border 0.2s ease; +} + +.otp-modal .card-content input.empty { + letter-spacing: 0; +} diff --git a/frontend/src/css/styles.css b/frontend/src/css/styles.css index 19b94b95..2b0b3365 100644 --- a/frontend/src/css/styles.css +++ b/frontend/src/css/styles.css @@ -17,6 +17,7 @@ @import "./mobile.css"; @import "./epubReader.css"; @import "./mdPreview.css"; +@import "./otp-modal.css"; /* For testing only :focus { diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 1360bbec..4359c33b 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -1,6 +1,7 @@ { "buttons": { "cancel": "Cancel", + "check": "Check", "clear": "Clear", "close": "Close", "continue": "Continue", @@ -11,6 +12,8 @@ "create": "Create", "delete": "Delete", "download": "Download", + "enable": "Enable", + "disable": "Disable", "file": "File", "folder": "Folder", "fullScreen": "Toggle full screen", @@ -41,6 +44,7 @@ "toggleSidebar": "Toggle sidebar", "update": "Update", "upload": "Upload", + "verify": "Verify", "openFile": "Open file", "discardChanges": "Discard" }, @@ -182,6 +186,7 @@ "disableExternalLinks": "Disable external links (except documentation)", "disableUsedDiskPercentage": "Disable used disk percentage graph", "documentation": "documentation", + "otpCodeCheckPlaceholder": "Enter the otp code to check your setup key", "examples": "Examples", "executeOnShell": "Execute on shell", "executeOnShellDescription": "By default, File Browser executes the commands by calling their binaries directly. If you wish to run them on a shell instead (such as Bash or PowerShell), you can define it here with the required arguments and flags. If set, the command you execute will be appended as an argument. This applies to both user commands and event hooks.", @@ -239,6 +244,15 @@ "username": "Username", "users": "Users" }, + "otp": { + "name": "Two-Factor Authentication", + "verifyInstructions": "Enter the code from your authenticator app", + "codeInputPlaceholder": "6-digit code", + "invalidCodeType": "Verification code should be 6 english digits", + "enabledSuccessfully": "OTP enabled successfully", + "verificationFailed": "Verfication Failed", + "verificationSucceed": "Verification Succeed" + }, "sidebar": { "help": "Help", "hugoNew": "Hugo New", diff --git a/frontend/src/types/user.d.ts b/frontend/src/types/user.d.ts index b81806fc..64e10225 100644 --- a/frontend/src/types/user.d.ts +++ b/frontend/src/types/user.d.ts @@ -12,6 +12,7 @@ interface IUser { singleClick: boolean; dateFormat: boolean; viewMode: ViewModeType; + otpEnabled: boolean; sorting?: Sorting; } @@ -64,3 +65,7 @@ interface IRegexp { } type UserTheme = "light" | "dark" | ""; + +interface IOtpSetupKey { + setupKey: string; +} diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts index b868d90f..72e3ad3a 100644 --- a/frontend/src/utils/auth.ts +++ b/frontend/src/utils/auth.ts @@ -33,7 +33,7 @@ export async function login( username: string, password: string, recaptcha: string -) { +): Promise<{ otp: boolean; token: string }> { const data = { username, password, recaptcha }; const res = await fetch(`${baseURL}/api/login`, { @@ -47,7 +47,29 @@ export async function login( const body = await res.text(); if (res.status === 200) { - parseToken(body); + const payload = JSON.parse(body); + return payload; + } else { + throw new StatusError( + body || `${res.status} ${res.statusText}`, + res.status + ); + } +} + +export async function verifyTOTP(code: string, token: string): Promise { + const res = await fetch(`${baseURL}/api/login/otp`, { + method: "POST", + headers: { + "X-TOTP-CODE": code, + "X-TOTP-Auth": token, + }, + }); + const body = await res.text(); + + if (res.status === 200) { + const payload = JSON.parse(body); + parseToken(payload.token); } else { throw new StatusError( body || `${res.status} ${res.statusText}`, @@ -67,7 +89,8 @@ export async function renew(jwt: string) { const body = await res.text(); if (res.status === 200) { - parseToken(body); + const x = JSON.parse(body); + parseToken(x.token); } else { throw new StatusError( body || `${res.status} ${res.statusText}`, diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 5804789a..cf62bfad 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -1,5 +1,6 @@