diff --git a/controllers/server.go b/controllers/server.go index ef1ea612..eec9ed06 100644 --- a/controllers/server.go +++ b/controllers/server.go @@ -3,6 +3,7 @@ package controller import ( "encoding/json" "errors" + "github.com/google/go-cmp/cmp" "net/http" "os" "strings" @@ -275,11 +276,11 @@ func updateSettings(w http.ResponseWriter, r *http.Request) { currSettings := logic.GetServerSettings() err := logic.UpsertServerSettings(req) if err != nil { - logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("failed to udpate server settings "+err.Error()), "internal")) + logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("failed to update server settings "+err.Error()), "internal")) return } logic.LogEvent(&models.Event{ - Action: models.Update, + Action: identifySettingsUpdateAction(currSettings, req), Source: models.Subject{ ID: r.Header.Get("user"), Name: r.Header.Get("user"), @@ -324,7 +325,90 @@ func reInit(curr, new models.ServerSettings, force bool) { } } go mq.PublishPeerUpdate(false) +} +func identifySettingsUpdateAction(old, new models.ServerSettings) models.Action { + // TODO: here we are relying on the dashboard to only + // make singular updates, but it's possible that the + // API can be called to make multiple changes to the + // server settings. We should update it to log multiple + // events or create singular update APIs. + if old.MFAEnforced != new.MFAEnforced { + if new.MFAEnforced { + return models.EnforceMFA + } else { + return models.UnenforceMFA + } + } + + if old.BasicAuth != new.BasicAuth { + if new.BasicAuth { + return models.EnableBasicAuth + } else { + return models.DisableBasicAuth + } + } + + if old.Telemetry != new.Telemetry { + if new.Telemetry == "off" { + return models.DisableTelemetry + } else { + return models.EnableTelemetry + } + } + + if old.NetclientAutoUpdate != new.NetclientAutoUpdate || + old.RacRestrictToSingleNetwork != new.RacRestrictToSingleNetwork || + old.ManageDNS != new.ManageDNS || + old.DefaultDomain != new.DefaultDomain || + old.EndpointDetection != new.EndpointDetection { + return models.UpdateClientSettings + } + + if old.AllowedEmailDomains != new.AllowedEmailDomains || + old.JwtValidityDuration != new.JwtValidityDuration { + return models.UpdateAuthenticationSecuritySettings + } + + if old.Verbosity != new.Verbosity || + old.MetricsPort != new.MetricsPort || + old.MetricInterval != new.MetricInterval || + old.AuditLogsRetentionPeriodInDays != new.AuditLogsRetentionPeriodInDays { + return models.UpdateMonitoringAndDebuggingSettings + } + + if old.Theme != new.Theme { + return models.UpdateDisplaySettings + } + + if old.TextSize != new.TextSize || + old.ReducedMotion != new.ReducedMotion { + return models.UpdateAccessibilitySettings + } + + if old.EmailSenderAddr != new.EmailSenderAddr || + old.EmailSenderUser != new.EmailSenderUser || + old.EmailSenderPassword != new.EmailSenderPassword || + old.SmtpHost != new.SmtpHost || + old.SmtpPort != new.SmtpPort { + return models.UpdateSMTPSettings + } + + if old.AuthProvider != new.AuthProvider || + old.OIDCIssuer != new.OIDCIssuer || + old.ClientID != new.ClientID || + old.ClientSecret != new.ClientSecret || + old.SyncEnabled != new.SyncEnabled || + old.IDPSyncInterval != new.IDPSyncInterval || + old.GoogleAdminEmail != new.GoogleAdminEmail || + old.GoogleSACredsJson != new.GoogleSACredsJson || + old.AzureTenant != new.AzureTenant || + !cmp.Equal(old.GroupFilters, new.GroupFilters) || + cmp.Equal(old.UserFilters, new.UserFilters) { + return models.UpdateIDPSettings + } + + return models.Update } // @Summary Get feature flags for this server. diff --git a/controllers/user.go b/controllers/user.go index 852767c9..cf15eedf 100644 --- a/controllers/user.go +++ b/controllers/user.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "github.com/pquerna/otp" + "golang.org/x/crypto/bcrypt" "image/png" "net/http" "reflect" @@ -38,6 +39,7 @@ 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}/validate-identity", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(validateUserIdentity)))).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) @@ -312,38 +314,6 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) { logic.ReturnErrorResponse(response, request, logic.FormatError(errors.New("access denied to dashboard"), "unauthorized")) return } - // log user activity - logic.LogEvent(&models.Event{ - Action: models.Login, - Source: models.Subject{ - ID: user.UserName, - Name: user.UserName, - Type: models.UserSub, - }, - TriggeredBy: user.UserName, - Target: models.Subject{ - ID: models.DashboardSub.String(), - Name: models.DashboardSub.String(), - Type: models.DashboardSub, - }, - Origin: models.Dashboard, - }) - } else { - logic.LogEvent(&models.Event{ - Action: models.Login, - Source: models.Subject{ - ID: user.UserName, - Name: user.UserName, - Type: models.UserSub, - }, - TriggeredBy: user.UserName, - Target: models.Subject{ - ID: models.ClientAppSub.String(), - Name: models.ClientAppSub.String(), - Type: models.ClientAppSub, - }, - Origin: models.ClientApp, - }) } username := authRequest.UserName @@ -397,6 +367,44 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) { return } logger.Log(2, username, "was authenticated") + + // log user activity + if !user.IsMFAEnabled { + if val := request.Header.Get("From-Ui"); val == "true" { + logic.LogEvent(&models.Event{ + Action: models.Login, + Source: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + TriggeredBy: user.UserName, + Target: models.Subject{ + ID: models.DashboardSub.String(), + Name: models.DashboardSub.String(), + Type: models.DashboardSub, + }, + Origin: models.Dashboard, + }) + } else { + logic.LogEvent(&models.Event{ + Action: models.Login, + Source: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + TriggeredBy: user.UserName, + Target: models.Subject{ + ID: models.ClientAppSub.String(), + Name: models.ClientAppSub.String(), + Type: models.ClientAppSub, + }, + Origin: models.ClientApp, + }) + } + } + response.Header().Set("Content-Type", "application/json") response.Write(successJSONResponse) @@ -438,6 +446,43 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) { }() } +// @Summary Validates a user's identity against it's token. This is used by UI before a user performing a critical operation to validate the user's identity. +// @Router /api/users/{username}/validate-identity [post] +// @Tags Auth +// @Accept json +// @Param body body models.UserIdentityValidationRequest true "User Identity Validation Request" +// @Success 200 {object} models.SuccessResponse +// @Failure 400 {object} models.ErrorResponse +func validateUserIdentity(w http.ResponseWriter, r *http.Request) { + username := r.Header.Get("user") + + var req models.UserIdentityValidationRequest + 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 + } + + var resp models.UserIdentityValidationResponse + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) + if err != nil { + logic.ReturnSuccessResponseWithJson(w, r, resp, "user identity validation failed") + } else { + resp.IdentityValidated = true + logic.ReturnSuccessResponseWithJson(w, r, resp, "user identity validated") + } +} + // @Summary Initiate setting up TOTP 2FA for a user. // @Router /api/users/auth/init-totp [post] // @Tags Auth @@ -561,6 +606,22 @@ func completeTOTPSetup(w http.ResponseWriter, r *http.Request) { return } + logic.LogEvent(&models.Event{ + Action: models.EnableMFA, + Source: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + TriggeredBy: user.UserName, + Target: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + Origin: models.Dashboard, + }) + 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) @@ -628,6 +689,22 @@ func verifyTOTP(w http.ResponseWriter, r *http.Request) { return } + logic.LogEvent(&models.Event{ + Action: models.Login, + Source: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + TriggeredBy: user.UserName, + Target: models.Subject{ + ID: models.DashboardSub.String(), + Name: models.DashboardSub.String(), + Type: models.DashboardSub, + }, + Origin: models.Dashboard, + }) + logic.ReturnSuccessResponseWithJson(w, r, models.SuccessfulUserLoginResponse{ UserName: username, AuthToken: jwt, @@ -1144,8 +1221,22 @@ func updateUser(w http.ResponseWriter, r *http.Request) { UserName: logic.MasterUser, } } + action := models.Update + // TODO: here we are relying on the dashboard to only + // make singular updates, but it's possible that the + // API can be called to make multiple changes to the + // user. We should update it to log multiple events + // or create singular update APIs. + if userchange.IsMFAEnabled != user.IsMFAEnabled { + if userchange.IsMFAEnabled { + // the update API won't be used to enable MFA. + action = models.EnableMFA + } else { + action = models.DisableMFA + } + } e := models.Event{ - Action: models.Update, + Action: action, Source: models.Subject{ ID: caller.UserName, Name: caller.UserName, diff --git a/logic/extpeers.go b/logic/extpeers.go index 67a3b874..8704caff 100644 --- a/logic/extpeers.go +++ b/logic/extpeers.go @@ -484,7 +484,7 @@ func GetAllExtClientsWithStatus(status models.NodeStatus) ([]models.ExtClient, e var validExtClients []models.ExtClient for _, extClient := range extClients { node := extClient.ConvertToStaticNode() - GetNodeCheckInStatus(&node, false) + GetNodeStatus(&node, false) if node.Status == status { validExtClients = append(validExtClients, extClient) diff --git a/logic/settings.go b/logic/settings.go index 4b4361b7..8428be21 100644 --- a/logic/settings.go +++ b/logic/settings.go @@ -33,6 +33,11 @@ func UpsertServerSettings(s models.ServerSettings) error { if s.ClientSecret == Mask() { s.ClientSecret = currSettings.ClientSecret } + + if servercfg.DeployedByOperator() { + s.BasicAuth = true + } + data, err := json.Marshal(s) if err != nil { return err @@ -330,6 +335,10 @@ func GetManageDNS() bool { // IsBasicAuthEnabled - checks if basic auth has been configured to be turned off func IsBasicAuthEnabled() bool { + if servercfg.DeployedByOperator() { + return true + } + return GetServerSettings().BasicAuth } diff --git a/logic/zombie.go b/logic/zombie.go index 406c2806..862e732a 100644 --- a/logic/zombie.go +++ b/logic/zombie.go @@ -139,7 +139,10 @@ func ManageZombies(ctx context.Context, peerUpdate chan *models.Node) { if servercfg.IsAutoCleanUpEnabled() { nodes, _ := GetAllNodes() for _, node := range nodes { - if time.Since(node.LastCheckIn) > time.Minute*ZOMBIE_DELETE_TIME { + if !node.Connected { + continue + } + if time.Since(node.LastCheckIn) > time.Hour*2 { if err := DeleteNode(&node, true); err != nil { continue } @@ -168,8 +171,8 @@ func checkPendingRemovalNodes(peerUpdate chan *models.Node) { peerUpdate <- &node continue } - if servercfg.IsAutoCleanUpEnabled() { - if time.Since(node.LastCheckIn) > time.Minute*ZOMBIE_DELETE_TIME { + if servercfg.IsAutoCleanUpEnabled() && node.Connected { + if time.Since(node.LastCheckIn) > time.Hour*2 { if err := DeleteNode(&node, true); err != nil { continue } diff --git a/migrate/migrate.go b/migrate/migrate.go index 27ef8a00..22869ccd 100644 --- a/migrate/migrate.go +++ b/migrate/migrate.go @@ -536,21 +536,27 @@ func migrateToEgressV1() { } for _, node := range nodes { if node.IsEgressGateway { - egressHost, err := logic.GetHost(node.HostID.String()) + _, err := logic.GetHost(node.HostID.String()) if err != nil { continue } - for _, rangeI := range node.EgressGatewayRequest.Ranges { - e := schema.Egress{ + for _, rangeMetric := range node.EgressGatewayRequest.RangesWithMetric { + e := &schema.Egress{Range: rangeMetric.Network} + if err := e.DoesEgressRouteExists(db.WithContext(context.TODO())); err == nil { + e.Nodes[node.ID.String()] = rangeMetric.RouteMetric + e.Update(db.WithContext(context.TODO())) + continue + } + e = &schema.Egress{ ID: uuid.New().String(), - Name: fmt.Sprintf("%s egress", egressHost.Name), + Name: fmt.Sprintf("%s egress", rangeMetric.Network), Description: "", Network: node.Network, Nodes: datatypes.JSONMap{ - node.ID.String(): 256, + node.ID.String(): rangeMetric.RouteMetric, }, Tags: make(datatypes.JSONMap), - Range: rangeI, + Range: rangeMetric.Network, Nat: node.EgressGatewayRequest.NatEnabled == "yes", Status: true, CreatedBy: user.UserName, diff --git a/models/events.go b/models/events.go index 12a1d1d4..4a6e1603 100644 --- a/models/events.go +++ b/models/events.go @@ -3,21 +3,36 @@ package models type Action string const ( - Create Action = "CREATE" - Update Action = "UPDATE" - Delete Action = "DELETE" - DeleteAll Action = "DELETE_ALL" - Login Action = "LOGIN" - LogOut Action = "LOGOUT" - Connect Action = "CONNECT" - Sync Action = "SYNC" - RefreshKey Action = "REFRESH_KEY" - RefreshAllKeys Action = "REFRESH_ALL_KEYS" - SyncAll Action = "SYNC_ALL" - UpgradeAll Action = "UPGRADE_ALL" - Disconnect Action = "DISCONNECT" - JoinHostToNet Action = "JOIN_HOST_TO_NETWORK" - RemoveHostFromNet Action = "REMOVE_HOST_FROM_NETWORK" + Create Action = "CREATE" + Update Action = "UPDATE" + Delete Action = "DELETE" + DeleteAll Action = "DELETE_ALL" + Login Action = "LOGIN" + LogOut Action = "LOGOUT" + Connect Action = "CONNECT" + Sync Action = "SYNC" + RefreshKey Action = "REFRESH_KEY" + RefreshAllKeys Action = "REFRESH_ALL_KEYS" + SyncAll Action = "SYNC_ALL" + UpgradeAll Action = "UPGRADE_ALL" + Disconnect Action = "DISCONNECT" + JoinHostToNet Action = "JOIN_HOST_TO_NETWORK" + RemoveHostFromNet Action = "REMOVE_HOST_FROM_NETWORK" + EnableMFA Action = "ENABLE_MFA" + DisableMFA Action = "DISABLE_MFA" + EnforceMFA Action = "ENFORCE_MFA" + UnenforceMFA Action = "UNENFORCE_MFA" + EnableBasicAuth Action = "ENABLE_BASIC_AUTH" + DisableBasicAuth Action = "DISABLE_BASIC_AUTH" + EnableTelemetry Action = "ENABLE_TELEMETRY" + DisableTelemetry Action = "DISABLE_TELEMETRY" + UpdateClientSettings Action = "UPDATE_CLIENT_SETTINGS" + UpdateAuthenticationSecuritySettings Action = "UPDATE_AUTHENTICATION_SECURITY_SETTINGS" + UpdateMonitoringAndDebuggingSettings Action = "UPDATE_MONITORING_AND_DEBUGGING_SETTINGS" + UpdateDisplaySettings Action = "UPDATE_DISPLAY_SETTINGS" + UpdateAccessibilitySettings Action = "UPDATE_ACCESSIBILITY_SETTINGS" + UpdateSMTPSettings Action = "UPDATE_EMAIL_SETTINGS" + UpdateIDPSettings Action = "UPDATE_IDP_SETTINGS" ) type SubjectType string diff --git a/models/user_mgmt.go b/models/user_mgmt.go index 75737ea0..a16e6ec4 100644 --- a/models/user_mgmt.go +++ b/models/user_mgmt.go @@ -202,6 +202,16 @@ type UserAuthParams struct { Password string `json:"password"` } +// UserIdentityValidationRequest - user identity validation request struct +type UserIdentityValidationRequest struct { + Password string `json:"password"` +} + +// UserIdentityValidationResponse - user identity validation response struct +type UserIdentityValidationResponse struct { + IdentityValidated bool `json:"identity_validated"` +} + type UserTOTPVerificationParams struct { OTPAuthURL string `json:"otp_auth_url"` OTPAuthURLSignature string `json:"otp_auth_url_signature"` diff --git a/schema/egress.go b/schema/egress.go index d420198a..2c711f58 100644 --- a/schema/egress.go +++ b/schema/egress.go @@ -50,6 +50,10 @@ func (e *Egress) UpdateEgressStatus(ctx context.Context) error { }).Error } +func (e *Egress) DoesEgressRouteExists(ctx context.Context) error { + return db.FromContext(ctx).Table(e.Table()).Where("range = ?", e.Range).First(&e).Error +} + func (e *Egress) Create(ctx context.Context) error { return db.FromContext(ctx).Table(e.Table()).Create(&e).Error } diff --git a/servercfg/serverconf.go b/servercfg/serverconf.go index e77583e6..9a9d083f 100644 --- a/servercfg/serverconf.go +++ b/servercfg/serverconf.go @@ -646,6 +646,10 @@ func GetEmqxRestEndpoint() string { // IsBasicAuthEnabled - checks if basic auth has been configured to be turned off func IsBasicAuthEnabled() bool { + if DeployedByOperator() { + return true + } + var enabled = true //default if os.Getenv("BASIC_AUTH") != "" { enabled = os.Getenv("BASIC_AUTH") == "yes"