This commit is contained in:
Khashayar Khosromoradi 2025-04-30 09:31:28 +00:00 committed by GitHub
commit 55f792c121
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 897 additions and 57 deletions

View File

@ -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
}

View File

@ -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")

View File

@ -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",

View File

@ -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': {}

View File

@ -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,
},
});
}

View 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>

View File

@ -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<string, any>([
["share-delete", ShareDelete],
["deleteUser", DeleteUser],
["discardEditorChanges", DiscardEditorChanges],
["otp", Otp],
]);
watch(currentPromptName, (newValue) => {

View 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>

View 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;
}

View File

@ -17,6 +17,7 @@
@import "./mobile.css";
@import "./epubReader.css";
@import "./mdPreview.css";
@import "./otp-modal.css";
/* For testing only
:focus {

View File

@ -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",

View File

@ -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;
}

View File

@ -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<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 {
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}`,

View File

@ -1,5 +1,6 @@
<template>
<div id="login" :class="{ recaptcha: recaptcha }">
<prompts></prompts>
<form @submit="submit">
<img :src="logoURL" alt="File Browser" />
<h1>{{ name }}</h1>
@ -43,6 +44,8 @@
<script setup lang="ts">
import { StatusError } from "@/api/utils";
import { useLayoutStore } from "@/stores/layout";
import Prompts from "@/components/prompts/Prompts.vue";
import * as auth from "@/utils/auth";
import {
name,
@ -65,6 +68,7 @@ const passwordConfirm = ref<string>("");
const route = useRoute();
const router = useRouter();
const { t } = useI18n({});
const layoutStore = useLayoutStore();
// Define functions
const toggleMode = () => (createMode.value = !createMode.value);
@ -97,11 +101,29 @@ const submit = async (event: Event) => {
if (createMode.value) {
await auth.signup(username.value, password.value);
}
await auth.login(username.value, password.value, captcha);
router.push({ path: redirect });
const res = await auth.login(username.value, password.value, captcha);
if (res.otp) {
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) {
// console.error(e);
console.error(e);
if (e instanceof StatusError) {
if (e.status === 409) {
error.value = t("login.usernameTaken");

View File

@ -74,6 +74,8 @@
</div>
</form>
</div>
<profile-2fa />
</div>
</template>
@ -84,6 +86,7 @@ import { users as api } from "@/api";
import Languages from "@/components/settings/Languages.vue";
import { computed, inject, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import Profile2fa from "@/components/settings/Profile2FA.vue";
const layoutStore = useLayoutStore();
const authStore = useAuthStore();

View File

@ -99,6 +99,7 @@ const fetchData = async () => {
rules: [],
lockPassword: false,
id: 0,
otpEnabled: false,
};
} else {
const id = Array.isArray(route.params.id)

2
go.mod
View File

@ -16,6 +16,7 @@ require (
github.com/mholt/archiver/v3 v3.5.1
github.com/mitchellh/go-homedir v1.1.0
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/spf13/afero v1.11.0
github.com/spf13/cobra v1.8.1
@ -35,6 +36,7 @@ require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/asticode/go-astikit v0.42.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/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect

5
go.sum
View File

@ -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.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c=
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/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=
@ -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/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/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/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=

View File

@ -17,9 +17,15 @@ import (
)
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 {
ID uint `json:"id"`
Locale string `json:"locale"`
@ -30,6 +36,7 @@ type userInfo struct {
LockPassword bool `json:"lockPassword"`
HideDotfiles bool `json:"hideDotfiles"`
DateFormat bool `json:"dateFormat"`
OTPEnabled bool `json:"otpEnabled"`
}
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) {
auther, err := d.store.Auth.Get(d.settings.AuthMethod)
if err != nil {
@ -117,6 +124,10 @@ func loginHandler(tokenExpireTime time.Duration) handleFunc {
return http.StatusInternalServerError, err
}
if user.TOTPSecret != "" {
return printTOTPToken(w, r, d, user, totpLoginTokenExpireTime)
}
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,
HideDotfiles: user.HideDotfiles,
DateFormat: user.DateFormat,
OTPEnabled: user.TOTPSecret != "",
},
RegisteredClaims: jwt.RegisteredClaims{
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
}
w.Header().Set("Content-Type", "text/plain")
if _, err := w.Write([]byte(signed)); err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
return renderJSON(w, nil, loginResponse{Token: signed, OTP: false})
}

View File

@ -48,8 +48,9 @@ func NewHandler(
api := r.PathPrefix("/api").Subrouter()
tokenExpirationTime := server.GetTokenExpirationTime(DefaultTokenExpirationTime)
api.Handle("/login", monkey(loginHandler(tokenExpirationTime), ""))
tokenExpirationTime, totpExpTime := server.GetTokenExpirationTime(DefaultTokenExpirationTime, DefaultTOTPTokenExpirationTime)
api.Handle("/login", monkey(loginHandler(tokenExpirationTime, totpExpTime), ""))
api.Handle("/login/otp", monkey(verifyTOTPHandler(tokenExpirationTime), ""))
api.Handle("/signup", monkey(signupHandler, ""))
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(userGetHandler, "")).Methods("GET")
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(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")

110
http/totp.go Normal file
View 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})
}

View File

@ -3,12 +3,14 @@ package http
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"sort"
"strconv"
"github.com/gorilla/mux"
"github.com/pquerna/otp/totp"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@ -18,6 +20,7 @@ import (
var (
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules"}
TOTPIssuer = "FileBrowser"
)
type modifyUserRequest struct {
@ -25,6 +28,22 @@ type modifyUserRequest struct {
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) {
vars := mux.Vars(r)
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 {
u.Password = ""
u.TOTPSecret = ""
u.TOTPNonce = ""
}
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.TOTPSecret = ""
u.TOTPNonce = ""
if !d.user.Perm.Admin {
u.Scope = ""
}
@ -206,3 +229,93 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
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
})

View File

@ -36,20 +36,22 @@ func (s *Settings) GetRules() []rules.Rule {
// Server specific settings.
type Server struct {
Root string `json:"root"`
BaseURL string `json:"baseURL"`
Socket string `json:"socket"`
TLSKey string `json:"tlsKey"`
TLSCert string `json:"tlsCert"`
Port string `json:"port"`
Address string `json:"address"`
Log string `json:"log"`
EnableThumbnails bool `json:"enableThumbnails"`
ResizePreview bool `json:"resizePreview"`
EnableExec bool `json:"enableExec"`
TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
AuthHook string `json:"authHook"`
TokenExpirationTime string `json:"tokenExpirationTime"`
Root string `json:"root"`
BaseURL string `json:"baseURL"`
Socket string `json:"socket"`
TLSKey string `json:"tlsKey"`
TLSCert string `json:"tlsCert"`
Port string `json:"port"`
Address string `json:"address"`
Log string `json:"log"`
EnableThumbnails bool `json:"enableThumbnails"`
ResizePreview bool `json:"resizePreview"`
EnableExec bool `json:"enableExec"`
TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
AuthHook string `json:"authHook"`
TokenExpirationTime string `json:"tokenExpirationTime"`
TOTPTokenExpirationTime string `json:"totpTokenExpirationTime"`
TOTPEncryptionKey []byte `json:"totpEncryptionKey"`
}
// Clean cleans any variables that might need cleaning.
@ -57,17 +59,21 @@ func (s *Server) Clean() {
s.BaseURL = strings.TrimSuffix(s.BaseURL, "/")
}
func (s *Server) GetTokenExpirationTime(fallback time.Duration) time.Duration {
if s.TokenExpirationTime == "" {
return fallback
func (s *Server) GetTokenExpirationTime(tokenFB, totpFB time.Duration) (time.Duration, time.Duration) {
getTokenDuration := func(v string, fb time.Duration) time.Duration {
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)
if err != nil {
log.Printf("[WARN] Failed to parse tokenExpirationTime: %v", err)
return fallback
}
return duration
return getTokenDuration(s.TokenExpirationTime, tokenFB), getTokenDuration(s.TOTPTokenExpirationTime, totpFB)
}
// GenerateKey generates a key of 512 bits.

View File

@ -1,7 +1,17 @@
package users
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"io"
"log"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
fbErrors "github.com/filebrowser/filebrowser/v2/errors"
)
// HashPwd hashes a password.
@ -15,3 +25,79 @@ func CheckPwd(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
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
}

View File

@ -23,6 +23,8 @@ const (
type User struct {
ID uint `storm:"id,increment" json:"id"`
Username string `storm:"unique" json:"username"`
TOTPSecret string `json:"totpSecret"`
TOTPNonce string `json:"totpNonce"`
Password string `json:"password"`
Scope string `json:"scope"`
Locale string `json:"locale"`