Merge 9f37aeadcf into 35d1c09243
This commit is contained in:
commit
55f792c121
14
cmd/root.go
14
cmd/root.go
@ -2,6 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@ -23,6 +24,7 @@ import (
|
|||||||
|
|
||||||
"github.com/filebrowser/filebrowser/v2/auth"
|
"github.com/filebrowser/filebrowser/v2/auth"
|
||||||
"github.com/filebrowser/filebrowser/v2/diskcache"
|
"github.com/filebrowser/filebrowser/v2/diskcache"
|
||||||
|
fbErrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||||
"github.com/filebrowser/filebrowser/v2/frontend"
|
"github.com/filebrowser/filebrowser/v2/frontend"
|
||||||
fbhttp "github.com/filebrowser/filebrowser/v2/http"
|
fbhttp "github.com/filebrowser/filebrowser/v2/http"
|
||||||
"github.com/filebrowser/filebrowser/v2/img"
|
"github.com/filebrowser/filebrowser/v2/img"
|
||||||
@ -65,6 +67,7 @@ func addServerFlags(flags *pflag.FlagSet) {
|
|||||||
flags.StringP("baseurl", "b", "", "base url")
|
flags.StringP("baseurl", "b", "", "base url")
|
||||||
flags.String("cache-dir", "", "file cache directory (disabled if empty)")
|
flags.String("cache-dir", "", "file cache directory (disabled if empty)")
|
||||||
flags.String("token-expiration-time", "2h", "user session timeout")
|
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.Int("img-processors", 4, "image processors count") //nolint:gomnd
|
||||||
flags.Bool("disable-thumbnails", false, "disable image thumbnails")
|
flags.Bool("disable-thumbnails", false, "disable image thumbnails")
|
||||||
flags.Bool("disable-preview-resize", false, "disable resize of image previews")
|
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)
|
checkErr(err)
|
||||||
server.Root = root
|
server.Root = root
|
||||||
|
|
||||||
|
setTOTPEncryptionKey(server)
|
||||||
|
|
||||||
adr := server.Address + ":" + server.Port
|
adr := server.Address + ":" + server.Port
|
||||||
|
|
||||||
var listener net.Listener
|
var listener net.Listener
|
||||||
@ -425,3 +430,12 @@ func initConfig() {
|
|||||||
cfgFile = "Using config file: " + v.ConfigFileUsed()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package errors
|
|||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
ErrInvalidEncryptionKey = errors.New("The TOTP encryption key should be a 32-byte string encoded in Base64")
|
||||||
ErrEmptyKey = errors.New("empty key")
|
ErrEmptyKey = errors.New("empty key")
|
||||||
ErrExist = errors.New("the resource already exists")
|
ErrExist = errors.New("the resource already exists")
|
||||||
ErrNotExist = errors.New("the resource does not exist")
|
ErrNotExist = errors.New("the resource does not exist")
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chenfengyuan/vue-number-input": "^2.0.1",
|
"@chenfengyuan/vue-number-input": "^2.0.1",
|
||||||
|
"@scure/base": "^1.2.4",
|
||||||
"@vueuse/core": "^12.5.0",
|
"@vueuse/core": "^12.5.0",
|
||||||
"@vueuse/integrations": "^12.5.0",
|
"@vueuse/integrations": "^12.5.0",
|
||||||
"ace-builds": "^1.37.5",
|
"ace-builds": "^1.37.5",
|
||||||
|
|||||||
50
frontend/pnpm-lock.yaml
generated
50
frontend/pnpm-lock.yaml
generated
@ -11,6 +11,9 @@ importers:
|
|||||||
'@chenfengyuan/vue-number-input':
|
'@chenfengyuan/vue-number-input':
|
||||||
specifier: ^2.0.1
|
specifier: ^2.0.1
|
||||||
version: 2.0.1(vue@3.5.13(typescript@5.6.3))
|
version: 2.0.1(vue@3.5.13(typescript@5.6.3))
|
||||||
|
'@scure/base':
|
||||||
|
specifier: ^1.2.4
|
||||||
|
version: 1.2.4
|
||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^12.5.0
|
specifier: ^12.5.0
|
||||||
version: 12.5.0(typescript@5.6.3)
|
version: 12.5.0(typescript@5.6.3)
|
||||||
@ -934,22 +937,26 @@ packages:
|
|||||||
resolution: {integrity: sha512-nmG512G8QOABsserleechwHGZxzKSAlggGf9hQX0nltvSwyKNVuB/4o6iFeG2OnjXK253r8p8eSDOZf8PgFdWw==}
|
resolution: {integrity: sha512-nmG512G8QOABsserleechwHGZxzKSAlggGf9hQX0nltvSwyKNVuB/4o6iFeG2OnjXK253r8p8eSDOZf8PgFdWw==}
|
||||||
engines: {node: '>= 16'}
|
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':
|
'@intlify/message-compiler@11.1.2':
|
||||||
resolution: {integrity: sha512-T/xbNDzi+Yv0Qn2Dfz2CWCAJiwNgU5d95EhhAEf4YmOgjCKktpfpiUSmLcBvK1CtLpPQ85AMMQk/2NCcXnNj1g==}
|
resolution: {integrity: sha512-T/xbNDzi+Yv0Qn2Dfz2CWCAJiwNgU5d95EhhAEf4YmOgjCKktpfpiUSmLcBvK1CtLpPQ85AMMQk/2NCcXnNj1g==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
'@intlify/shared@11.0.0-rc.1':
|
'@intlify/message-compiler@12.0.0-alpha.2':
|
||||||
resolution: {integrity: sha512-8tR1xe7ZEbkabTuE/tNhzpolygUn9OaYp9yuYAF4MgDNZg06C3Qny80bes2/e9/Wm3aVkPUlCw6WgU7mQd0yEg==}
|
resolution: {integrity: sha512-PD9C+oQbb7BF52hec0+vLnScaFkvnfX+R7zSbODYuRo/E2niAtGmHd0wPvEMsDhf9Z9b8f/qyDsVeZnD/ya9Ug==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
'@intlify/shared@11.1.2':
|
'@intlify/shared@11.1.2':
|
||||||
resolution: {integrity: sha512-dF2iMMy8P9uKVHV/20LA1ulFLL+MKSbfMiixSmn6fpwqzvix38OIc7ebgnFbBqElvghZCW9ACtzKTGKsTGTWGA==}
|
resolution: {integrity: sha512-dF2iMMy8P9uKVHV/20LA1ulFLL+MKSbfMiixSmn6fpwqzvix38OIc7ebgnFbBqElvghZCW9ACtzKTGKsTGTWGA==}
|
||||||
engines: {node: '>= 16'}
|
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':
|
'@intlify/unplugin-vue-i18n@6.0.3':
|
||||||
resolution: {integrity: sha512-9ZDjBlhUHtgjRl23TVcgfJttgu8cNepwVhWvOv3mUMRDAhjW0pur1mWKEUKr1I8PNwE4Gvv2IQ1xcl4RL0nG0g==}
|
resolution: {integrity: sha512-9ZDjBlhUHtgjRl23TVcgfJttgu8cNepwVhWvOv3mUMRDAhjW0pur1mWKEUKr1I8PNwE4Gvv2IQ1xcl4RL0nG0g==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@ -1140,6 +1147,9 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@scure/base@1.2.4':
|
||||||
|
resolution: {integrity: sha512-5Yy9czTO47mqz+/J8GM6GIId4umdCk1wc1q8rKERQulIoc8VP9pzDcghv10Tl2E7R96ZUx/PhND3ESYUQX8NuQ==}
|
||||||
|
|
||||||
'@tsconfig/node22@22.0.0':
|
'@tsconfig/node22@22.0.0':
|
||||||
resolution: {integrity: sha512-twLQ77zevtxobBOD4ToAtVmuYrpeYUh3qh+TEp+08IWhpsrIflVHqQ1F1CiPxQGL7doCdBIOOCF+1Tm833faNg==}
|
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)))':
|
'@intlify/bundle-utils@10.0.0(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@intlify/message-compiler': 11.0.0-rc.1
|
'@intlify/message-compiler': 12.0.0-alpha.2
|
||||||
'@intlify/shared': 11.0.0-rc.1
|
'@intlify/shared': 12.0.0-alpha.2
|
||||||
acorn: 8.14.0
|
acorn: 8.14.0
|
||||||
escodegen: 2.1.0
|
escodegen: 2.1.0
|
||||||
estree-walker: 2.0.2
|
estree-walker: 2.0.2
|
||||||
@ -3595,26 +3605,28 @@ snapshots:
|
|||||||
'@intlify/message-compiler': 11.1.2
|
'@intlify/message-compiler': 11.1.2
|
||||||
'@intlify/shared': 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':
|
'@intlify/message-compiler@11.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@intlify/shared': 11.1.2
|
'@intlify/shared': 11.1.2
|
||||||
source-map-js: 1.2.1
|
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.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))':
|
'@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:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0)
|
'@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/bundle-utils': 10.0.0(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))
|
||||||
'@intlify/shared': 11.1.2
|
'@intlify/shared': 11.1.3
|
||||||
'@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))
|
||||||
'@rollup/pluginutils': 5.1.4(rollup@4.32.0)
|
'@rollup/pluginutils': 5.1.4(rollup@4.32.0)
|
||||||
'@typescript-eslint/scope-manager': 8.21.0
|
'@typescript-eslint/scope-manager': 8.21.0
|
||||||
'@typescript-eslint/typescript-estree': 8.21.0(typescript@5.6.3)
|
'@typescript-eslint/typescript-estree': 8.21.0(typescript@5.6.3)
|
||||||
@ -3636,11 +3648,11 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- 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:
|
dependencies:
|
||||||
'@babel/parser': 7.26.7
|
'@babel/parser': 7.26.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@intlify/shared': 11.1.2
|
'@intlify/shared': 11.1.3
|
||||||
'@vue/compiler-dom': 3.5.13
|
'@vue/compiler-dom': 3.5.13
|
||||||
vue: 3.5.13(typescript@5.6.3)
|
vue: 3.5.13(typescript@5.6.3)
|
||||||
vue-i18n: 11.1.2(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':
|
'@rollup/rollup-win32-x64-msvc@4.32.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@scure/base@1.2.4': {}
|
||||||
|
|
||||||
'@tsconfig/node22@22.0.0': {}
|
'@tsconfig/node22@22.0.0': {}
|
||||||
|
|
||||||
'@types/estree@1.0.6': {}
|
'@types/estree@1.0.6': {}
|
||||||
|
|||||||
@ -41,3 +41,45 @@ export async function remove(id: number) {
|
|||||||
method: "DELETE",
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
85
frontend/src/components/prompts/Otp.vue
Normal file
85
frontend/src/components/prompts/Otp.vue
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card floating otp-modal">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>{{ t("otp.name") }}</h2>
|
||||||
|
<p>{{ t("otp.verifyInstructions") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<input
|
||||||
|
v-model.trim="totpCode"
|
||||||
|
:class="inputClassObject"
|
||||||
|
:placeholder="t('otp.codeInputPlaceholder')"
|
||||||
|
@keyup.enter="submit"
|
||||||
|
id="focus-prompt"
|
||||||
|
tabindex="1"
|
||||||
|
class="input input--block"
|
||||||
|
type="text"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
required
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
aria-describedby="totp-error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-action">
|
||||||
|
<button
|
||||||
|
class="button button--flat button--grey"
|
||||||
|
@click="layoutStore.closeHovers"
|
||||||
|
:aria-label="t('buttons.cancel')"
|
||||||
|
:title="t('buttons.cancel')"
|
||||||
|
tabindex="3"
|
||||||
|
>
|
||||||
|
{{ t("buttons.cancel") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button button--flat"
|
||||||
|
:aria-label="t('buttons.verify')"
|
||||||
|
:title="t('buttons.verify')"
|
||||||
|
@click="submit"
|
||||||
|
tabindex="2"
|
||||||
|
>
|
||||||
|
{{ t("buttons.verify") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { inject, ref } from "vue";
|
||||||
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { StatusError } from "@/api/utils";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const $showError = inject<IToastError>("$showError")!;
|
||||||
|
const layoutStore = useLayoutStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const totpCode = ref<string>("");
|
||||||
|
const inputClassObject = computed(() => ({
|
||||||
|
empty: totpCode.value === "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const submit = async (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (totpCode.value.length !== 6 || !/^\d+$/.test(totpCode.value)) {
|
||||||
|
throw new Error(t("otp.invalidCodeType"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await layoutStore.currentPrompt?.confirm(totpCode.value);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof StatusError) {
|
||||||
|
console.error("TOTP Verification Error:", e);
|
||||||
|
$showError(t("otp.verificationFailed"));
|
||||||
|
} else if (e instanceof Error) {
|
||||||
|
$showError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutStore.closeHovers();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -25,6 +25,7 @@ import Share from "./Share.vue";
|
|||||||
import ShareDelete from "./ShareDelete.vue";
|
import ShareDelete from "./ShareDelete.vue";
|
||||||
import Upload from "./Upload.vue";
|
import Upload from "./Upload.vue";
|
||||||
import DiscardEditorChanges from "./DiscardEditorChanges.vue";
|
import DiscardEditorChanges from "./DiscardEditorChanges.vue";
|
||||||
|
import Otp from "./Otp.vue";
|
||||||
|
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
|
|
||||||
@ -47,6 +48,7 @@ const components = new Map<string, any>([
|
|||||||
["share-delete", ShareDelete],
|
["share-delete", ShareDelete],
|
||||||
["deleteUser", DeleteUser],
|
["deleteUser", DeleteUser],
|
||||||
["discardEditorChanges", DiscardEditorChanges],
|
["discardEditorChanges", DiscardEditorChanges],
|
||||||
|
["otp", Otp],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
watch(currentPromptName, (newValue) => {
|
watch(currentPromptName, (newValue) => {
|
||||||
|
|||||||
246
frontend/src/components/settings/Profile2FA.vue
Normal file
246
frontend/src/components/settings/Profile2FA.vue
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
<template>
|
||||||
|
<div class="column">
|
||||||
|
<form v-if="authStore?.user?.otpEnabled" class="card">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>{{ t("otp.name") }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="otpSetupKey" class="card-content">
|
||||||
|
<div class="qrcode-container">
|
||||||
|
<qrcode-vue :value="otpSetupKey" :size="300" level="M" />
|
||||||
|
</div>
|
||||||
|
<div class="setup-key-container">
|
||||||
|
<input
|
||||||
|
:value="otpSecretB32"
|
||||||
|
class="input input--block"
|
||||||
|
type="text"
|
||||||
|
name="otpSetupKey"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<button class="action copy-clipboard" @click="copyOtpSetupKey">
|
||||||
|
<i class="material-icons">content_paste_go</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="setup-key-container">
|
||||||
|
<input
|
||||||
|
v-model="otpCode"
|
||||||
|
:placeholder="t('settings.otpCodeCheckPlaceholder')"
|
||||||
|
type="text"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
class="input input--block"
|
||||||
|
/>
|
||||||
|
<button class="action copy-clipboard" @click="checkOtpCode">
|
||||||
|
<i class="material-icons">send</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="button button--block button--red" @click="disableOtp">
|
||||||
|
{{ t("buttons.disable") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!otpSetupKey" class="card-action">
|
||||||
|
<button class="button button--flat" @click="showOtpInfo">
|
||||||
|
{{ t("prompts.show") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form v-else class="card" @submit="enable2FA">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>{{ t("otp.name") }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<input
|
||||||
|
v-if="!otpSetupKey"
|
||||||
|
v-model="passwordForOTP"
|
||||||
|
:placeholder="t('settings.password')"
|
||||||
|
class="input input--block"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<div class="qrcode-container">
|
||||||
|
<qrcode-vue :value="otpSetupKey" :size="300" level="M" />
|
||||||
|
</div>
|
||||||
|
<div class="setup-key-container">
|
||||||
|
<input
|
||||||
|
:value="otpSecretB32"
|
||||||
|
class="input input--block"
|
||||||
|
type="text"
|
||||||
|
name="otpSetupKey"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<button class="action copy-clipboard" @click="copyOtpSetupKey">
|
||||||
|
<i class="material-icons">content_paste_go</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="setup-key-container">
|
||||||
|
<input
|
||||||
|
v-model="otpCode"
|
||||||
|
:placeholder="t('settings.otpCodeCheckPlaceholder')"
|
||||||
|
type="text"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
class="input input--block"
|
||||||
|
/>
|
||||||
|
<button class="action copy-clipboard" @click="checkOtpCode">
|
||||||
|
<i class="material-icons">send</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-action">
|
||||||
|
<input
|
||||||
|
v-if="!otpSetupKey"
|
||||||
|
:value="t('buttons.enable')"
|
||||||
|
class="button button--flat"
|
||||||
|
type="submit"
|
||||||
|
name="submitEnableOTPForm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { base32 } from "@scure/base";
|
||||||
|
import QrcodeVue from "qrcode.vue";
|
||||||
|
import { copy } from "@/utils/clipboard";
|
||||||
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { users as api } from "@/api";
|
||||||
|
import { inject, ref } from "vue";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const layoutStore = useLayoutStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const $showSuccess = inject<IToastSuccess>("$showSuccess")!;
|
||||||
|
const $showError = inject<IToastError>("$showError")!;
|
||||||
|
|
||||||
|
const passwordForOTP = ref<string>("");
|
||||||
|
const otpSetupKey = ref<string>("");
|
||||||
|
const otpCode = ref<string>("");
|
||||||
|
|
||||||
|
const otpSecretB32 = computed(() => {
|
||||||
|
const otpURI = new URL(otpSetupKey.value);
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const secstr = String(otpURI.searchParams.get("secret"));
|
||||||
|
const secret = encoder.encode(secstr);
|
||||||
|
|
||||||
|
return base32.encode(secret);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showOtpInfo = async (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
layoutStore.showHover({
|
||||||
|
prompt: "otp",
|
||||||
|
confirm: async (code: string) => {
|
||||||
|
if (authStore.user === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.getOtpInfo(authStore.user.id, code);
|
||||||
|
otpSetupKey.value = res.setupKey;
|
||||||
|
} catch (err: any) {
|
||||||
|
$showError(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const disableOtp = async (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
layoutStore.showHover({
|
||||||
|
prompt: "otp",
|
||||||
|
confirm: async (code: string) => {
|
||||||
|
if (authStore.user === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.disableOtp(authStore.user.id, code);
|
||||||
|
otpSetupKey.value = "";
|
||||||
|
authStore.user.otpEnabled = false;
|
||||||
|
} catch (err: any) {
|
||||||
|
$showError(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const enable2FA = async (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (authStore.user === null || otpSetupKey.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.enableOTP(authStore.user.id, passwordForOTP.value);
|
||||||
|
|
||||||
|
otpSetupKey.value = res.setupKey;
|
||||||
|
authStore.user.otpEnabled = true;
|
||||||
|
$showSuccess(t("otp.enabledSuccessfully"));
|
||||||
|
} catch (err: any) {
|
||||||
|
$showError(err);
|
||||||
|
} finally {
|
||||||
|
passwordForOTP.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await copy({ text });
|
||||||
|
$showSuccess(t("success.linkCopied"));
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await copy({ text }, { permission: true });
|
||||||
|
$showSuccess(t("success.linkCopied"));
|
||||||
|
} catch (e: any) {
|
||||||
|
$showError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const copyOtpSetupKey = async (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await copyToClipboard(otpSecretB32.value);
|
||||||
|
};
|
||||||
|
const checkOtpCode = async (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (authStore.user === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.checkOtp(authStore.user.id, otpCode.value);
|
||||||
|
$showSuccess(t("otp.verificationSucceed"));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(err);
|
||||||
|
$showError(t("otp.verificationFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="css" scoped>
|
||||||
|
.qrcode-container,
|
||||||
|
.setup-key-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-key-container {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-key-container > * {
|
||||||
|
margin: 0 0.5em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
frontend/src/css/otp-modal.css
Normal file
29
frontend/src/css/otp-modal.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@
|
|||||||
@import "./mobile.css";
|
@import "./mobile.css";
|
||||||
@import "./epubReader.css";
|
@import "./epubReader.css";
|
||||||
@import "./mdPreview.css";
|
@import "./mdPreview.css";
|
||||||
|
@import "./otp-modal.css";
|
||||||
|
|
||||||
/* For testing only
|
/* For testing only
|
||||||
:focus {
|
:focus {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"check": "Check",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
@ -11,6 +12,8 @@
|
|||||||
"create": "Create",
|
"create": "Create",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"enable": "Enable",
|
||||||
|
"disable": "Disable",
|
||||||
"file": "File",
|
"file": "File",
|
||||||
"folder": "Folder",
|
"folder": "Folder",
|
||||||
"fullScreen": "Toggle full screen",
|
"fullScreen": "Toggle full screen",
|
||||||
@ -41,6 +44,7 @@
|
|||||||
"toggleSidebar": "Toggle sidebar",
|
"toggleSidebar": "Toggle sidebar",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
|
"verify": "Verify",
|
||||||
"openFile": "Open file",
|
"openFile": "Open file",
|
||||||
"discardChanges": "Discard"
|
"discardChanges": "Discard"
|
||||||
},
|
},
|
||||||
@ -182,6 +186,7 @@
|
|||||||
"disableExternalLinks": "Disable external links (except documentation)",
|
"disableExternalLinks": "Disable external links (except documentation)",
|
||||||
"disableUsedDiskPercentage": "Disable used disk percentage graph",
|
"disableUsedDiskPercentage": "Disable used disk percentage graph",
|
||||||
"documentation": "documentation",
|
"documentation": "documentation",
|
||||||
|
"otpCodeCheckPlaceholder": "Enter the otp code to check your setup key",
|
||||||
"examples": "Examples",
|
"examples": "Examples",
|
||||||
"executeOnShell": "Execute on shell",
|
"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.",
|
"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",
|
"username": "Username",
|
||||||
"users": "Users"
|
"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": {
|
"sidebar": {
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"hugoNew": "Hugo New",
|
"hugoNew": "Hugo New",
|
||||||
|
|||||||
5
frontend/src/types/user.d.ts
vendored
5
frontend/src/types/user.d.ts
vendored
@ -12,6 +12,7 @@ interface IUser {
|
|||||||
singleClick: boolean;
|
singleClick: boolean;
|
||||||
dateFormat: boolean;
|
dateFormat: boolean;
|
||||||
viewMode: ViewModeType;
|
viewMode: ViewModeType;
|
||||||
|
otpEnabled: boolean;
|
||||||
sorting?: Sorting;
|
sorting?: Sorting;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,3 +65,7 @@ interface IRegexp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserTheme = "light" | "dark" | "";
|
type UserTheme = "light" | "dark" | "";
|
||||||
|
|
||||||
|
interface IOtpSetupKey {
|
||||||
|
setupKey: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export async function login(
|
|||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
recaptcha: string
|
recaptcha: string
|
||||||
) {
|
): Promise<{ otp: boolean; token: string }> {
|
||||||
const data = { username, password, recaptcha };
|
const data = { username, password, recaptcha };
|
||||||
|
|
||||||
const res = await fetch(`${baseURL}/api/login`, {
|
const res = await fetch(`${baseURL}/api/login`, {
|
||||||
@ -47,7 +47,29 @@ export async function login(
|
|||||||
const body = await res.text();
|
const body = await res.text();
|
||||||
|
|
||||||
if (res.status === 200) {
|
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<void> {
|
||||||
|
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 {
|
} else {
|
||||||
throw new StatusError(
|
throw new StatusError(
|
||||||
body || `${res.status} ${res.statusText}`,
|
body || `${res.status} ${res.statusText}`,
|
||||||
@ -67,7 +89,8 @@ export async function renew(jwt: string) {
|
|||||||
const body = await res.text();
|
const body = await res.text();
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
parseToken(body);
|
const x = JSON.parse(body);
|
||||||
|
parseToken(x.token);
|
||||||
} else {
|
} else {
|
||||||
throw new StatusError(
|
throw new StatusError(
|
||||||
body || `${res.status} ${res.statusText}`,
|
body || `${res.status} ${res.statusText}`,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="login" :class="{ recaptcha: recaptcha }">
|
<div id="login" :class="{ recaptcha: recaptcha }">
|
||||||
|
<prompts></prompts>
|
||||||
<form @submit="submit">
|
<form @submit="submit">
|
||||||
<img :src="logoURL" alt="File Browser" />
|
<img :src="logoURL" alt="File Browser" />
|
||||||
<h1>{{ name }}</h1>
|
<h1>{{ name }}</h1>
|
||||||
@ -43,6 +44,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { StatusError } from "@/api/utils";
|
import { StatusError } from "@/api/utils";
|
||||||
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import Prompts from "@/components/prompts/Prompts.vue";
|
||||||
import * as auth from "@/utils/auth";
|
import * as auth from "@/utils/auth";
|
||||||
import {
|
import {
|
||||||
name,
|
name,
|
||||||
@ -65,6 +68,7 @@ const passwordConfirm = ref<string>("");
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useI18n({});
|
const { t } = useI18n({});
|
||||||
|
const layoutStore = useLayoutStore();
|
||||||
// Define functions
|
// Define functions
|
||||||
const toggleMode = () => (createMode.value = !createMode.value);
|
const toggleMode = () => (createMode.value = !createMode.value);
|
||||||
|
|
||||||
@ -97,11 +101,29 @@ const submit = async (event: Event) => {
|
|||||||
if (createMode.value) {
|
if (createMode.value) {
|
||||||
await auth.signup(username.value, password.value);
|
await auth.signup(username.value, password.value);
|
||||||
}
|
}
|
||||||
|
const res = await auth.login(username.value, password.value, captcha);
|
||||||
await auth.login(username.value, password.value, captcha);
|
if (res.otp) {
|
||||||
router.push({ path: redirect });
|
layoutStore.showHover({
|
||||||
|
prompt: "otp",
|
||||||
|
confirm: async (code: string) => {
|
||||||
|
try {
|
||||||
|
await auth.verifyTOTP(code, res.token);
|
||||||
|
router.push({ path: redirect });
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof StatusError) {
|
||||||
|
error.value = t("otp.verificationFailed");
|
||||||
|
} else {
|
||||||
|
$showError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
auth.parseToken(res.token);
|
||||||
|
router.push({ path: redirect });
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// console.error(e);
|
console.error(e);
|
||||||
if (e instanceof StatusError) {
|
if (e instanceof StatusError) {
|
||||||
if (e.status === 409) {
|
if (e.status === 409) {
|
||||||
error.value = t("login.usernameTaken");
|
error.value = t("login.usernameTaken");
|
||||||
|
|||||||
@ -74,6 +74,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<profile-2fa />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -84,6 +86,7 @@ import { users as api } from "@/api";
|
|||||||
import Languages from "@/components/settings/Languages.vue";
|
import Languages from "@/components/settings/Languages.vue";
|
||||||
import { computed, inject, onMounted, ref } from "vue";
|
import { computed, inject, onMounted, ref } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import Profile2fa from "@/components/settings/Profile2FA.vue";
|
||||||
|
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|||||||
@ -99,6 +99,7 @@ const fetchData = async () => {
|
|||||||
rules: [],
|
rules: [],
|
||||||
lockPassword: false,
|
lockPassword: false,
|
||||||
id: 0,
|
id: 0,
|
||||||
|
otpEnabled: false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const id = Array.isArray(route.params.id)
|
const id = Array.isArray(route.params.id)
|
||||||
|
|||||||
2
go.mod
2
go.mod
@ -16,6 +16,7 @@ require (
|
|||||||
github.com/mholt/archiver/v3 v3.5.1
|
github.com/mholt/archiver/v3 v3.5.1
|
||||||
github.com/mitchellh/go-homedir v1.1.0
|
github.com/mitchellh/go-homedir v1.1.0
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3
|
github.com/pelletier/go-toml/v2 v2.2.3
|
||||||
|
github.com/pquerna/otp v1.4.0
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/spf13/afero v1.11.0
|
github.com/spf13/afero v1.11.0
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
@ -35,6 +36,7 @@ require (
|
|||||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
github.com/asticode/go-astikit v0.42.0 // indirect
|
github.com/asticode/go-astikit v0.42.0 // indirect
|
||||||
github.com/asticode/go-astits v1.13.0 // indirect
|
github.com/asticode/go-astits v1.13.0 // indirect
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
|
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
|
||||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||||
|
|||||||
5
go.sum
5
go.sum
@ -16,6 +16,8 @@ github.com/asticode/go-astisub v0.26.2/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2z
|
|||||||
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
|
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
|
||||||
github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c=
|
github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c=
|
||||||
github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
|
github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@ -127,6 +129,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||||
|
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
@ -150,6 +154,7 @@ github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
|||||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
|||||||
22
http/auth.go
22
http/auth.go
@ -17,9 +17,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultTokenExpirationTime = time.Hour * 2
|
DefaultTokenExpirationTime = time.Hour * 2
|
||||||
|
DefaultTOTPTokenExpirationTime = time.Minute * 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type loginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
OTP bool `json:"otp"`
|
||||||
|
}
|
||||||
|
|
||||||
type userInfo struct {
|
type userInfo struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Locale string `json:"locale"`
|
Locale string `json:"locale"`
|
||||||
@ -30,6 +36,7 @@ type userInfo struct {
|
|||||||
LockPassword bool `json:"lockPassword"`
|
LockPassword bool `json:"lockPassword"`
|
||||||
HideDotfiles bool `json:"hideDotfiles"`
|
HideDotfiles bool `json:"hideDotfiles"`
|
||||||
DateFormat bool `json:"dateFormat"`
|
DateFormat bool `json:"dateFormat"`
|
||||||
|
OTPEnabled bool `json:"otpEnabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type authToken struct {
|
type authToken struct {
|
||||||
@ -102,7 +109,7 @@ func withAdmin(fn handleFunc) handleFunc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginHandler(tokenExpireTime time.Duration) handleFunc {
|
func loginHandler(totpLoginTokenExpireTime, tokenExpireTime time.Duration) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
auther, err := d.store.Auth.Get(d.settings.AuthMethod)
|
auther, err := d.store.Auth.Get(d.settings.AuthMethod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -117,6 +124,10 @@ func loginHandler(tokenExpireTime time.Duration) handleFunc {
|
|||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.TOTPSecret != "" {
|
||||||
|
return printTOTPToken(w, r, d, user, totpLoginTokenExpireTime)
|
||||||
|
}
|
||||||
|
|
||||||
return printToken(w, r, d, user, tokenExpireTime)
|
return printToken(w, r, d, user, tokenExpireTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -195,6 +206,7 @@ func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.Use
|
|||||||
Commands: user.Commands,
|
Commands: user.Commands,
|
||||||
HideDotfiles: user.HideDotfiles,
|
HideDotfiles: user.HideDotfiles,
|
||||||
DateFormat: user.DateFormat,
|
DateFormat: user.DateFormat,
|
||||||
|
OTPEnabled: user.TOTPSecret != "",
|
||||||
},
|
},
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
@ -209,9 +221,5 @@ func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.Use
|
|||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
return renderJSON(w, nil, loginResponse{Token: signed, OTP: false})
|
||||||
if _, err := w.Write([]byte(signed)); err != nil {
|
|
||||||
return http.StatusInternalServerError, err
|
|
||||||
}
|
|
||||||
return 0, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,8 +48,9 @@ func NewHandler(
|
|||||||
|
|
||||||
api := r.PathPrefix("/api").Subrouter()
|
api := r.PathPrefix("/api").Subrouter()
|
||||||
|
|
||||||
tokenExpirationTime := server.GetTokenExpirationTime(DefaultTokenExpirationTime)
|
tokenExpirationTime, totpExpTime := server.GetTokenExpirationTime(DefaultTokenExpirationTime, DefaultTOTPTokenExpirationTime)
|
||||||
api.Handle("/login", monkey(loginHandler(tokenExpirationTime), ""))
|
api.Handle("/login", monkey(loginHandler(tokenExpirationTime, totpExpTime), ""))
|
||||||
|
api.Handle("/login/otp", monkey(verifyTOTPHandler(tokenExpirationTime), ""))
|
||||||
api.Handle("/signup", monkey(signupHandler, ""))
|
api.Handle("/signup", monkey(signupHandler, ""))
|
||||||
api.Handle("/renew", monkey(renewHandler(tokenExpirationTime), ""))
|
api.Handle("/renew", monkey(renewHandler(tokenExpirationTime), ""))
|
||||||
|
|
||||||
@ -59,6 +60,10 @@ func NewHandler(
|
|||||||
users.Handle("/{id:[0-9]+}", monkey(userPutHandler, "")).Methods("PUT")
|
users.Handle("/{id:[0-9]+}", monkey(userPutHandler, "")).Methods("PUT")
|
||||||
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
|
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
|
||||||
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
|
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
|
||||||
|
users.Handle("/{id:[0-9]+}/otp", monkey(userEnableTOTPHandler, "")).Methods("POST")
|
||||||
|
users.Handle("/{id:[0-9]+}/otp", monkey(userGetTOTPHandler, "")).Methods("GET")
|
||||||
|
users.Handle("/{id:[0-9]+}/otp/check", monkey(userCheckTOTPHandler, "")).Methods("POST")
|
||||||
|
users.Handle("/{id:[0-9]+}/otp", monkey(userDisableTOTPHandler, "")).Methods("DELETE")
|
||||||
|
|
||||||
api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET")
|
api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET")
|
||||||
api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")
|
api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")
|
||||||
|
|||||||
110
http/totp.go
Normal file
110
http/totp.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/golang-jwt/jwt/v4/request"
|
||||||
|
|
||||||
|
"github.com/filebrowser/filebrowser/v2/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
type totpUserInfo struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type totpAuthToken struct {
|
||||||
|
User totpUserInfo `json:"user"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type totpExtractor []string
|
||||||
|
|
||||||
|
func (e totpExtractor) ExtractToken(r *http.Request) (string, error) {
|
||||||
|
token, _ := request.HeaderExtractor{"X-TOTP-Auth"}.ExtractToken(r)
|
||||||
|
|
||||||
|
// Checks if the token isn't empty and if it contains two dots.
|
||||||
|
// The former prevents incompatibility with URLs that previously
|
||||||
|
// used basic auth.
|
||||||
|
if token != "" && strings.Count(token, ".") == 2 {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", request.ErrNoTokenInRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyTOTPHandler(tokenExpireTime time.Duration) handleFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
|
code := r.Header.Get("X-TOTP-CODE")
|
||||||
|
if code == "" {
|
||||||
|
return http.StatusUnauthorized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keyFunc := func(_ *jwt.Token) (interface{}, error) {
|
||||||
|
return d.settings.Key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tk totpAuthToken
|
||||||
|
token, err := request.ParseFromRequest(r, &totpExtractor{}, keyFunc, request.WithClaims(&tk))
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return http.StatusUnauthorized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d.user, err = d.store.Users.Get(d.server.Root, tk.User.ID)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok, err := users.CheckTOTP(d.server.TOTPEncryptionKey, d.user.TOTPSecret, d.user.TOTPNonce, code); err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
} else if !ok {
|
||||||
|
return http.StatusUnauthorized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return printToken(w, r, d, d.user, tokenExpireTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withTOTP(fn handleFunc) handleFunc {
|
||||||
|
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
|
if d.user.TOTPSecret == "" {
|
||||||
|
return fn(w, r, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := r.Header.Get("X-TOTP-CODE"); code == "" {
|
||||||
|
return http.StatusForbidden, nil
|
||||||
|
} else {
|
||||||
|
if ok, err := users.CheckTOTP(d.server.TOTPEncryptionKey, d.user.TOTPSecret, d.user.TOTPNonce, code); err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
} else if !ok {
|
||||||
|
return http.StatusForbidden, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn(w, r, d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func printTOTPToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.User, tokenExpirationTime time.Duration) (int, error) {
|
||||||
|
claims := &totpAuthToken{
|
||||||
|
User: totpUserInfo{
|
||||||
|
ID: user.ID,
|
||||||
|
},
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenExpirationTime)),
|
||||||
|
Issuer: "File Browser TOTP",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
signed, err := token.SignedString(d.settings.Key)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderJSON(w, nil, loginResponse{Token: signed, OTP: true})
|
||||||
|
}
|
||||||
113
http/users.go
113
http/users.go
@ -3,12 +3,14 @@ package http
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/text/cases"
|
"golang.org/x/text/cases"
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
@ -18,6 +20,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules"}
|
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules"}
|
||||||
|
TOTPIssuer = "FileBrowser"
|
||||||
)
|
)
|
||||||
|
|
||||||
type modifyUserRequest struct {
|
type modifyUserRequest struct {
|
||||||
@ -25,6 +28,22 @@ type modifyUserRequest struct {
|
|||||||
Data *users.User `json:"data"`
|
Data *users.User `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type enableTOTPVerificationRequest struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type enableTOTPVerificationResponse struct {
|
||||||
|
SetupKey string `json:"setupKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type getTOTPInfoResponse struct {
|
||||||
|
SetupKey string `json:"setupKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkTOTPRequest struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
func getUserID(r *http.Request) (uint, error) {
|
func getUserID(r *http.Request) (uint, error) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
i, err := strconv.ParseUint(vars["id"], 10, 0)
|
i, err := strconv.ParseUint(vars["id"], 10, 0)
|
||||||
@ -76,6 +95,8 @@ var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *
|
|||||||
|
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
u.Password = ""
|
u.Password = ""
|
||||||
|
u.TOTPSecret = ""
|
||||||
|
u.TOTPNonce = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(users, func(i, j int) bool {
|
sort.Slice(users, func(i, j int) bool {
|
||||||
@ -96,6 +117,8 @@ var userGetHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
|
|
||||||
u.Password = ""
|
u.Password = ""
|
||||||
|
u.TOTPSecret = ""
|
||||||
|
u.TOTPNonce = ""
|
||||||
if !d.user.Perm.Admin {
|
if !d.user.Perm.Admin {
|
||||||
u.Scope = ""
|
u.Scope = ""
|
||||||
}
|
}
|
||||||
@ -206,3 +229,93 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
return http.StatusOK, nil
|
return http.StatusOK, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var userEnableTOTPHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
|
if r.Body == nil {
|
||||||
|
return http.StatusBadRequest, fbErrors.ErrEmptyRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.user.TOTPSecret != "" {
|
||||||
|
return http.StatusBadRequest, fmt.Errorf("TOTP verification already enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req enableTOTPVerificationRequest
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusBadRequest, fmt.Errorf("Invalid request body: %w", err)
|
||||||
|
} else if req.Password == "" {
|
||||||
|
return http.StatusBadRequest, fbErrors.ErrEmptyPassword
|
||||||
|
} else if !users.CheckPwd(req.Password, d.user.Password) {
|
||||||
|
return http.StatusBadRequest, errors.New("password is incorrect")
|
||||||
|
}
|
||||||
|
|
||||||
|
ops := totp.GenerateOpts{AccountName: d.user.Username, Issuer: TOTPIssuer}
|
||||||
|
key, err := totp.Generate(ops)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptedSecret, nonce, err := users.EncryptSymmetric(d.server.TOTPEncryptionKey, []byte(key.Secret()))
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.user.TOTPSecret = encryptedSecret
|
||||||
|
d.user.TOTPNonce = nonce
|
||||||
|
if err := d.store.Users.Update(d.user, "TOTPSecret", "TOTPNonce"); err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderJSON(w, r, enableTOTPVerificationResponse{SetupKey: key.URL()})
|
||||||
|
})
|
||||||
|
|
||||||
|
var userGetTOTPHandler = withTOTP(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
|
if d.user.TOTPSecret == "" {
|
||||||
|
return http.StatusForbidden, fmt.Errorf("user does not enable the TOTP verification")
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, err := users.DecryptSymmetric(d.server.TOTPEncryptionKey, d.user.TOTPSecret, d.user.TOTPNonce)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ops := totp.GenerateOpts{AccountName: d.user.Username, Issuer: TOTPIssuer, Secret: []byte(secret)}
|
||||||
|
key, err := totp.Generate(ops)
|
||||||
|
|
||||||
|
return renderJSON(w, r, getTOTPInfoResponse{SetupKey: key.URL()})
|
||||||
|
})
|
||||||
|
|
||||||
|
var userDisableTOTPHandler = withTOTP(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
|
if d.user.TOTPSecret == "" {
|
||||||
|
return http.StatusOK, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d.user.TOTPNonce = ""
|
||||||
|
d.user.TOTPSecret = ""
|
||||||
|
|
||||||
|
if err := d.store.Users.Update(d.user, "TOTPSecret", "TOTPNonce"); err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.StatusOK, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
var userCheckTOTPHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
|
if d.user.TOTPSecret == "" {
|
||||||
|
return http.StatusForbidden, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var req checkTOTPRequest
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusBadRequest, fmt.Errorf("Invalid request body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok, err := users.CheckTOTP(d.server.TOTPEncryptionKey, d.user.TOTPSecret, d.user.TOTPNonce, req.Code); err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
} else if !ok {
|
||||||
|
return http.StatusForbidden, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.StatusOK, nil
|
||||||
|
})
|
||||||
|
|||||||
@ -36,20 +36,22 @@ func (s *Settings) GetRules() []rules.Rule {
|
|||||||
|
|
||||||
// Server specific settings.
|
// Server specific settings.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Root string `json:"root"`
|
Root string `json:"root"`
|
||||||
BaseURL string `json:"baseURL"`
|
BaseURL string `json:"baseURL"`
|
||||||
Socket string `json:"socket"`
|
Socket string `json:"socket"`
|
||||||
TLSKey string `json:"tlsKey"`
|
TLSKey string `json:"tlsKey"`
|
||||||
TLSCert string `json:"tlsCert"`
|
TLSCert string `json:"tlsCert"`
|
||||||
Port string `json:"port"`
|
Port string `json:"port"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Log string `json:"log"`
|
Log string `json:"log"`
|
||||||
EnableThumbnails bool `json:"enableThumbnails"`
|
EnableThumbnails bool `json:"enableThumbnails"`
|
||||||
ResizePreview bool `json:"resizePreview"`
|
ResizePreview bool `json:"resizePreview"`
|
||||||
EnableExec bool `json:"enableExec"`
|
EnableExec bool `json:"enableExec"`
|
||||||
TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
|
TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
|
||||||
AuthHook string `json:"authHook"`
|
AuthHook string `json:"authHook"`
|
||||||
TokenExpirationTime string `json:"tokenExpirationTime"`
|
TokenExpirationTime string `json:"tokenExpirationTime"`
|
||||||
|
TOTPTokenExpirationTime string `json:"totpTokenExpirationTime"`
|
||||||
|
TOTPEncryptionKey []byte `json:"totpEncryptionKey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean cleans any variables that might need cleaning.
|
// Clean cleans any variables that might need cleaning.
|
||||||
@ -57,17 +59,21 @@ func (s *Server) Clean() {
|
|||||||
s.BaseURL = strings.TrimSuffix(s.BaseURL, "/")
|
s.BaseURL = strings.TrimSuffix(s.BaseURL, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GetTokenExpirationTime(fallback time.Duration) time.Duration {
|
func (s *Server) GetTokenExpirationTime(tokenFB, totpFB time.Duration) (time.Duration, time.Duration) {
|
||||||
if s.TokenExpirationTime == "" {
|
getTokenDuration := func(v string, fb time.Duration) time.Duration {
|
||||||
return fallback
|
if v == "" {
|
||||||
|
return fb
|
||||||
|
}
|
||||||
|
|
||||||
|
dur, err := time.ParseDuration(v)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] Failed to parse ExpirationTime(value: %s): %v", v, err)
|
||||||
|
return fb
|
||||||
|
}
|
||||||
|
return dur
|
||||||
}
|
}
|
||||||
|
|
||||||
duration, err := time.ParseDuration(s.TokenExpirationTime)
|
return getTokenDuration(s.TokenExpirationTime, tokenFB), getTokenDuration(s.TOTPTokenExpirationTime, totpFB)
|
||||||
if err != nil {
|
|
||||||
log.Printf("[WARN] Failed to parse tokenExpirationTime: %v", err)
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateKey generates a key of 512 bits.
|
// GenerateKey generates a key of 512 bits.
|
||||||
|
|||||||
@ -1,7 +1,17 @@
|
|||||||
package users
|
package users
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
fbErrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HashPwd hashes a password.
|
// HashPwd hashes a password.
|
||||||
@ -15,3 +25,79 @@ func CheckPwd(password, hash string) bool {
|
|||||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// returns cipher text and nonce in base64
|
||||||
|
func EncryptSymmetric(encryptionKey, secret []byte) (string, string, error) {
|
||||||
|
if len(encryptionKey) != 32 {
|
||||||
|
log.Printf("%s (key=\"%s\")", fbErrors.ErrInvalidEncryptionKey.Error(), string(encryptionKey))
|
||||||
|
return "", "", fbErrors.ErrInvalidEncryptionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
cipherText := gcm.Seal(nil, nonce, secret, nil)
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(cipherText), base64.StdEncoding.EncodeToString(nonce), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecryptSymmetric(encryptionKey []byte, cipherTextB64, nonceB64 string) (string, error) {
|
||||||
|
if len(encryptionKey) != 32 {
|
||||||
|
log.Printf("%s (key=\"%s\")", fbErrors.ErrInvalidEncryptionKey.Error(), string(encryptionKey))
|
||||||
|
return "", fbErrors.ErrInvalidEncryptionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
cipherText, err := base64.StdEncoding.DecodeString(cipherTextB64)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, err := base64.StdEncoding.DecodeString(nonceB64)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, err := gcm.Open(nil, nonce, cipherText, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(secret), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the secret and validate the code
|
||||||
|
func CheckTOTP(totpEncryptionKey []byte, encryptedSecretB64, nonceB64, code string) (bool, error) {
|
||||||
|
if len(totpEncryptionKey) != 32 {
|
||||||
|
log.Printf("%s (key=\"%s\")", fbErrors.ErrInvalidEncryptionKey.Error(), string(totpEncryptionKey))
|
||||||
|
return false, fbErrors.ErrInvalidEncryptionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, err := DecryptSymmetric(totpEncryptionKey, encryptedSecretB64, nonceB64)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return totp.Validate(code, secret), nil
|
||||||
|
}
|
||||||
|
|||||||
@ -23,6 +23,8 @@ const (
|
|||||||
type User struct {
|
type User struct {
|
||||||
ID uint `storm:"id,increment" json:"id"`
|
ID uint `storm:"id,increment" json:"id"`
|
||||||
Username string `storm:"unique" json:"username"`
|
Username string `storm:"unique" json:"username"`
|
||||||
|
TOTPSecret string `json:"totpSecret"`
|
||||||
|
TOTPNonce string `json:"totpNonce"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
Locale string `json:"locale"`
|
Locale string `json:"locale"`
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user