Skip to content

Commit 3551e8e

Browse files
NET-1996: Add Support for TOTP Authentication. (#3517)
* feat(git): ignore run configurations; * feat(go): add support for TOTP authentication; * fix(go): api docs; * fix(go): static checks failing; * fix(go): ignore mfa enforcement for user auth; * feat(go): allow resetting mfa; * feat(go): allow resetting mfa; * feat(go): use library function; * fix(go): signature; * feat(go): allow only master user to unset user's mfa; * feat(go): set caller when master to prevent panic; * feat(go): make messages more user friendly; * fix(go): run go mod tidy; * fix(go): optimize imports; * fix(go): return unauthorized on token expiry; * fix(go): move mfa endpoints under username; * fix(go): set is mfa enabled when converting; * feat(go): allow authenticated users to use preauth apis; * feat(go): set correct header value; * feat(go): allow super-admins and admins to unset mfa; * feat(go): allow user to unset mfa if not enforced;
1 parent aca9117 commit 3551e8e

File tree

12 files changed

+419
-45
lines changed

12 files changed

+419
-45
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ controllers/data/
2222
data/
2323
.vscode/
2424
.idea/
25+
.run/
2526
netmaker.exe
2627
netmaker.code-workspace
2728
dist/

controllers/user.go

Lines changed: 237 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package controller
22

33
import (
4-
"context"
4+
"bytes"
5+
"encoding/base64"
56
"encoding/json"
67
"errors"
78
"fmt"
8-
"github.com/gravitl/netmaker/db"
9+
"github.com/pquerna/otp"
10+
"image/png"
911
"net/http"
1012
"reflect"
1113
"time"
@@ -20,6 +22,7 @@ import (
2022
"github.com/gravitl/netmaker/mq"
2123
"github.com/gravitl/netmaker/schema"
2224
"github.com/gravitl/netmaker/servercfg"
25+
"github.com/pquerna/otp/totp"
2326
"golang.org/x/exp/slog"
2427
)
2528

@@ -35,6 +38,9 @@ func userHandlers(r *mux.Router) {
3538
r.HandleFunc("/api/users/adm/transfersuperadmin/{username}", logic.SecurityCheck(true, http.HandlerFunc(transferSuperAdmin))).
3639
Methods(http.MethodPost)
3740
r.HandleFunc("/api/users/adm/authenticate", authenticateUser).Methods(http.MethodPost)
41+
r.HandleFunc("/api/users/{username}/auth/init-totp", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(initiateTOTPSetup)))).Methods(http.MethodPost)
42+
r.HandleFunc("/api/users/{username}/auth/complete-totp", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(completeTOTPSetup)))).Methods(http.MethodPost)
43+
r.HandleFunc("/api/users/{username}/auth/verify-totp", logic.PreAuthCheck(logic.ContinueIfUserMatch(http.HandlerFunc(verifyTOTP)))).Methods(http.MethodPost)
3844
r.HandleFunc("/api/users/{username}", logic.SecurityCheck(true, http.HandlerFunc(updateUser))).Methods(http.MethodPut)
3945
r.HandleFunc("/api/users/{username}", logic.SecurityCheck(true, checkFreeTierLimits(limitChoiceUsers, http.HandlerFunc(createUser)))).Methods(http.MethodPost)
4046
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) {
356362
return
357363
}
358364

