From 0d4552db5efb64675ff59f735bcd071fe1b21a0f Mon Sep 17 00:00:00 2001 From: Abhishek K <32607604+abhishek9686@users.noreply.github.com> Date: Wed, 3 Apr 2024 11:20:19 +0530 Subject: [PATCH] NET-1064: Oauth User SignUp Approval Flow (#2874) * add pending users api * insert user to pending users on first time oauth login * add pending user check on headless login * fix conflicting apis * no records error * add allowed emails domains for oauth singup to config * check if user is allowed to signup --- auth/auth.go | 28 ++++++- auth/azure-ad.go | 27 ++++++- auth/error.go | 27 ++++++- auth/github.go | 27 ++++++- auth/google.go | 27 ++++++- auth/headless_callback.go | 21 ++++-- auth/oidc.go | 27 ++++++- config/config.go | 1 + controllers/user.go | 139 +++++++++++++++++++++++++++++++++++ database/database.go | 4 +- logic/jwts.go | 1 - logic/users.go | 44 +++++++++++ scripts/netmaker.default.env | 4 +- scripts/nm-quick.sh | 2 +- servercfg/serverconf.go | 11 +++ 15 files changed, 361 insertions(+), 29 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index 61bbdda1..f139c2c4 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -75,7 +75,7 @@ func InitializeAuthProvider() string { if functions == nil { return "" } - var _, err = fetchPassValue(logic.RandomString(64)) + var _, err = FetchPassValue(logic.RandomString(64)) if err != nil { logger.Log(0, err.Error()) return "" @@ -156,7 +156,7 @@ func HandleAuthLogin(w http.ResponseWriter, r *http.Request) { // IsOauthUser - returns func IsOauthUser(user *models.User) error { - var currentValue, err = fetchPassValue("") + var currentValue, err = FetchPassValue("") if err != nil { return err } @@ -246,7 +246,7 @@ func addUser(email string) error { slog.Error("error checking for existence of admin user during OAuth login for", "email", email, "error", err) return err } // generate random password to adapt to current model - var newPass, fetchErr = fetchPassValue("") + var newPass, fetchErr = FetchPassValue("") if fetchErr != nil { return fetchErr } @@ -272,7 +272,7 @@ func addUser(email string) error { return nil } -func fetchPassValue(newValue string) (string, error) { +func FetchPassValue(newValue string) (string, error) { type valueHolder struct { Value string `json:"value" bson:"value"` @@ -334,3 +334,23 @@ func isStateCached(state string) bool { _, err := netcache.Get(state) return err == nil || strings.Contains(err.Error(), "expired") } + +// isEmailAllowed - checks if email is allowed to signup +func isEmailAllowed(email string) bool { + allowedDomains := servercfg.GetAllowedEmailDomains() + domains := strings.Split(allowedDomains, ",") + if len(domains) == 1 && domains[0] == "*" { + return true + } + emailParts := strings.Split(email, "@") + if len(emailParts) < 2 { + return false + } + baseDomainOfEmail := emailParts[1] + for _, domain := range domains { + if domain == baseDomainOfEmail { + return true + } + } + return false +} diff --git a/auth/azure-ad.go b/auth/azure-ad.go index f6f565fb..7d6d0eb1 100644 --- a/auth/azure-ad.go +++ b/auth/azure-ad.go @@ -7,6 +7,7 @@ import ( "io" "net/http" + "github.com/gravitl/netmaker/database" "github.com/gravitl/netmaker/logger" "github.com/gravitl/netmaker/logic" "github.com/gravitl/netmaker/models" @@ -60,9 +61,29 @@ func handleAzureCallback(w http.ResponseWriter, r *http.Request) { handleOauthNotConfigured(w) return } + if !isEmailAllowed(content.UserPrincipalName) { + handleOauthUserNotAllowedToSignUp(w) + return + } + // check if user approval is already pending + if logic.IsPendingUser(content.UserPrincipalName) { + handleOauthUserNotAllowed(w) + return + } _, err = logic.GetUser(content.UserPrincipalName) - if err != nil { // user must not exists, so try to make one - if err = addUser(content.UserPrincipalName); err != nil { + if err != nil { + if database.IsEmptyRecord(err) { // user must not exist, so try to make one + err = logic.InsertPendingUser(&models.User{ + UserName: content.UserPrincipalName, + }) + if err != nil { + handleSomethingWentWrong(w) + return + } + handleOauthUserNotAllowed(w) + return + } else { + handleSomethingWentWrong(w) return } } @@ -75,7 +96,7 @@ func handleAzureCallback(w http.ResponseWriter, r *http.Request) { handleOauthUserNotAllowed(w) return } - var newPass, fetchErr = fetchPassValue("") + var newPass, fetchErr = FetchPassValue("") if fetchErr != nil { return } diff --git a/auth/error.go b/auth/error.go index b982bc98..c5bd0e74 100644 --- a/auth/error.go +++ b/auth/error.go @@ -13,7 +13,8 @@ const oauthNotConfigured = ` const userNotAllowed = `

Only Admins are allowed to access Dashboard.

-

Non-Admins can access the netmaker networks using RemoteAccessClient.

+

Furthermore, Admin has to approve your identity to have access to netmaker networks

+

Once your identity is approved, Non-Admins can access the netmaker networks using RemoteAccessClient.

` @@ -23,6 +24,18 @@ const userNotFound = ` ` +const somethingwentwrong = ` + +

Something went wrong. Contact Admin

+ +` + +const notallowedtosignup = ` + +

You are not allowed to SignUp.

+ +` + func handleOauthUserNotFound(response http.ResponseWriter) { response.Header().Set("Content-Type", "text/html; charset=utf-8") response.WriteHeader(http.StatusNotFound) @@ -35,9 +48,21 @@ func handleOauthUserNotAllowed(response http.ResponseWriter) { response.Write([]byte(userNotAllowed)) } +func handleOauthUserNotAllowedToSignUp(response http.ResponseWriter) { + response.Header().Set("Content-Type", "text/html; charset=utf-8") + response.WriteHeader(http.StatusForbidden) + response.Write([]byte(notallowedtosignup)) +} + // handleOauthNotConfigured - returns an appropriate html page when oauth is not configured on netmaker server but an oauth login was attempted func handleOauthNotConfigured(response http.ResponseWriter) { response.Header().Set("Content-Type", "text/html; charset=utf-8") response.WriteHeader(http.StatusInternalServerError) response.Write([]byte(oauthNotConfigured)) } + +func handleSomethingWentWrong(response http.ResponseWriter) { + response.Header().Set("Content-Type", "text/html; charset=utf-8") + response.WriteHeader(http.StatusInternalServerError) + response.Write([]byte(somethingwentwrong)) +} diff --git a/auth/github.go b/auth/github.go index 4bcc6753..50f9e78e 100644 --- a/auth/github.go +++ b/auth/github.go @@ -7,6 +7,7 @@ import ( "io" "net/http" + "github.com/gravitl/netmaker/database" "github.com/gravitl/netmaker/logger" "github.com/gravitl/netmaker/logic" "github.com/gravitl/netmaker/models" @@ -60,9 +61,29 @@ func handleGithubCallback(w http.ResponseWriter, r *http.Request) { handleOauthNotConfigured(w) return } + if !isEmailAllowed(content.Login) { + handleOauthUserNotAllowedToSignUp(w) + return + } + // check if user approval is already pending + if logic.IsPendingUser(content.Login) { + handleOauthUserNotAllowed(w) + return + } _, err = logic.GetUser(content.Login) - if err != nil { // user must not exist, so try to make one - if err = addUser(content.Login); err != nil { + if err != nil { + if database.IsEmptyRecord(err) { // user must not exist, so try to make one + err = logic.InsertPendingUser(&models.User{ + UserName: content.Login, + }) + if err != nil { + handleSomethingWentWrong(w) + return + } + handleOauthUserNotAllowed(w) + return + } else { + handleSomethingWentWrong(w) return } } @@ -75,7 +96,7 @@ func handleGithubCallback(w http.ResponseWriter, r *http.Request) { handleOauthUserNotAllowed(w) return } - var newPass, fetchErr = fetchPassValue("") + var newPass, fetchErr = FetchPassValue("") if fetchErr != nil { return } diff --git a/auth/google.go b/auth/google.go index e61ab4c7..e77c7d45 100644 --- a/auth/google.go +++ b/auth/google.go @@ -8,6 +8,7 @@ import ( "net/http" "time" + "github.com/gravitl/netmaker/database" "github.com/gravitl/netmaker/logger" "github.com/gravitl/netmaker/logic" "github.com/gravitl/netmaker/models" @@ -62,9 +63,29 @@ func handleGoogleCallback(w http.ResponseWriter, r *http.Request) { handleOauthNotConfigured(w) return } + if !isEmailAllowed(content.Email) { + handleOauthUserNotAllowedToSignUp(w) + return + } + // check if user approval is already pending + if logic.IsPendingUser(content.Email) { + handleOauthUserNotAllowed(w) + return + } _, err = logic.GetUser(content.Email) - if err != nil { // user must not exists, so try to make one - if err = addUser(content.Email); err != nil { + if err != nil { + if database.IsEmptyRecord(err) { // user must not exist, so try to make one + err = logic.InsertPendingUser(&models.User{ + UserName: content.Email, + }) + if err != nil { + handleSomethingWentWrong(w) + return + } + handleOauthUserNotAllowed(w) + return + } else { + handleSomethingWentWrong(w) return } } @@ -77,7 +98,7 @@ func handleGoogleCallback(w http.ResponseWriter, r *http.Request) { handleOauthUserNotAllowed(w) return } - var newPass, fetchErr = fetchPassValue("") + var newPass, fetchErr = FetchPassValue("") if fetchErr != nil { return } diff --git a/auth/headless_callback.go b/auth/headless_callback.go index 72c0c500..d76704b7 100644 --- a/auth/headless_callback.go +++ b/auth/headless_callback.go @@ -50,19 +50,24 @@ func HandleHeadlessSSOCallback(w http.ResponseWriter, r *http.Request) { return } - _, err = logic.GetUser(userClaims.getUserName()) - if err != nil { // user must not exists, so try to make one - if err = addUser(userClaims.getUserName()); err != nil { - logger.Log(1, "could not create new user: ", userClaims.getUserName()) - return - } + // check if user approval is already pending + if logic.IsPendingUser(userClaims.getUserName()) { + handleOauthUserNotAllowed(w) + return } - newPass, fetchErr := fetchPassValue("") + user, err := logic.GetUser(userClaims.getUserName()) + if err != nil { + response := returnErrTemplate("", "user not found", state, reqKeyIf) + w.WriteHeader(http.StatusForbidden) + w.Write(response) + return + } + newPass, fetchErr := FetchPassValue("") if fetchErr != nil { return } jwt, jwtErr := logic.VerifyAuthRequest(models.UserAuthParams{ - UserName: userClaims.getUserName(), + UserName: user.UserName, Password: newPass, }) if jwtErr != nil { diff --git a/auth/oidc.go b/auth/oidc.go index d38ddeea..ccbe7119 100644 --- a/auth/oidc.go +++ b/auth/oidc.go @@ -7,6 +7,7 @@ import ( "time" "github.com/coreos/go-oidc/v3/oidc" + "github.com/gravitl/netmaker/database" "github.com/gravitl/netmaker/logger" "github.com/gravitl/netmaker/logic" "github.com/gravitl/netmaker/models" @@ -73,9 +74,29 @@ func handleOIDCCallback(w http.ResponseWriter, r *http.Request) { handleOauthNotConfigured(w) return } + if !isEmailAllowed(content.Email) { + handleOauthUserNotAllowedToSignUp(w) + return + } + // check if user approval is already pending + if logic.IsPendingUser(content.Email) { + handleOauthUserNotAllowed(w) + return + } _, err = logic.GetUser(content.Email) - if err != nil { // user must not exists, so try to make one - if err = addUser(content.Email); err != nil { + if err != nil { + if database.IsEmptyRecord(err) { // user must not exist, so try to make one + err = logic.InsertPendingUser(&models.User{ + UserName: content.Email, + }) + if err != nil { + handleSomethingWentWrong(w) + return + } + handleOauthUserNotAllowed(w) + return + } else { + handleSomethingWentWrong(w) return } } @@ -88,7 +109,7 @@ func handleOIDCCallback(w http.ResponseWriter, r *http.Request) { handleOauthUserNotAllowed(w) return } - var newPass, fetchErr = fetchPassValue("") + var newPass, fetchErr = FetchPassValue("") if fetchErr != nil { return } diff --git a/config/config.go b/config/config.go index 67acb117..67eb83af 100644 --- a/config/config.go +++ b/config/config.go @@ -92,6 +92,7 @@ type ServerConfig struct { JwtValidityDuration time.Duration `yaml:"jwt_validity_duration"` RacAutoDisable bool `yaml:"rac_auto_disable"` CacheEnabled string `yaml:"caching_enabled"` + AllowedEmailDomains string `yaml:"allowed_email_domains"` } // SQLConfig - Generic SQL Config diff --git a/controllers/user.go b/controllers/user.go index 9548afb7..70c911e6 100644 --- a/controllers/user.go +++ b/controllers/user.go @@ -9,6 +9,7 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/websocket" "github.com/gravitl/netmaker/auth" + "github.com/gravitl/netmaker/database" "github.com/gravitl/netmaker/logger" "github.com/gravitl/netmaker/logic" "github.com/gravitl/netmaker/models" @@ -35,6 +36,11 @@ func userHandlers(r *mux.Router) { r.HandleFunc("/api/oauth/callback", auth.HandleAuthCallback).Methods(http.MethodGet) r.HandleFunc("/api/oauth/headless", auth.HandleHeadlessSSO) r.HandleFunc("/api/oauth/register/{regKey}", auth.RegisterHostSSO).Methods(http.MethodGet) + r.HandleFunc("/api/users_pending", logic.SecurityCheck(true, http.HandlerFunc(getPendingUsers))).Methods(http.MethodGet) + r.HandleFunc("/api/users_pending", logic.SecurityCheck(true, http.HandlerFunc(deleteAllPendingUsers))).Methods(http.MethodDelete) + r.HandleFunc("/api/users_pending/user/{username}", logic.SecurityCheck(true, http.HandlerFunc(deletePendingUser))).Methods(http.MethodDelete) + r.HandleFunc("/api/users_pending/user/{username}", logic.SecurityCheck(true, http.HandlerFunc(approvePendingUser))).Methods(http.MethodPost) + } // swagger:route POST /api/users/adm/authenticate authenticate authenticateUser @@ -583,3 +589,136 @@ func socketHandler(w http.ResponseWriter, r *http.Request) { // Start handling the session go auth.SessionHandler(conn) } + +// swagger:route GET /api/users_pending user getPendingUsers +// +// Get all pending users. +// +// Schemes: https +// +// Security: +// oauth +// +// Responses: +// 200: userBodyResponse +func getPendingUsers(w http.ResponseWriter, r *http.Request) { + // set header. + w.Header().Set("Content-Type", "application/json") + + users, err := logic.ListPendingUsers() + if err != nil { + logger.Log(0, "failed to fetch users: ", err.Error()) + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) + return + } + + logic.SortUsers(users[:]) + logger.Log(2, r.Header.Get("user"), "fetched pending users") + json.NewEncoder(w).Encode(users) +} + +// swagger:route POST /api/users_pending/user/{username} user approvePendingUser +// +// approve pending user. +// +// Schemes: https +// +// Security: +// oauth +// +// Responses: +// 200: userBodyResponse +func approvePendingUser(w http.ResponseWriter, r *http.Request) { + // set header. + w.Header().Set("Content-Type", "application/json") + var params = mux.Vars(r) + username := params["username"] + users, err := logic.ListPendingUsers() + + if err != nil { + logger.Log(0, "failed to fetch users: ", err.Error()) + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) + return + } + for _, user := range users { + if user.UserName == username { + var newPass, fetchErr = auth.FetchPassValue("") + if fetchErr != nil { + logic.ReturnErrorResponse(w, r, logic.FormatError(fetchErr, "internal")) + return + } + if err = logic.CreateUser(&models.User{ + UserName: user.UserName, + Password: newPass, + }); err != nil { + logic.ReturnErrorResponse(w, r, logic.FormatError(fmt.Errorf("failed to create user: %s", err), "internal")) + return + } + err = logic.DeletePendingUser(username) + if err != nil { + logic.ReturnErrorResponse(w, r, logic.FormatError(fmt.Errorf("failed to delete pending user: %s", err), "internal")) + return + } + break + } + } + logic.ReturnSuccessResponse(w, r, "approved "+username) +} + +// swagger:route DELETE /api/users_pending/user/{username} user deletePendingUser +// +// delete pending user. +// +// Schemes: https +// +// Security: +// oauth +// +// Responses: +// 200: userBodyResponse +func deletePendingUser(w http.ResponseWriter, r *http.Request) { + // set header. + w.Header().Set("Content-Type", "application/json") + var params = mux.Vars(r) + username := params["username"] + users, err := logic.ListPendingUsers() + + if err != nil { + logger.Log(0, "failed to fetch users: ", err.Error()) + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) + return + } + for _, user := range users { + if user.UserName == username { + err = logic.DeletePendingUser(username) + if err != nil { + logic.ReturnErrorResponse(w, r, logic.FormatError(fmt.Errorf("failed to delete pending user: %s", err), "internal")) + return + } + break + } + } + logic.ReturnSuccessResponse(w, r, "deleted pending "+username) +} + +// swagger:route DELETE /api/users_pending/{username}/pending user deleteAllPendingUsers +// +// delete all pending users. +// +// Schemes: https +// +// Security: +// oauth +// +// Responses: +// 200: userBodyResponse +func deleteAllPendingUsers(w http.ResponseWriter, r *http.Request) { + // set header. + w.Header().Set("Content-Type", "application/json") + err := database.DeleteAllRecords(database.PENDING_USERS_TABLE_NAME) + if err != nil { + logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("failed to delete all pending users "+err.Error()), "internal")) + return + } + logic.ReturnSuccessResponse(w, r, "cleared all pending users") +} diff --git a/database/database.go b/database/database.go index dc6385b3..dd2b2af1 100644 --- a/database/database.go +++ b/database/database.go @@ -61,7 +61,8 @@ const ( ENROLLMENT_KEYS_TABLE_NAME = "enrollmentkeys" // HOST_ACTIONS_TABLE_NAME - table name for enrollmentkeys HOST_ACTIONS_TABLE_NAME = "hostactions" - + // PENDING_USERS_TABLE_NAME - table name for pending users + PENDING_USERS_TABLE_NAME = "pending_users" // == ERROR CONSTS == // NO_RECORD - no singular result found NO_RECORD = "no result found" @@ -144,6 +145,7 @@ func createTables() { CreateTable(HOSTS_TABLE_NAME) CreateTable(ENROLLMENT_KEYS_TABLE_NAME) CreateTable(HOST_ACTIONS_TABLE_NAME) + CreateTable(PENDING_USERS_TABLE_NAME) } func CreateTable(tableName string) error { diff --git a/logic/jwts.go b/logic/jwts.go index 6e227b59..a2b95049 100644 --- a/logic/jwts.go +++ b/logic/jwts.go @@ -106,7 +106,6 @@ func VerifyUserToken(tokenString string) (username string, issuperadmin, isadmin if err != nil { return "", false, false, err } - if user.UserName != "" { return user.UserName, user.IsSuperAdmin, user.IsAdmin, nil } diff --git a/logic/users.go b/logic/users.go index 994a71db..987556e2 100644 --- a/logic/users.go +++ b/logic/users.go @@ -75,3 +75,47 @@ func GetSuperAdmin() (models.ReturnUser, error) { } return models.ReturnUser{}, errors.New("superadmin not found") } + +func InsertPendingUser(u *models.User) error { + data, err := json.Marshal(u) + if err != nil { + return err + } + return database.Insert(u.UserName, string(data), database.PENDING_USERS_TABLE_NAME) +} + +func DeletePendingUser(username string) error { + return database.DeleteRecord(database.PENDING_USERS_TABLE_NAME, username) +} + +func IsPendingUser(username string) bool { + records, err := database.FetchRecords(database.PENDING_USERS_TABLE_NAME) + if err != nil { + return false + + } + for _, record := range records { + u := models.ReturnUser{} + err := json.Unmarshal([]byte(record), &u) + if err == nil && u.UserName == username { + return true + } + } + return false +} + +func ListPendingUsers() ([]models.ReturnUser, error) { + pendingUsers := []models.ReturnUser{} + records, err := database.FetchRecords(database.PENDING_USERS_TABLE_NAME) + if err != nil && !database.IsEmptyRecord(err) { + return pendingUsers, err + } + for _, record := range records { + u := models.ReturnUser{} + err = json.Unmarshal([]byte(record), &u) + if err == nil { + pendingUsers = append(pendingUsers, u) + } + } + return pendingUsers, nil +} diff --git a/scripts/netmaker.default.env b/scripts/netmaker.default.env index b95a3fca..cf0c8d67 100644 --- a/scripts/netmaker.default.env +++ b/scripts/netmaker.default.env @@ -53,6 +53,8 @@ TELEMETRY=on # OAuth section # ### +# only mentioned domains will be allowded to signup using oauth, by default all domains are allowed +ALLOWED_EMAIL_DOMAINS=* # "" AUTH_PROVIDER= # "" @@ -70,4 +72,4 @@ JWT_VALIDITY_DURATION=43200 # Auto disable a user's connecteds clients bassed on JWT token expiration RAC_AUTO_DISABLE=true # if turned on data will be cached on to improve performance significantly (IMPORTANT: If HA set to `false` ) -CACHING_ENABLED=true +CACHING_ENABLED=true \ No newline at end of file diff --git a/scripts/nm-quick.sh b/scripts/nm-quick.sh index ed092426..787ac01a 100755 --- a/scripts/nm-quick.sh +++ b/scripts/nm-quick.sh @@ -248,7 +248,7 @@ save_config() { ( local toCopy=("SERVER_HOST" "MASTER_KEY" "MQ_USERNAME" "MQ_PASSWORD" "INSTALL_TYPE" "NODE_ID" "DNS_MODE" "NETCLIENT_AUTO_UPDATE" "API_PORT" "CORS_ALLOWED_ORIGIN" "DISPLAY_KEYS" "DATABASE" "SERVER_BROKER_ENDPOINT" "VERBOSITY" - "DEBUG_MODE" "REST_BACKEND" "DISABLE_REMOTE_IP_CHECK" "TELEMETRY" "AUTH_PROVIDER" "CLIENT_ID" "CLIENT_SECRET" + "DEBUG_MODE" "REST_BACKEND" "DISABLE_REMOTE_IP_CHECK" "TELEMETRY" "ALLOWED_EMAIL_DOMAINS" "AUTH_PROVIDER" "CLIENT_ID" "CLIENT_SECRET" "FRONTEND_URL" "AZURE_TENANT" "OIDC_ISSUER" "EXPORTER_API_PORT" "JWT_VALIDITY_DURATION" "RAC_AUTO_DISABLE" "CACHING_ENABLED") for name in "${toCopy[@]}"; do save_config_item $name "${!name}" diff --git a/servercfg/serverconf.go b/servercfg/serverconf.go index 20bb08bd..46e35233 100644 --- a/servercfg/serverconf.go +++ b/servercfg/serverconf.go @@ -703,3 +703,14 @@ func GetEmqxAppID() string { func GetEmqxAppSecret() string { return os.Getenv("EMQX_APP_SECRET") } + +// GetAllowedEmailDomains - gets the allowed email domains for oauth signup +func GetAllowedEmailDomains() string { + allowedDomains := "*" + if os.Getenv("ALLOWED_EMAIL_DOMAINS") != "" { + allowedDomains = os.Getenv("ALLOWED_EMAIL_DOMAINS") + } else if config.Config.Server.AllowedEmailDomains != "" { + allowedDomains = config.Config.Server.AllowedEmailDomains + } + return allowedDomains +}