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