359-
var successResponse = models.SuccessResponse{
360-
Code: http.StatusOK,
361-
Message: "W1R3: Device " + username + " Authorized",
362-
Response: models.SuccessfulUserLoginResponse{
363-
AuthToken: jwt,
364-
UserName: username,
365-
},
365+
var successResponse models.SuccessResponse
366+
367+
if user.IsMFAEnabled {
368+
successResponse = models.SuccessResponse{
369+
Code: http.StatusOK,
370+
Message: "W1R3: TOTP required",
371+
Response: models.PartialUserLoginResponse{
372+
UserName: username,
373+
PreAuthToken: jwt,
374+
},
375+
}
376+
} else {
377+
successResponse = models.SuccessResponse{
378+
Code: http.StatusOK,
379+
Message: "W1R3: Device " + username + " Authorized",
380+
Response: models.SuccessfulUserLoginResponse{
381+
UserName: username,
382+
AuthToken: jwt,
383+
},
384+
}
366385
}
386+
367387
// Send back the JWT
368388
successJSONResponse, jsonError := json.Marshal(successResponse)
369389
if jsonError != nil {
@@ -414,6 +434,201 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) {
414434
}()
415435
}
416436

437+
// @Summary Initiate setting up TOTP 2FA for a user.
438+
// @Router /api/users/auth/init-totp [post]
439+
// @Tags Auth
440+
// @Success 200 {object} models.SuccessResponse
441+
// @Failure 400 {object} models.ErrorResponse
442+
// @Failure 500 {object} models.ErrorResponse
443+
func initiateTOTPSetup(w http.ResponseWriter, r *http.Request) {
444+
username := r.Header.Get("user")
445+
446+
user, err := logic.GetUser(username)
447+
if err != nil {
448+
logger.Log(0, "failed to get user: ", err.Error())
449+
err = fmt.Errorf("user not found: %v", err)
450+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
451+
return
452+
}
453+
454+
if user.AuthType == models.OAuth {
455+
err = fmt.Errorf("auth type is %s, cannot process totp setup", user.AuthType)
456+
logger.Log(0, err.Error())
457+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
458+
return
459+
}
460+
461+
key, err := totp.Generate(totp.GenerateOpts{
462+
Issuer: "Netmaker",
463+
AccountName: username,
464+
})
465+
if err != nil {
466+
err = fmt.Errorf("failed to generate totp key: %v", err)
467+
logger.Log(0, err.Error())
468+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
469+
return
470+
}
471+
472+
qrCodeImg, err := key.Image(200, 200)
473+
if err != nil {
474+
err = fmt.Errorf("failed to generate totp key: %v", err)
475+
logger.Log(0, err.Error())
476+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
477+
return
478+
}
479+
480+
var qrCodePng bytes.Buffer
481+
err = png.Encode(&qrCodePng, qrCodeImg)
482+
if err != nil {
483+
err = fmt.Errorf("failed to generate totp key: %v", err)
484+
logger.Log(0, err.Error())
485+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
486+
return
487+
}
488+
489+
qrCode := "data:image/png;base64," + base64.StdEncoding.EncodeToString(qrCodePng.Bytes())
490+
491+
logic.ReturnSuccessResponseWithJson(w, r, models.TOTPInitiateResponse{
492+
OTPAuthURL: key.URL(),
493+
OTPAuthURLSignature: logic.GenerateOTPAuthURLSignature(key.URL()),
494+
QRCode: qrCode,
495+
}, "totp setup initiated")
496+
}
497+
498+
// @Summary Verify and complete setting up TOTP 2FA for a user.
499+
// @Router /api/users/auth/complete-totp [post]
500+
// @Tags Auth
501+
// @Param body body models.UserTOTPVerificationParams true "TOTP verification parameters"
502+
// @Success 200 {object} models.SuccessResponse
503+
// @Failure 400 {object} models.ErrorResponse
504+
// @Failure 500 {object} models.ErrorResponse
505+
func completeTOTPSetup(w http.ResponseWriter, r *http.Request) {
506+
username := r.Header.Get("user")
507+
508+
var req models.UserTOTPVerificationParams
509+
err := json.NewDecoder(r.Body).Decode(&req)
510+
if err != nil {
511+
logger.Log(0, "failed to decode request body: ", err.Error())
512+
err = fmt.Errorf("invalid request body: %v", err)
513+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
514+
return
515+
}
516+
517+
if !logic.VerifyOTPAuthURL(req.OTPAuthURL, req.OTPAuthURLSignature) {
518+
err = fmt.Errorf("otp auth url signature mismatch")
519+
logger.Log(0, err.Error())
520+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
521+
return
522+
}
523+
524+
user, err := logic.GetUser(username)
525+
if err != nil {
526+
logger.Log(0, "failed to get user: ", err.Error())
527+
err = fmt.Errorf("user not found: %v", err)
528+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
529+
return
530+
}
531+
532+
if user.AuthType == models.OAuth {
533+
err = fmt.Errorf("auth type is %s, cannot process totp setup", user.AuthType)
534+
logger.Log(0, err.Error())
535+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
536+
return
537+
}
538+
539+
otpAuthURL, err := otp.NewKeyFromURL(req.OTPAuthURL)
540+
if err != nil {
541+
err = fmt.Errorf("error parsing otp auth url: %v", err)
542+
logger.Log(0, err.Error())
543+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
544+
return
545+
}
546+
547+
totpSecret := otpAuthURL.Secret()
548+
549+
if totp.Validate(req.TOTP, totpSecret) {
550+
user.IsMFAEnabled = true
551+
user.TOTPSecret = totpSecret
552+
err = logic.UpsertUser(*user)
553+
if err != nil {
554+
err = fmt.Errorf("error upserting user: %v", err)
555+
logger.Log(0, err.Error())
556+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
557+
return
558+
}
559+
560+
logic.ReturnSuccessResponse(w, r, fmt.Sprintf("totp setup complete for user %s", username))
561+
} else {
562+
err = fmt.Errorf("cannot setup totp for user %s: invalid otp", username)
563+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
564+
}
565+
}
566+
567+
// @Summary Verify a user's TOTP token.
568+
// @Router /api/users/auth/verify-totp [post]
569+
// @Tags Auth
570+
// @Accept json
571+
// @Param body body models.UserTOTPVerificationParams true "TOTP verification parameters"
572+
// @Success 200 {object} models.SuccessResponse
573+
// @Failure 400 {object} models.ErrorResponse
574+
// @Failure 401 {object} models.ErrorResponse
575+
// @Failure 500 {object} models.ErrorResponse
576+
func verifyTOTP(w http.ResponseWriter, r *http.Request) {
577+
username := r.Header.Get("user")
578+
579+
var req models.UserTOTPVerificationParams
580+
err := json.NewDecoder(r.Body).Decode(&req)
581+
if err != nil {
582+
logger.Log(0, "failed to decode request body: ", err.Error())
583+
err = fmt.Errorf("invalid request body: %v", err)
584+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
585+
return
586+
}
587+
588+
user, err := logic.GetUser(username)
589+
if err != nil {
590+
logger.Log(0, "failed to get user: ", err.Error())
591+
err = fmt.Errorf("user not found: %v", err)
592+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
593+
return
594+
}
595+
596+
if !user.IsMFAEnabled {
597+
err = fmt.Errorf("mfa is disabled for user(%s), cannot process totp verification", username)
598+
logger.Log(0, err.Error())
599+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
600+
return
601+
}
602+
603+
if totp.Validate(req.TOTP, user.TOTPSecret) {
604+
jwt, err := logic.CreateUserJWT(user.UserName, user.PlatformRoleID)
605+
if err != nil {
606+
err = fmt.Errorf("error creating token: %v", err)
607+
logger.Log(0, err.Error())
608+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
609+
return
610+
}
611+
612+
// update last login time
613+
user.LastLoginTime = time.Now().UTC()
614+
err = logic.UpsertUser(*user)
615+
if err != nil {
616+
err = fmt.Errorf("error upserting user: %v", err)
617+
logger.Log(0, err.Error())
618+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
619+
return
620+
}
621+
622+
logic.ReturnSuccessResponseWithJson(w, r, models.SuccessfulUserLoginResponse{
623+
UserName: username,
624+
AuthToken: jwt,
625+
}, "W1R3: User "+username+" Authorized")
626+
} else {
627+
err = fmt.Errorf("invalid otp")
628+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "unauthorized"))
629+
}
630+
}
631+
417632
// @Summary Check if the server has a super admin
418633
// @Router /api/users/adm/hassuperadmin [get]
419634
// @Tags Users
@@ -586,18 +801,6 @@ func getUsers(w http.ResponseWriter, r *http.Request) {
586801
return
587802
}
588803

