mirror of
https://github.com/gravitl/netmaker.git
synced 2025-09-26 21:01:32 +08:00
@@ -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.
|
||||
|
@@ -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,
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
@@ -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"`
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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"
|
||||
|
Reference in New Issue
Block a user