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
pull/3885/head
KhashayarKhm 2025-04-29 11:19:27 +03:30
parent 7f8467cd10
commit b233d47459
5 changed files with 257 additions and 18 deletions

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

109
http/totp.go Normal file
View File

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

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

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