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.