Skip to content

Commit 78d8bb5

Browse files
authored
updated with 2fa (#681)
1 parent 443f405 commit 78d8bb5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1582
-520
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file. For commit
66

77
**New Features**
88
- new `./filebrowser.exe setup` command for creating a config.yaml on first run. https://github.com/gtsteffaniak/filebrowser/issues/675
9+
- new 2FA/OTP support for password-based users.
10+
- `auth.password.enforcedOtp` option to enforce 2FA usage for password users.
911

1012
**Notes**:
1113
- logging uses localtime, optional UTC config added https://github.com/gtsteffaniak/filebrowser/issues/665
@@ -23,7 +25,9 @@ All notable changes to this project will be documented in this file. For commit
2325
- rename button doesn't close prompt https://github.com/gtsteffaniak/filebrowser/issues/664
2426
- webm video preview issue https://github.com/gtsteffaniak/filebrowser/issues/673
2527
- fix signup issue https://github.com/gtsteffaniak/filebrowser/issues/648
26-
#- fix default source bug (needs tests)
28+
- fix default source bug
29+
30+
![image](https://github.com/user-attachments/assets/28e4e67e-31a1-4107-9294-0e715e87b558)
2731

2832
## v0.7.4-beta
2933

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
2222

2323
1. ✅ Multiple sources support
24-
2. ✅ Login support for OIDC, password, and proxy.
24+
2. ✅ Login support for OIDC, password + 2FA, and proxy.
2525
3. ✅ Revamped UI
2626
4. ✅ Simplified configuration via `config.yaml` config file.
2727
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates

backend/auth/json.go

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,38 +8,26 @@ import (
88
"os"
99
"strings"
1010

11+
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
1112
"github.com/gtsteffaniak/filebrowser/backend/database/users"
1213
"github.com/gtsteffaniak/go-logger/logger"
1314
)
1415

15-
type jsonCred struct {
16-
Password string `json:"password"`
17-
Username string `json:"username"`
18-
ReCaptcha string `json:"recaptcha"`
19-
}
20-
2116
// JSONAuth is a json implementation of an Auther.
2217
type JSONAuth struct {
2318
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
2419
}
2520

2621
// Auth authenticates the user via a json in content body.
27-
func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
28-
var cred jsonCred
29-
30-
if r.Body == nil {
31-
logger.Error("nil body error")
32-
return nil, os.ErrPermission
33-
}
34-
err := json.NewDecoder(r.Body).Decode(&cred)
35-
if err != nil {
36-
logger.Error("decode body error")
37-
return nil, os.ErrPermission
38-
}
22+
func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
23+
password := r.URL.Query().Get("password")
24+
username := r.URL.Query().Get("username")
25+
recaptcha := r.URL.Query().Get("recaptcha")
26+
totpCode := r.URL.Query().Get("code")
3927

4028
// If ReCaptcha is enabled, check the code.
41-
if a.ReCaptcha != nil && len(a.ReCaptcha.Secret) > 0 {
42-
ok, err := a.ReCaptcha.Ok(cred.ReCaptcha) //nolint:govet
29+
if auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
30+
ok, err := auther.ReCaptcha.Ok(recaptcha) //nolint:govet
4331

4432
if err != nil {
4533
return nil, err
@@ -49,16 +37,42 @@ func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User,
4937
return nil, os.ErrPermission
5038
}
5139
}
52-
u, err := userStore.Get(cred.Username)
40+
41+
user, err := userStore.Get(username)
5342
if err != nil {
5443
return nil, fmt.Errorf("unable to get user from store: %v", err)
5544
}
56-
err = users.CheckPwd(cred.Password, u.Password)
45+
err = users.CheckPwd(password, user.Password)
5746
if err != nil {
5847
return nil, err
5948
}
6049

61-
return u, nil
50+
// check for OTP for password
51+
if user.TOTPSecret != "" {
52+
if totpCode == "" {
53+
return nil, errors.ErrNoTotpProvided
54+
}
55+
err = VerifyTotpCode(user, totpCode, userStore)
56+
if err != nil {
57+
logger.Error("OTP verification failed")
58+
return nil, err
59+
}
60+
}
61+
62+
if user.LoginMethod != users.LoginMethodPassword {
63+
logger.Warningf("user %s is not allowed to login with password authentication, bypassing and updating login method", user.Username)
64+
user.LoginMethod = users.LoginMethodPassword
65+
// Perform the user update
66+
err = userStore.Update(user, true, "LoginMethod")
67+
if err != nil {
68+
logger.Debug(err.Error())
69+
}
70+
//http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
71+
//return
72+
}
73+
74+
return user, nil
75+
6276
}
6377

6478
// LoginPage tells that json auth doesn't require a login page.

backend/auth/storage.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package auth
22

33
import (
4+
"encoding/base64"
5+
6+
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
47
"github.com/gtsteffaniak/filebrowser/backend/database/users"
8+
"github.com/gtsteffaniak/go-logger/logger"
59
)
610

711
// StorageBackend is a storage backend for auth storage.
@@ -35,6 +39,10 @@ func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error)
3539
if err != nil {
3640
return nil, err
3741
}
42+
encryptionKey, err = base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
43+
if err != nil {
44+
logger.Warning("failed to decode TOTP secret, using default key. This is insecure and should be fixed.")
45+
}
3846
return store, nil
3947
}
4048

