|
@@ -1,11 +1,13 @@
|
|
|
package controller
|
|
|
|
|
|
import (
|
|
|
- "context"
|
|
|
+ "bytes"
|
|
|
+ "encoding/base64"
|
|
|
"encoding/json"
|
|
|
"errors"
|
|
|
"fmt"
|
|
|
- "github.com/gravitl/netmaker/db"
|
|
|
+ "github.com/pquerna/otp"
|
|
|
+ "image/png"
|
|
|
"net/http"
|
|
|
"reflect"
|
|
|
"time"
|
|
@@ -20,6 +22,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 +38,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/{username}/auth/init-totp", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(initiateTOTPSetup)))).Methods(http.MethodPost)
|
|
|
+ r.HandleFunc("/api/users/{username}/auth/complete-totp", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(completeTOTPSetup)))).Methods(http.MethodPost)
|
|
|
+ r.HandleFunc("/api/users/{username}/auth/verify-totp", logic.PreAuthCheck(logic.ContinueIfUserMatch(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 +362,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 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 +434,201 @@ 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 400 {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 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
|
|
|
+ }
|
|
|
+
|
|
|
+ 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(),
|
|
|
+ OTPAuthURLSignature: logic.GenerateOTPAuthURLSignature(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 400 {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
|
|
|
+ }
|
|
|
+
|
|
|
+ if !logic.VerifyOTPAuthURL(req.OTPAuthURL, req.OTPAuthURLSignature) {
|
|
|
+ err = fmt.Errorf("otp auth url signature mismatch")
|
|
|
+ logger.Log(0, err.Error())
|
|
|
+ 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 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
|
|
|
+ }
|
|
|
+
|
|
|
+ otpAuthURL, err := otp.NewKeyFromURL(req.OTPAuthURL)
|
|
|
+ if err != nil {
|
|
|
+ err = fmt.Errorf("error parsing otp auth url: %v", err)
|
|
|
+ logger.Log(0, err.Error())
|
|
|
+ logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ totpSecret := otpAuthURL.Secret()
|
|
|
+
|
|
|
+ if totp.Validate(req.TOTP, totpSecret) {
|
|
|
+ user.IsMFAEnabled = true
|
|
|
+ user.TOTPSecret = totpSecret
|
|
|
+ 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 otp", 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 400 {object} models.ErrorResponse
|
|
|
+// @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 !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 otp")
|
|
|
+ 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 +801,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)
|
|
@@ -884,6 +1087,14 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
|
|
|
return
|
|
|
|
|
|
}
|
|
|
+
|
|
|
+ if logic.IsMFAEnforced() && user.IsMFAEnabled && !userchange.IsMFAEnabled {
|
|
|
+ err = errors.New("mfa is enforced, user cannot unset their own mfa")
|
|
|
+ slog.Error("failed to update user", "caller", caller.UserName, "attempted to update user", username, "error", err)
|
|
|
+ logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
if servercfg.IsPro {
|
|
|
// user cannot update his own roles and groups
|
|
|
if len(user.NetworkRoles) != len(userchange.NetworkRoles) || !reflect.DeepEqual(user.NetworkRoles, userchange.NetworkRoles) {
|
|
@@ -900,7 +1111,6 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
|
|
|
return
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
}
|
|
|
if ismaster {
|
|
|
if user.PlatformRoleID != models.SuperAdminRole && userchange.PlatformRoleID == models.SuperAdminRole {
|
|
@@ -920,6 +1130,11 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
|
|
|
(&schema.UserAccessToken{UserName: user.UserName}).DeleteAllUserTokens(r.Context())
|
|
|
}
|
|
|
oldUser := *user
|
|
|
+ if ismaster {
|
|
|
+ caller = &models.User{
|
|
|
+ UserName: logic.MasterUser,
|
|
|
+ }
|
|
|
+ }
|
|
|
e := models.Event{
|
|
|
Action: models.Update,
|
|
|
Source: models.Subject{
|