589-
for i, user := range users {
590-
// only setting num_access_tokens here, because only UI needs it.
591-
user.NumAccessTokens, err = (&schema.UserAccessToken{
592-
UserName: user.UserName,
593-
}).CountByUser(db.WithContext(context.TODO()))
594-
if err != nil {
595-
continue
596-
}
597-
598-
users[i] = user
599-
}
600-
601804
logic.SortUsers(users[:])
602805
logger.Log(2, r.Header.Get("user"), "fetched users")
603806
json.NewEncoder(w).Encode(users)
@@ -884,6 +1087,14 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
8841087
return
8851088

8861089
}
1090+
1091+
if logic.IsMFAEnforced() && user.IsMFAEnabled && !userchange.IsMFAEnabled {
1092+
err = errors.New("mfa is enforced, user cannot unset their own mfa")
1093+
slog.Error("failed to update user", "caller", caller.UserName, "attempted to update user", username, "error", err)
1094+
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
1095+
return
1096+
}
1097+
8871098
if servercfg.IsPro {
8881099
// user cannot update his own roles and groups
8891100
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) {
9001111
return
9011112
}
9021113
}
903-
9041114
}
9051115
if ismaster {
9061116
if user.PlatformRoleID != models.SuperAdminRole && userchange.PlatformRoleID == models.SuperAdminRole {
@@ -920,6 +1130,11 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
9201130
(&schema.UserAccessToken{UserName: user.UserName}).DeleteAllUserTokens(r.Context())
9211131
}
9221132
oldUser := *user
1133+
if ismaster {
1134+
caller = &models.User{
1135+
UserName: logic.MasterUser,
1136+
}
1137+
}
9231138
e := models.Event{
9241139
Action: models.Update,
9251140
Source: models.Subject{

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ require (
4646
github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e
4747
github.com/guumaster/tablewriter v0.0.10
4848
github.com/matryer/is v1.4.1
49+
github.com/pquerna/otp v1.5.0
4950
github.com/spf13/cobra v1.9.1
5051
google.golang.org/api v0.238.0
5152
gopkg.in/mail.v2 v2.3.1
@@ -59,6 +60,7 @@ require (
5960
cloud.google.com/go/auth v0.16.2 // indirect
6061
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
6162
cloud.google.com/go/compute/metadata v0.7.0 // indirect
63+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
6264
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
6365
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
6466
github.com/go-logr/logr v1.4.2 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
88
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
99
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
1010
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
11+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
12+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
1113
github.com/c-robinson/iplib v1.0.8 h1:exDRViDyL9UBLcfmlxxkY5odWX5092nPsQIykHXhIn4=
1214
github.com/c-robinson/iplib v1.0.8/go.mod h1:i3LuuFL1hRT5gFpBRnEydzw8R6yhGkF4szNDIbF8pgo=
1315
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
@@ -107,6 +109,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
107109
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
108110
github.com/posthog/posthog-go v1.5.12 h1:nxK/z5QLCFxwzxV8GNvVd4Y1wJ++zJSWMGEtzU+/HLM=
109111
github.com/posthog/posthog-go v1.5.12/go.mod h1:ZPCind3bz8xDLK0Zhvpv1fQav6WfRcQDqTMfMXmna98=
112+
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
113+
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
110114
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
111115
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
112116
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=

0 commit comments

Comments
 (0)