Parcourir la source

feat(go): add support for TOTP authentication;

Vishal Dalwadi il y a 6 mois
Parent
commit
625760ba8f
10 fichiers modifiés avec 377 ajouts et 35 suppressions
  1. 247 19
      controllers/user.go
  2. 2 0
      go.mod
  3. 6 2
      go.sum
  4. 24 14
      logic/auth.go
  5. 23 0
      logic/jwts.go
  6. 46 0
      logic/security.go
  7. 5 0
      logic/settings.go
  8. 1 0
      models/settings.go
  9. 16 0
      models/structs.go
  10. 7 0
      models/user_mgmt.go

+ 247 - 19
controllers/user.go

@@ -2,10 +2,13 @@ package controller
 
 import (
 	"context"
+	"bytes"
+	"encoding/base64"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"github.com/gravitl/netmaker/db"
+	"image/png"
 	"net/http"
 	"reflect"
 	"time"
@@ -20,6 +23,7 @@ import (
 	"github.com/gravitl/netmaker/mq"
 	"github.com/gravitl/netmaker/schema"
 	"github.com/gravitl/netmaker/servercfg"
+	"github.com/pquerna/otp/totp"
 	"golang.org/x/exp/slog"
 )
 
@@ -35,6 +39,9 @@ func userHandlers(r *mux.Router) {
 	r.HandleFunc("/api/users/adm/transfersuperadmin/{username}", logic.SecurityCheck(true, http.HandlerFunc(transferSuperAdmin))).
 		Methods(http.MethodPost)
 	r.HandleFunc("/api/users/adm/authenticate", authenticateUser).Methods(http.MethodPost)
+	r.HandleFunc("/api/users/auth/init-totp", logic.SecurityCheck(false, http.HandlerFunc(initiateTOTPSetup))).Methods(http.MethodPost)
+	r.HandleFunc("/api/users/auth/complete-totp", logic.SecurityCheck(false, http.HandlerFunc(completeTOTPSetup))).Methods(http.MethodPost)
+	r.HandleFunc("/api/users/auth/verify-totp", logic.PreAuthCheck(http.HandlerFunc(verifyTOTP))).Methods(http.MethodPost)
 	r.HandleFunc("/api/users/{username}", logic.SecurityCheck(true, http.HandlerFunc(updateUser))).Methods(http.MethodPut)
 	r.HandleFunc("/api/users/{username}", logic.SecurityCheck(true, checkFreeTierLimits(limitChoiceUsers, http.HandlerFunc(createUser)))).Methods(http.MethodPost)
 	r.HandleFunc("/api/users/{username}", logic.SecurityCheck(true, http.HandlerFunc(deleteUser))).Methods(http.MethodDelete)
@@ -356,14 +363,28 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) {
 		return
 	}
 
-	var successResponse = models.SuccessResponse{
-		Code:    http.StatusOK,
-		Message: "W1R3: Device " + username + " Authorized",
-		Response: models.SuccessfulUserLoginResponse{
-			AuthToken: jwt,
-			UserName:  username,
-		},
+	var successResponse models.SuccessResponse
+
+	if logic.IsMFAEnabled() && user.IsMFAEnabled {
+		successResponse = models.SuccessResponse{
+			Code:    http.StatusOK,
+			Message: "W1R3: TOTP required",
+			Response: models.PartialUserLoginResponse{
+				UserName:     username,
+				PreAuthToken: jwt,
+			},
+		}
+	} else {
+		successResponse = models.SuccessResponse{
+			Code:    http.StatusOK,
+			Message: "W1R3: Device " + username + " Authorized",
+			Response: models.SuccessfulUserLoginResponse{
+				UserName:  username,
+				AuthToken: jwt,
+			},
+		}
 	}
+
 	// Send back the JWT
 	successJSONResponse, jsonError := json.Marshal(successResponse)
 	if jsonError != nil {
@@ -414,6 +435,225 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) {
 	}()
 }
 
+// @Summary     Initiate setting up TOTP 2FA for a user.
+// @Router      /api/users/auth/init-totp [post]
+// @Tags        Auth
+// @Success     200 {object} models.SuccessResponse
+// @Failure     401 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func initiateTOTPSetup(w http.ResponseWriter, r *http.Request) {
+	username := r.Header.Get("user")
+
+	user, err := logic.GetUser(username)
+	if err != nil {
+		logger.Log(0, "failed to get user: ", err.Error())
+		err = fmt.Errorf("user not found: %v", err)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if !logic.IsMFAEnabled() {
+		err = fmt.Errorf("mfa is disabled, cannot process totp setup")
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if user.IsMFAEnabled {
+		err = fmt.Errorf("mfa is already enabled for user, cannot process totp setup")
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if user.AuthType == models.OAuth {
+		err = fmt.Errorf("auth type is %s, cannot process totp setup", user.AuthType)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	key, err := totp.Generate(totp.GenerateOpts{
+		Issuer:      "Netmaker",
+		AccountName: username,
+	})
+	if err != nil {
+		err = fmt.Errorf("failed to generate totp key: %v", err)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+
+	user.TOTPSecret = key.Secret()
+	err = logic.UpsertUser(*user)
+	if err != nil {
+		err = fmt.Errorf("error upserting user: %v", err)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+
+	qrCodeImg, err := key.Image(200, 200)
+	if err != nil {
+		err = fmt.Errorf("failed to generate totp key: %v", err)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+
+	var qrCodePng bytes.Buffer
+	err = png.Encode(&qrCodePng, qrCodeImg)
+	if err != nil {
+		err = fmt.Errorf("failed to generate totp key: %v", err)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+
+	qrCode := "data:image/png;base64," + base64.StdEncoding.EncodeToString(qrCodePng.Bytes())
+
+	logic.ReturnSuccessResponseWithJson(w, r, models.TOTPInitiateResponse{
+		OTPAuthURL: key.URL(),
+		QRCode:     qrCode,
+	}, "totp setup initiated")
+}
+
+// @Summary     Verify and complete setting up TOTP 2FA for a user.
+// @Router      /api/users/auth/complete-totp [post]
+// @Tags        Auth
+// @Param       body body models.UserTOTPVerificationParams true "TOTP verification parameters"
+// @Success     200 {object} models.SuccessResponse
+// @Failure     401 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func completeTOTPSetup(w http.ResponseWriter, r *http.Request) {
+	username := r.Header.Get("user")
+
+	var req models.UserTOTPVerificationParams
+	err := json.NewDecoder(r.Body).Decode(&req)
+	if err != nil {
+		logger.Log(0, "failed to decode request body: ", err.Error())
+		err = fmt.Errorf("invalid request body: %v", err)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	user, err := logic.GetUser(username)
+	if err != nil {
+		logger.Log(0, "failed to get user: ", err.Error())
+		err = fmt.Errorf("user not found: %v", err)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if !logic.IsMFAEnabled() {
+		err = fmt.Errorf("mfa is disabled, cannot process totp setup")
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if user.IsMFAEnabled {
+		err = fmt.Errorf("mfa is already enabled for user, cannot process totp setup")
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if user.AuthType == models.OAuth {
+		err = fmt.Errorf("auth type is %s, cannot process totp setup", user.AuthType)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if totp.Validate(req.TOTP, user.TOTPSecret) {
+		user.IsMFAEnabled = true
+		err = logic.UpsertUser(*user)
+		if err != nil {
+			err = fmt.Errorf("error upserting user: %v", err)
+			logger.Log(0, err.Error())
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			return
+		}
+
+		logic.ReturnSuccessResponse(w, r, fmt.Sprintf("totp setup complete for user %s", username))
+	} else {
+		err = fmt.Errorf("cannot setup totp for user %s: invalid totp", username)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+	}
+}
+
+// @Summary     Verify a user's TOTP token.
+// @Router      /api/users/auth/verify-totp [post]
+// @Tags        Auth
+// @Accept      json
+// @Param       body body models.UserTOTPVerificationParams true "TOTP verification parameters"
+// @Success     200 {object} models.SuccessResponse
+// @Failure     401 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func verifyTOTP(w http.ResponseWriter, r *http.Request) {
+	username := r.Header.Get("user")
+
+	var req models.UserTOTPVerificationParams
+	err := json.NewDecoder(r.Body).Decode(&req)
+	if err != nil {
+		logger.Log(0, "failed to decode request body: ", err.Error())
+		err = fmt.Errorf("invalid request body: %v", err)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	user, err := logic.GetUser(username)
+	if err != nil {
+		logger.Log(0, "failed to get user: ", err.Error())
+		err = fmt.Errorf("user not found: %v", err)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if !logic.IsMFAEnabled() {
+		err = fmt.Errorf("mfa is disabled, cannot process totp verification")
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if !user.IsMFAEnabled {
+		err = fmt.Errorf("mfa is disabled for user(%s), cannot process totp verification", username)
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if totp.Validate(req.TOTP, user.TOTPSecret) {
+		jwt, err := logic.CreateUserJWT(user.UserName, user.PlatformRoleID)
+		if err != nil {
+			err = fmt.Errorf("error creating token: %v", err)
+			logger.Log(0, err.Error())
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			return
+		}
+
+		// update last login time
+		user.LastLoginTime = time.Now().UTC()
+		err = logic.UpsertUser(*user)
+		if err != nil {
+			err = fmt.Errorf("error upserting user: %v", err)
+			logger.Log(0, err.Error())
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			return
+		}
+
+		logic.ReturnSuccessResponseWithJson(w, r, models.SuccessfulUserLoginResponse{
+			UserName:  username,
+			AuthToken: jwt,
+		}, "W1R3: User "+username+" Authorized")
+	} else {
+		err = fmt.Errorf("invalid totp")
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "unauthorized"))
+	}
+}
+
 // @Summary     Check if the server has a super admin
 // @Router      /api/users/adm/hassuperadmin [get]
 // @Tags        Users
@@ -586,18 +826,6 @@ func getUsers(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	for i, user := range users {
-		// only setting num_access_tokens here, because only UI needs it.
-		user.NumAccessTokens, err = (&schema.UserAccessToken{
-			UserName: user.UserName,
-		}).CountByUser(db.WithContext(context.TODO()))
-		if err != nil {
-			continue
-		}
-
-		users[i] = user
-	}
-
 	logic.SortUsers(users[:])
 	logger.Log(2, r.Header.Get("user"), "fetched users")
 	json.NewEncoder(w).Encode(users)

+ 2 - 0
go.mod

@@ -59,6 +59,7 @@ require (
 	cloud.google.com/go/auth v0.16.2 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
 	cloud.google.com/go/compute/metadata v0.7.0 // indirect
+	github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
 	github.com/go-logr/logr v1.4.2 // indirect
@@ -75,6 +76,7 @@ require (
 	github.com/jackc/puddle/v2 v2.2.2 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect
+	github.com/pquerna/otp v1.5.0 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/rogpeppe/go-internal v1.14.1 // indirect
 	github.com/seancfoley/bintree v1.3.1 // indirect

+ 6 - 2
go.sum

@@ -8,6 +8,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
 filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
 github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
 github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
+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/c-robinson/iplib v1.0.8 h1:exDRViDyL9UBLcfmlxxkY5odWX5092nPsQIykHXhIn4=
 github.com/c-robinson/iplib v1.0.8/go.mod h1:i3LuuFL1hRT5gFpBRnEydzw8R6yhGkF4szNDIbF8pgo=
 github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
@@ -56,8 +58,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
 github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
-github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
-github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
+github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
+github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
 github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e h1:XmA6L9IPRdUr28a+SK/oMchGgQy159wvzXA5tJ7l+40=
 github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e/go.mod h1:AFIo+02s+12CEg8Gzz9kzhCbmbq6JcKNrhHffCGA9z4=
 github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
@@ -107,6 +109,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posthog/posthog-go v1.5.12 h1:nxK/z5QLCFxwzxV8GNvVd4Y1wJ++zJSWMGEtzU+/HLM=
 github.com/posthog/posthog-go v1.5.12/go.mod h1:ZPCind3bz8xDLK0Zhvpv1fQav6WfRcQDqTMfMXmna98=
+github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
+github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
 github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
 github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=

+ 24 - 14
logic/auth.go

@@ -235,22 +235,32 @@ func VerifyAuthRequest(authRequest models.UserAuthParams) (string, error) {
 		return "", errors.New("incorrect credentials")
 	}
 
-	// Create a new JWT for the node
-	tokenString, err := CreateUserJWT(authRequest.UserName, result.PlatformRoleID)
-	if err != nil {
-		slog.Error("error creating jwt", "error", err)
-		return "", err
-	}
+	if IsMFAEnabled() && result.IsMFAEnabled {
+		tokenString, err := CreatePreAuthToken(authRequest.UserName)
+		if err != nil {
+			slog.Error("error creating jwt", "error", err)
+			return "", err
+		}
 
-	// update last login time
-	result.LastLoginTime = time.Now().UTC()
-	err = UpsertUser(result)
-	if err != nil {
-		slog.Error("error upserting user", "error", err)
-		return "", err
-	}
+		return tokenString, nil
+	} else {
+		// Create a new JWT for the node
+		tokenString, err := CreateUserJWT(authRequest.UserName, result.PlatformRoleID)
+		if err != nil {
+			slog.Error("error creating jwt", "error", err)
+			return "", err
+		}
 
-	return tokenString, nil
+		// update last login time
+		result.LastLoginTime = time.Now().UTC()
+		err = UpsertUser(result)
+		if err != nil {
+			slog.Error("error upserting user", "error", err)
+			return "", err
+		}
+
+		return tokenString, nil
+	}
 }
 
 // UpsertUser - updates user in the db

+ 23 - 0
logic/jwts.go

@@ -105,6 +105,18 @@ func CreateUserJWT(username string, role models.UserRoleID) (response string, er
 	return "", err
 }
 
+func CreatePreAuthToken(username string) (string, error) {
+	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
+		Issuer:    "Netmaker",
+		Subject:   fmt.Sprintf("%s", username),
+		Audience:  []string{"auth:mfa"},
+		IssuedAt:  jwt.NewNumericDate(time.Now()),
+		ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
+	})
+
+	return token.SignedString(jwtSecretKey)
+}
+
 func GetUserNameFromToken(authtoken string) (username string, err error) {
 	claims := &models.UserClaims{}
 	var tokenSplit = strings.Split(authtoken, " ")
@@ -125,6 +137,17 @@ func GetUserNameFromToken(authtoken string) (username string, err error) {
 	if err != nil {
 		return "", Unauthorized_Err
 	}
+
+	if len(claims.Audience) > 0 {
+		for _, aud := range claims.Audience {
+			// token created for mfa cannot be used for
+			// anything else.
+			if aud == "auth:mfa" {
+				return "", Unauthorized_Err
+			}
+		}
+	}
+
 	if claims.TokenType == models.AccessTokenType {
 		jti := claims.ID
 		if jti != "" {

+ 46 - 0
logic/security.go

@@ -2,6 +2,7 @@ package logic
 
 import (
 	"errors"
+	"github.com/golang-jwt/jwt/v4"
 	"net/http"
 	"strings"
 
@@ -72,6 +73,51 @@ func SecurityCheck(reqAdmin bool, next http.Handler) http.HandlerFunc {
 	}
 }
 
+func PreAuthCheck(next http.Handler) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		authHeader := r.Header.Get("Authorization")
+		headerSplits := strings.Split(authHeader, " ")
+		if len(headerSplits) != 2 {
+			ReturnErrorResponse(w, r, FormatError(Unauthorized_Err, "unauthorized"))
+			return
+		}
+
+		authToken := headerSplits[1]
+
+		var claims jwt.RegisteredClaims
+		token, err := jwt.ParseWithClaims(authToken, &claims, func(token *jwt.Token) (interface{}, error) {
+			return jwtSecretKey, nil
+		})
+		if err != nil {
+			ReturnErrorResponse(w, r, FormatError(err, "unauthorized"))
+			return
+		}
+
+		if token != nil && token.Valid {
+			if len(claims.Audience) > 0 {
+				var found bool
+				for _, aud := range claims.Audience {
+					// token created for mfa cannot be used for
+					// anything else.
+					if aud == "auth:mfa" {
+						found = true
+					}
+				}
+
+				if !found {
+					ReturnErrorResponse(w, r, FormatError(Unauthorized_Err, "unauthorized"))
+				}
+
+				r.Header.Set("user", claims.Subject)
+				next.ServeHTTP(w, r)
+			}
+		} else {
+			ReturnErrorResponse(w, r, FormatError(Unauthorized_Err, "unauthorized"))
+			return
+		}
+	}
+}
+
 // UserPermissions - checks token stuff
 func UserPermissions(reqAdmin bool, token string) (string, error) {
 	var tokenSplit = strings.Split(token, " ")

+ 5 - 0
logic/settings.go

@@ -327,6 +327,11 @@ func IsBasicAuthEnabled() bool {
 	return GetServerSettings().BasicAuth
 }
 
+// IsMFAEnabled returns whether MFA has been enabled.
+func IsMFAEnabled() bool {
+	return GetServerSettings().MFAEnabled
+}
+
 // IsEndpointDetectionEnabled - returns true if endpoint detection enabled
 func IsEndpointDetectionEnabled() bool {
 	return GetServerSettings().EndpointDetection

+ 1 - 0
models/settings.go

@@ -25,6 +25,7 @@ type ServerSettings struct {
 	Telemetry                      string   `json:"telemetry"`
 	BasicAuth                      bool     `json:"basic_auth"`
 	JwtValidityDuration            int      `json:"jwt_validity_duration"`
+	MFAEnabled                     bool     `json:"mfa_enabled"`
 	RacAutoDisable                 bool     `json:"rac_auto_disable"`
 	RacRestrictToSingleNetwork     bool     `json:"rac_restrict_to_single_network"`
 	EndpointDetection              bool     `json:"endpoint_detection"`

+ 16 - 0
models/structs.go

@@ -69,6 +69,22 @@ type SuccessfulUserLoginResponse struct {
 	AuthToken string
 }
 
+// PartialUserLoginResponse represents the response returned to the client
+// after successful username and password authentication, but before the
+// completion of TOTP authentication.
+//
+// This response includes a temporary token required to complete
+// the authentication process.
+type PartialUserLoginResponse struct {
+	UserName     string `json:"user_name"`
+	PreAuthToken string `json:"pre_auth_token"`
+}
+
+type TOTPInitiateResponse struct {
+	OTPAuthURL string `json:"otp_auth_url"`
+	QRCode     string `json:"qr_code"`
+}
+
 // Claims is  a struct that will be encoded to a JWT.
 // jwt.StandardClaims is an embedded type to provide expiry time
 type Claims struct {

+ 7 - 0
models/user_mgmt.go

@@ -157,6 +157,8 @@ type UserGroup struct {
 type User struct {
 	UserName                   string                                `json:"username" bson:"username" validate:"min=3,in_charset|email"`
 	ExternalIdentityProviderID string                                `json:"external_identity_provider_id"`
+	IsMFAEnabled               bool                                  `json:"is_mfa_enabled"`
+	TOTPSecret                 string                                `json:"totp_secret"`
 	DisplayName                string                                `json:"display_name"`
 	AccountDisabled            bool                                  `json:"account_disabled"`
 	Password                   string                                `json:"password" bson:"password" validate:"required,min=5"`
@@ -180,6 +182,7 @@ type ReturnUserWithRolesAndGroups struct {
 type ReturnUser struct {
 	UserName                   string                                `json:"username"`
 	ExternalIdentityProviderID string                                `json:"external_identity_provider_id"`
+	IsMFAEnabled               bool                                  `json:"is_mfa_enabled"`
 	DisplayName                string                                `json:"display_name"`
 	AccountDisabled            bool                                  `json:"account_disabled"`
 	IsAdmin                    bool                                  `json:"isadmin"`
@@ -199,6 +202,10 @@ type UserAuthParams struct {
 	Password string `json:"password"`
 }
 
+type UserTOTPVerificationParams struct {
+	TOTP string `json:"totp"`
+}
+
 // UserClaims - user claims struct
 type UserClaims struct {
 	Role           UserRoleID