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 (
|
||||
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})
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 (
|
||||
"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
|
||||
})
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue