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
This commit is contained in:
Abhishek K
2024-04-03 11:20:19 +05:30
committed by GitHub
parent 3152c678e0
commit 0d4552db5e
15 changed files with 361 additions and 29 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -13,7 +13,8 @@ const oauthNotConfigured = `<!DOCTYPE html><html>
const userNotAllowed = `<!DOCTYPE html><html>
<body>
<h3>Only Admins are allowed to access Dashboard.</h3>
<p>Non-Admins can access the netmaker networks using <a href="https://docs.netmaker.io/pro/rac.html" target="_blank" rel="noopener">RemoteAccessClient.</a></p>
<h3>Furthermore, Admin has to approve your identity to have access to netmaker networks</h3>
<p>Once your identity is approved, Non-Admins can access the netmaker networks using <a href="https://docs.netmaker.io/pro/rac.html" target="_blank" rel="noopener">RemoteAccessClient.</a></p>
</body>
</html>
`
@@ -23,6 +24,18 @@ const userNotFound = `<!DOCTYPE html><html>
</body>
</html>`
const somethingwentwrong = `<!DOCTYPE html><html>
<body>
<h3>Something went wrong. Contact Admin</h3>
</body>
</html>`
const notallowedtosignup = `<!DOCTYPE html><html>
<body>
<h3>You are not allowed to SignUp.</h3>
</body>
</html>`
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))
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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())
// check if user approval is already pending
if logic.IsPendingUser(userClaims.getUserName()) {
handleOauthUserNotAllowed(w)
return
}
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("")
newPass, fetchErr := FetchPassValue("")
if fetchErr != nil {
return
}
jwt, jwtErr := logic.VerifyAuthRequest(models.UserAuthParams{
UserName: userClaims.getUserName(),
UserName: user.UserName,
Password: newPass,
})
if jwtErr != nil {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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=*
# "<azure-ad|github|google|oidc>"
AUTH_PROVIDER=
# "<client id of your oauth provider>"

View File

@@ -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}"

View File

@@ -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
}