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 setuppull/3885/head
parent
7f8467cd10
commit
b233d47459
22
http/auth.go
22
http/auth.go
|
@ -17,9 +17,15 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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 {
|
type userInfo struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Locale string `json:"locale"`
|
Locale string `json:"locale"`
|
||||||
|
@ -30,6 +36,7 @@ type userInfo struct {
|
||||||
LockPassword bool `json:"lockPassword"`
|
LockPassword bool `json:"lockPassword"`
|
||||||
HideDotfiles bool `json:"hideDotfiles"`
|
HideDotfiles bool `json:"hideDotfiles"`
|
||||||
DateFormat bool `json:"dateFormat"`
|
DateFormat bool `json:"dateFormat"`
|
||||||
|
OTPEnabled bool `json:"otpEnabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type authToken struct {
|
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) {
|
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
auther, err := d.store.Auth.Get(d.settings.AuthMethod)
|
auther, err := d.store.Auth.Get(d.settings.AuthMethod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -117,6 +124,10 @@ func loginHandler(tokenExpireTime time.Duration) handleFunc {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.TOTPSecret != "" {
|
||||||
|
return printTOTPToken(w, r, d, user, totpLoginTokenExpireTime)
|
||||||
|
}
|
||||||
|
|
||||||
return printToken(w, r, d, user, tokenExpireTime)
|
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,
|
Commands: user.Commands,
|
||||||
HideDotfiles: user.HideDotfiles,
|
HideDotfiles: user.HideDotfiles,
|
||||||
DateFormat: user.DateFormat,
|
DateFormat: user.DateFormat,
|
||||||
|
OTPEnabled: user.TOTPSecret != "",
|
||||||
},
|
},
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
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
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
return renderJSON(w, nil, loginResponse{Token: signed, OTP: false})
|
||||||
if _, err := w.Write([]byte(signed)); err != nil {
|
|
||||||
return http.StatusInternalServerError, err
|
|
||||||
}
|
|
||||||
return 0, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,8 +48,9 @@ func NewHandler(
|
||||||
|
|
||||||
api := r.PathPrefix("/api").Subrouter()
|
api := r.PathPrefix("/api").Subrouter()
|
||||||
|
|
||||||
tokenExpirationTime := server.GetTokenExpirationTime(DefaultTokenExpirationTime)
|
tokenExpirationTime, totpExpTime := server.GetTokenExpirationTime(DefaultTokenExpirationTime, DefaultTOTPTokenExpirationTime)
|
||||||
api.Handle("/login", monkey(loginHandler(tokenExpirationTime), ""))
|
api.Handle("/login", monkey(loginHandler(tokenExpirationTime, totpExpTime), ""))
|
||||||
|
api.Handle("/login/otp", monkey(verifyTOTPHandler(tokenExpirationTime), ""))
|
||||||
api.Handle("/signup", monkey(signupHandler, ""))
|
api.Handle("/signup", monkey(signupHandler, ""))
|
||||||
api.Handle("/renew", monkey(renewHandler(tokenExpirationTime), ""))
|
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(userPutHandler, "")).Methods("PUT")
|
||||||
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
|
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
|
||||||
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
|
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(resourceGetHandler, "/api/resources")).Methods("GET")
|
||||||
api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")
|
api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")
|
||||||
|
|
|
@ -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})
|
||||||
|
}
|
113
http/users.go
113
http/users.go
|
@ -3,12 +3,14 @@ package http
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/text/cases"
|
"golang.org/x/text/cases"
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
@ -18,6 +20,7 @@ import (
|
||||||
|
|
||||||
var (
|
var (
|
||||||
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules"}
|
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules"}
|
||||||
|
TOTPIssuer = "FileBrowser"
|
||||||
)
|
)
|
||||||
|
|
||||||
type modifyUserRequest struct {
|
type modifyUserRequest struct {
|
||||||
|
@ -25,6 +28,22 @@ type modifyUserRequest struct {
|
||||||
Data *users.User `json:"data"`
|
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) {
|
func getUserID(r *http.Request) (uint, error) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
i, err := strconv.ParseUint(vars["id"], 10, 0)
|
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 {
|
for _, u := range users {
|
||||||
u.Password = ""
|
u.Password = ""
|
||||||
|
u.TOTPSecret = ""
|
||||||
|
u.TOTPNonce = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(users, func(i, j int) bool {
|
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.Password = ""
|
||||||
|
u.TOTPSecret = ""
|
||||||
|
u.TOTPNonce = ""
|
||||||
if !d.user.Perm.Admin {
|
if !d.user.Perm.Admin {
|
||||||
u.Scope = ""
|
u.Scope = ""
|
||||||
}
|
}
|
||||||
|
@ -206,3 +229,93 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
|
||||||
|
|
||||||
return http.StatusOK, nil
|
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
|
||||||
|
})
|
||||||
|
|
|
@ -59,17 +59,21 @@ func (s *Server) Clean() {
|
||||||
s.BaseURL = strings.TrimSuffix(s.BaseURL, "/")
|
s.BaseURL = strings.TrimSuffix(s.BaseURL, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GetTokenExpirationTime(fallback time.Duration) time.Duration {
|
func (s *Server) GetTokenExpirationTime(tokenFB, totpFB time.Duration) (time.Duration, time.Duration) {
|
||||||
if s.TokenExpirationTime == "" {
|
getTokenDuration := func(v string, fb time.Duration) time.Duration {
|
||||||
return fallback
|
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)
|
return getTokenDuration(s.TokenExpirationTime, tokenFB), getTokenDuration(s.TOTPTokenExpirationTime, totpFB)
|
||||||
if err != nil {
|
|
||||||
log.Printf("[WARN] Failed to parse tokenExpirationTime: %v", err)
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateKey generates a key of 512 bits.
|
// GenerateKey generates a key of 512 bits.
|
||||||
|
|
Loading…
Reference in New Issue