backend/auth/totp.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package auth
2+
3+
import (
4+
"crypto/aes"
5+
"crypto/cipher"
6+
"crypto/rand"
7+
"encoding/base64"
8+
"fmt"
9+
"io"
10+
"strings"
11+
"time"
12+
13+
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
14+
"github.com/gtsteffaniak/filebrowser/backend/database/users"
15+
"github.com/gtsteffaniak/go-cache/cache"
16+
"github.com/gtsteffaniak/go-logger/logger"
17+
"github.com/pquerna/otp"
18+
"github.com/pquerna/otp/totp"
19+
)
20+
21+
// Constants for TOTP (Time-based One-Time Password) configuration.
22+
const (
23+
// IssuerName is the name of the application or service.
24+
IssuerName = "FileBrowser Quantum"
25+
26+
// TokenValidTime defines the total duration a token is considered valid.
27+
// Note: The actual validation window might be slightly longer depending on the period.
28+
TokenValidTime = time.Minute * 2
29+
30+
// TOTPPeriod is the standard duration in seconds that a TOTP code is valid.
31+
TOTPPeriod uint = 30
32+
33+
// TOTPSecretSize is the byte length of the shared secret.
34+
TOTPSecretSize uint = 20
35+
36+
// TOTPDigits specifies the number of digits in the OTP code.
37+
TOTPDigits = otp.DigitsSix
38+
39+
// TOTPAlgorithm is the hashing algorithm to use.
40+
TOTPAlgorithm = otp.AlgorithmSHA1
41+
)
42+
43+
var (
44+
// TOTPSkew allows for a certain number of periods of clock drift.
45+
// We calculate it to best match the TokenValidTime.
46+
// (2 * Skew + 1) * Period >= TokenValidTime
47+
// For a 2-minute (120s) window with a 30s period, we need a Skew of 2.
48+
// (2*2 + 1) * 30s = 150s (2.5 minutes). This is the closest we can get.
49+
TOTPSkew uint = uint(TokenValidTime.Seconds()) / (2 * uint(TOTPPeriod))
50+
encryptionKey []byte
51+
TotpCache = cache.NewCache(5 * time.Minute)
52+
)
53+
54+
func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, error) {
55+
// Generate a new TOTP key using the defined constants.
56+
key, err := totp.Generate(totp.GenerateOpts{
57+
Issuer: IssuerName,
58+
AccountName: user.Username,
59+
Period: TOTPPeriod,
60+
SecretSize: TOTPSecretSize,
61+
Digits: TOTPDigits,
62+
Algorithm: TOTPAlgorithm,
63+
})
64+
if err != nil {
65+
return "", fmt.Errorf("error generating TOTP key: %w", err)
66+
}
67+
68+
secretText := key.Secret()
69+
nonce := ""
70+
if settings.Config.Auth.TotpSecret != "" {
71+
// If an encryption key is provided, encrypt the secret.
72+
secretText, nonce, err = encryptSecret(secretText, encryptionKey)
73+
if err != nil {
74+
return "", fmt.Errorf("failed to encrypt TOTP secret: %w", err)
75+
}
76+
}
77+
// set cache so verify can attempt to use it but not require it for user yet.
78+
TotpCache.Set(user.Username, secretText+"||"+nonce)
79+
url := "otpauth://totp/FileBrowser%20Quantum?secret=" + secretText
80+
return url, nil
81+
}
82+
83+
// encryptSecret uses AES-GCM to encrypt a plaintext secret.
84+
// It returns the base64-encoded ciphertext and nonce, or an error.
85+
func encryptSecret(secret string, key []byte) (string, string, error) {
86+
if len(key) != 32 {
87+
return "", "", fmt.Errorf("invalid encryption key length: must be 32 bytes")
88+
}
89+
90+
block, err := aes.NewCipher(key)
91+
if err != nil {
92+
return "", "", err
93+
}
94+
95+
gcm, err := cipher.NewGCM(block)
96+
if err != nil {
97+
return "", "", err
98+
}
99+
100+
nonce := make([]byte, gcm.NonceSize())
101+
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
102+
return "", "", err
103+
}
104+
105+
ciphertext := gcm.Seal(nil, nonce, []byte(secret), nil)
106+
107+
return base64.StdEncoding.EncodeToString(ciphertext), base64.StdEncoding.EncodeToString(nonce), nil
108+
}
109+
110+
// decryptSecret uses AES-GCM to decrypt a ciphertext using its key and nonce.
111+
// It returns the plaintext secret or an error.
112+
func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
113+
if len(encryptionKey) != 32 {
114+
return "", fmt.Errorf("invalid encryption key length: must be 32 bytes")
115+
}
116+
117+
ciphertext, err := base64.StdEncoding.DecodeString(b64Ciphertext)
118+
if err != nil {
119+
return "", fmt.Errorf("failed to decode ciphertext: %w", err)
120+
}
121+
122+
nonce, err := base64.StdEncoding.DecodeString(b64Nonce)
123+
if err != nil {
124+
return "", fmt.Errorf("failed to decode nonce: %w", err)
125+
}
126+
127+
block, err := aes.NewCipher(encryptionKey)
128+
if err != nil {
129+
return "", err
130+
}
131+
132+
gcm, err := cipher.NewGCM(block)
133+
if err != nil {
134+
return "", err
135+
}
136+
137+
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
138+
if err != nil {
139+
// This error often means the key is wrong or the data is corrupt
140+
return "", fmt.Errorf("failed to decrypt secret: %w", err)
141+
}
142+
143+
return string(plaintext), nil
144+
}
145+
146+
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
147+
// get data from cache
148+
cachedSecret, found := TotpCache.Get(user.Username).(string)
149+
if !found && user.TOTPSecret == "" {
150+
return fmt.Errorf("OTP token not found in cache, please generate a new one")
151+
}
152+
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
153+
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
154+
if found {
155+
splitSecret := strings.Split(cachedSecret, "||")
156+
if len(splitSecret) < 2 {
157+
return fmt.Errorf("invalid cached OTP token format")
158+
}
159+
totpSecret = splitSecret[0]
160+
totpNonce = splitSecret[1]
161+
}
162+
secretToValidate := totpSecret
163+
if settings.Config.Auth.TotpSecret != "" {
164+
// If an encryption key is configured, we must decrypt the secret first.
165+
if totpNonce == "" {
166+
return fmt.Errorf("secret is encrypted but nonce is missing")
167+
}
168+
decryptedSecret, err := decryptSecret(totpSecret, totpNonce)
169+
if err != nil {
170+
return fmt.Errorf("failed to decrypt TOTP secret: %w", err)
171+
}
172+
secretToValidate = decryptedSecret
173+
}
174+
// --- END: ADD THIS DECRYPTION LOGIC ---
175+
176+
// Validate the token using the (now plaintext) secret.
177+
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
178+
Period: TOTPPeriod,
179+
Skew: TOTPSkew,
180+
Digits: TOTPDigits,
181+
Algorithm: TOTPAlgorithm,
182+
})
183+
if err != nil {
184+
logger.Errorf("error during TOTP validation: %v", err)
185+
}
186+
if !valid {
187+
return fmt.Errorf("invalid OTP token")
188+
}
189+
if totpSecret != "" {
190+
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
191+
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
192+
user.OtpEnabled = true // Enable OTP for the user
193+
}
194+
// save user
195+
if err := userStore.Update(user, user.Permissions.Admin, "TOTPSecret", "TOTPNonce"); err != nil {
196+
logger.Debug("error updating user with OTP token:", err)
197+
return fmt.Errorf("error updating user with OTP token: %w", err)
198+
}
199+
return nil
200+
}

backend/cmd/cli.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ func runCLI() bool {
8888
user, err := store.Users.Get(username)
8989
if err != nil {
9090
newUser := users.User{
91-
Username: username,
91+
Username: username,
92+
LoginMethod: users.LoginMethodPassword,
9293
NonAdminEditable: users.NonAdminEditable{
9394
Password: password,
9495
},
@@ -114,7 +115,13 @@ func runCLI() bool {
114115
}
115116
return false
116117
}
118+
if user.LoginMethod != users.LoginMethodPassword {
119+
logger.Warningf("user %s is not allowed to login with password authentication, bypassing and updating login method", user.Username)
120+
}
117121
user.Password = password
122+
user.TOTPSecret = "" // reset TOTP secret if it exists
123+
user.TOTPNonce = "" // reset TOTP nonce if it exists
124+
user.LoginMethod = users.LoginMethodPassword
118125
if asAdmin {
119126
user.Permissions.Admin = true
120127
}

backend/common/errors/errors.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,7 @@ var (
1818
ErrInvalidRequestParams = errors.New("invalid request params")
1919
ErrSourceIsParent = errors.New("source is parent")
2020
ErrRootUserDeletion = errors.New("user with id 1 can't be deleted")
21+
ErrNoTotpProvided = errors.New("OTP code is required for user")
22+
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
23+
ErrUnauthorized = errors.New("user unauthorized")
2124
)

0 commit comments

Comments
 (0)