Merge pull request #20 from TwinProduction/basic-auth
Implement basic authentication for dashboard
This commit is contained in:
		
							
								
								
									
										26
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								README.md
									
									
									
									
									
								
							| @ -102,19 +102,23 @@ Note that you can also add environment variables in the configuration file (i.e. | |||||||
| | `services[].alerts[].send-on-resolved`   | Whether to send a notification once a triggered alert is marked as resolved   | `false`        | | | `services[].alerts[].send-on-resolved`   | Whether to send a notification once a triggered alert is marked as resolved   | `false`        | | ||||||
| | `services[].alerts[].description`        | Description of the alert. Will be included in the alert sent                  | `""`           | | | `services[].alerts[].description`        | Description of the alert. Will be included in the alert sent                  | `""`           | | ||||||
| | `alerting`                               | Configuration for alerting                                                    | `{}`           | | | `alerting`                               | Configuration for alerting                                                    | `{}`           | | ||||||
| | `alerting.slack`                         | Configuration for alerts of type `slack`                                      | `""`           | | | `alerting.slack`                         | Configuration for alerts of type `slack`                                      | `{}`           | | ||||||
| | `alerting.slack.webhook-url`             | Slack Webhook URL                                                             | Required `""`  | | | `alerting.slack.webhook-url`             | Slack Webhook URL                                                             | Required `""`  | | ||||||
| | `alerting.pagerduty`                     | Configuration for alerts of type `pagerduty`                                  | `""`           | | | `alerting.pagerduty`                     | Configuration for alerts of type `pagerduty`                                  | `{}`           | | ||||||
| | `alerting.pagerduty.integration-key`     | PagerDuty Events API v2 integration key.                                      | Required `""`  | | | `alerting.pagerduty.integration-key`     | PagerDuty Events API v2 integration key.                                      | Required `""`  | | ||||||
| | `alerting.twilio`                        | Settings for alerts of type `twilio`                                          | `""`           | | | `alerting.twilio`                        | Settings for alerts of type `twilio`                                          | `{}`           | | ||||||
| | `alerting.twilio.sid`                    | Twilio account SID                                                            | Required `""`  | | | `alerting.twilio.sid`                    | Twilio account SID                                                            | Required `""`  | | ||||||
| | `alerting.twilio.token`                  | Twilio auth token                                                             | Required `""`  | | | `alerting.twilio.token`                  | Twilio auth token                                                             | Required `""`  | | ||||||
| | `alerting.twilio.from`                   | Number to send Twilio alerts from                                             | Required `""`  | | | `alerting.twilio.from`                   | Number to send Twilio alerts from                                             | Required `""`  | | ||||||
| | `alerting.twilio.to`                     | Number to send twilio alerts to                                               | Required `""`  | | | `alerting.twilio.to`                     | Number to send twilio alerts to                                               | Required `""`  | | ||||||
| | `alerting.custom`                        | Configuration for custom actions on failure or alerts                         | `""`           | | | `alerting.custom`                        | Configuration for custom actions on failure or alerts                         | `{}`           | | ||||||
| | `alerting.custom.url`                    | Custom alerting request url                                                   | Required `""`  | | | `alerting.custom.url`                    | Custom alerting request url                                                   | Required `""`  | | ||||||
| | `alerting.custom.body`                   | Custom alerting request body.                                                 | `""`           | | | `alerting.custom.body`                   | Custom alerting request body.                                                 | `""`           | | ||||||
| | `alerting.custom.headers`                | Custom alerting request headers                                               | `{}`           | | | `alerting.custom.headers`                | Custom alerting request headers                                               | `{}`           | | ||||||
|  | | `security`                               | Security configuration                                                        | `{}`           | | ||||||
|  | | `security.basic`                         | Basic authentication security configuration                                   | `{}`           | | ||||||
|  | | `security.basic.username`                | Username for Basic authentication                                             | Required `""`  | | ||||||
|  | | `security.basic.password-sha512`         | Password's SHA512 hash for Basic authentication                               | Required `""`  | | ||||||
|  |  | ||||||
|  |  | ||||||
| ### Conditions | ### Conditions | ||||||
| @ -410,3 +414,17 @@ Placeholders `[STATUS]` and `[BODY]` as well as the fields `services[].body`, `s | |||||||
| **NOTE**: `[CONNECTED] == true` does not guarantee that the service itself is healthy - it only guarantees that there's  | **NOTE**: `[CONNECTED] == true` does not guarantee that the service itself is healthy - it only guarantees that there's  | ||||||
| something at the given address listening to the given port, and that a connection to that address was successfully  | something at the given address listening to the given port, and that a connection to that address was successfully  | ||||||
| established. | established. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ### Basic authentication | ||||||
|  |  | ||||||
|  | You can require Basic authentication by leveraging the `security.basic` configuration: | ||||||
|  |  | ||||||
|  | ```yaml | ||||||
|  | security: | ||||||
|  |   basic: | ||||||
|  |     username: "john.doe" | ||||||
|  |     password-sha512: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | The example above will require that you authenticate with the username `john.doe` as well as the password `hunter2`. | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ import ( | |||||||
| 	"github.com/TwinProduction/gatus/alerting" | 	"github.com/TwinProduction/gatus/alerting" | ||||||
| 	"github.com/TwinProduction/gatus/alerting/provider" | 	"github.com/TwinProduction/gatus/alerting/provider" | ||||||
| 	"github.com/TwinProduction/gatus/core" | 	"github.com/TwinProduction/gatus/core" | ||||||
|  | 	"github.com/TwinProduction/gatus/security" | ||||||
| 	"gopkg.in/yaml.v2" | 	"gopkg.in/yaml.v2" | ||||||
| 	"io/ioutil" | 	"io/ioutil" | ||||||
| 	"log" | 	"log" | ||||||
| @ -21,6 +22,7 @@ var ( | |||||||
| 	ErrNoServiceInConfig     = errors.New("configuration file should contain at least 1 service") | 	ErrNoServiceInConfig     = errors.New("configuration file should contain at least 1 service") | ||||||
| 	ErrConfigFileNotFound    = errors.New("configuration file not found") | 	ErrConfigFileNotFound    = errors.New("configuration file not found") | ||||||
| 	ErrConfigNotLoaded       = errors.New("configuration is nil") | 	ErrConfigNotLoaded       = errors.New("configuration is nil") | ||||||
|  | 	ErrInvalidSecurityConfig = errors.New("invalid security configuration") | ||||||
| 	config                   *Config | 	config                   *Config | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @ -28,6 +30,7 @@ var ( | |||||||
| type Config struct { | type Config struct { | ||||||
| 	Metrics  bool             `yaml:"metrics"` | 	Metrics  bool             `yaml:"metrics"` | ||||||
| 	Debug    bool             `yaml:"debug"` | 	Debug    bool             `yaml:"debug"` | ||||||
|  | 	Security *security.Config `yaml:"security"` | ||||||
| 	Alerting *alerting.Config `yaml:"alerting"` | 	Alerting *alerting.Config `yaml:"alerting"` | ||||||
| 	Services []*core.Service  `yaml:"services"` | 	Services []*core.Service  `yaml:"services"` | ||||||
| } | } | ||||||
| @ -83,6 +86,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) { | |||||||
| 		err = ErrNoServiceInConfig | 		err = ErrNoServiceInConfig | ||||||
| 	} else { | 	} else { | ||||||
| 		validateAlertingConfig(config) | 		validateAlertingConfig(config) | ||||||
|  | 		validateSecurityConfig(config) | ||||||
| 		validateServicesConfig(config) | 		validateServicesConfig(config) | ||||||
| 	} | 	} | ||||||
| 	return | 	return | ||||||
| @ -98,6 +102,20 @@ func validateServicesConfig(config *Config) { | |||||||
| 	log.Printf("[config][validateServicesConfig] Validated %d services", len(config.Services)) | 	log.Printf("[config][validateServicesConfig] Validated %d services", len(config.Services)) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func validateSecurityConfig(config *Config) { | ||||||
|  | 	if config.Security != nil { | ||||||
|  | 		if config.Security.IsValid() { | ||||||
|  | 			if config.Debug { | ||||||
|  | 				log.Printf("[config][validateSecurityConfig] Basic security configuration has been validated") | ||||||
|  | 			} | ||||||
|  | 		} else { | ||||||
|  | 			// If there was an attempt to configure security, then it must mean that some confidential or private | ||||||
|  | 			// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry. | ||||||
|  | 			panic(ErrInvalidSecurityConfig) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
| func validateAlertingConfig(config *Config) { | func validateAlertingConfig(config *Config) { | ||||||
| 	if config.Alerting == nil { | 	if config.Alerting == nil { | ||||||
| 		log.Printf("[config][validateAlertingConfig] Alerting is not configured") | 		log.Printf("[config][validateAlertingConfig] Alerting is not configured") | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| package config | package config | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"fmt" | ||||||
| 	"github.com/TwinProduction/gatus/core" | 	"github.com/TwinProduction/gatus/core" | ||||||
| 	"testing" | 	"testing" | ||||||
| 	"time" | 	"time" | ||||||
| @ -217,3 +218,56 @@ services: | |||||||
| 		t.Fatal("PagerDuty alerting config should've been invalid") | 		t.Fatal("PagerDuty alerting config should've been invalid") | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func TestParseAndValidateConfigBytesWithInvalidSecurityConfig(t *testing.T) { | ||||||
|  | 	defer func() { recover() }() | ||||||
|  | 	_, _ = parseAndValidateConfigBytes([]byte(` | ||||||
|  | security: | ||||||
|  |   basic: | ||||||
|  |     username: "admin" | ||||||
|  |     password-sha512: "invalid-sha512-hash" | ||||||
|  | services: | ||||||
|  |   - name: twinnation | ||||||
|  |     url: https://twinnation.org/actuator/health | ||||||
|  |     conditions: | ||||||
|  |       - "[STATUS] == 200" | ||||||
|  | `)) | ||||||
|  | 	t.Error("Function should've panicked") | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) { | ||||||
|  | 	const expectedUsername = "admin" | ||||||
|  | 	const expectedPasswordHash = "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22" | ||||||
|  | 	config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(` | ||||||
|  | security: | ||||||
|  |   basic: | ||||||
|  |     username: "%s" | ||||||
|  |     password-sha512: "%s" | ||||||
|  | services: | ||||||
|  |   - name: twinnation | ||||||
|  |     url: https://twinnation.org/actuator/health | ||||||
|  |     conditions: | ||||||
|  |       - "[STATUS] == 200" | ||||||
|  | `, expectedUsername, expectedPasswordHash))) | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Error("No error should've been returned") | ||||||
|  | 	} | ||||||
|  | 	if config == nil { | ||||||
|  | 		t.Fatal("Config shouldn't have been nil") | ||||||
|  | 	} | ||||||
|  | 	if config.Security == nil { | ||||||
|  | 		t.Fatal("config.Security shouldn't have been nil") | ||||||
|  | 	} | ||||||
|  | 	if !config.Security.IsValid() { | ||||||
|  | 		t.Error("Security config should've been valid") | ||||||
|  | 	} | ||||||
|  | 	if config.Security.Basic == nil { | ||||||
|  | 		t.Fatal("config.Security.Basic shouldn't have been nil") | ||||||
|  | 	} | ||||||
|  | 	if config.Security.Basic.Username != expectedUsername { | ||||||
|  | 		t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username) | ||||||
|  | 	} | ||||||
|  | 	if config.Security.Basic.PasswordSha512Hash != expectedPasswordHash { | ||||||
|  | 		t.Errorf("config.Security.Basic.PasswordSha512Hash should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordSha512Hash) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | |||||||
							
								
								
									
										7
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								main.go
									
									
									
									
									
								
							| @ -4,6 +4,7 @@ import ( | |||||||
| 	"bytes" | 	"bytes" | ||||||
| 	"compress/gzip" | 	"compress/gzip" | ||||||
| 	"github.com/TwinProduction/gatus/config" | 	"github.com/TwinProduction/gatus/config" | ||||||
|  | 	"github.com/TwinProduction/gatus/security" | ||||||
| 	"github.com/TwinProduction/gatus/watchdog" | 	"github.com/TwinProduction/gatus/watchdog" | ||||||
| 	"github.com/prometheus/client_golang/prometheus/promhttp" | 	"github.com/prometheus/client_golang/prometheus/promhttp" | ||||||
| 	"log" | 	"log" | ||||||
| @ -23,7 +24,11 @@ var ( | |||||||
|  |  | ||||||
| func main() { | func main() { | ||||||
| 	cfg := loadConfiguration() | 	cfg := loadConfiguration() | ||||||
| 	http.HandleFunc("/api/v1/results", serviceResultsHandler) | 	resultsHandler := serviceResultsHandler | ||||||
|  | 	if cfg.Security != nil && cfg.Security.IsValid() { | ||||||
|  | 		resultsHandler = security.Handler(serviceResultsHandler, cfg.Security) | ||||||
|  | 	} | ||||||
|  | 	http.HandleFunc("/api/v1/results", resultsHandler) | ||||||
| 	http.HandleFunc("/health", healthHandler) | 	http.HandleFunc("/health", healthHandler) | ||||||
| 	http.Handle("/", GzipHandler(http.FileServer(http.Dir("./static")))) | 	http.Handle("/", GzipHandler(http.FileServer(http.Dir("./static")))) | ||||||
| 	if cfg.Metrics { | 	if cfg.Metrics { | ||||||
|  | |||||||
							
								
								
									
										18
									
								
								security/handler.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								security/handler.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | |||||||
|  | package security | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"net/http" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func Handler(handler http.HandlerFunc, security *Config) http.HandlerFunc { | ||||||
|  | 	return func(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 		usernameEntered, passwordEntered, ok := r.BasicAuth() | ||||||
|  | 		if !ok || usernameEntered != security.Basic.Username || Sha512(passwordEntered) != security.Basic.PasswordSha512Hash { | ||||||
|  | 			w.Header().Set("WWW-Authenticate", "Basic") | ||||||
|  | 			w.WriteHeader(http.StatusUnauthorized) | ||||||
|  | 			_, _ = w.Write([]byte("Unauthorized")) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		handler(w, r) | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										58
									
								
								security/handler_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								security/handler_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,58 @@ | |||||||
|  | package security | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/http/httptest" | ||||||
|  | 	"testing" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func mockHandler(writer http.ResponseWriter, _ *http.Request) { | ||||||
|  | 	writer.WriteHeader(200) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestHandlerWhenNotAuthenticated(t *testing.T) { | ||||||
|  | 	handler := Handler(mockHandler, &Config{&BasicConfig{ | ||||||
|  | 		Username:           "john.doe", | ||||||
|  | 		PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22", | ||||||
|  | 	}}) | ||||||
|  | 	request, _ := http.NewRequest("GET", "/api/v1/results", nil) | ||||||
|  | 	responseRecorder := httptest.NewRecorder() | ||||||
|  |  | ||||||
|  | 	handler.ServeHTTP(responseRecorder, request) | ||||||
|  |  | ||||||
|  | 	if responseRecorder.Code != http.StatusUnauthorized { | ||||||
|  | 		t.Error("Expected code to be 401, but was", responseRecorder.Code) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestHandlerWhenAuthenticated(t *testing.T) { | ||||||
|  | 	handler := Handler(mockHandler, &Config{&BasicConfig{ | ||||||
|  | 		Username:           "john.doe", | ||||||
|  | 		PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22", | ||||||
|  | 	}}) | ||||||
|  | 	request, _ := http.NewRequest("GET", "/api/v1/results", nil) | ||||||
|  | 	request.SetBasicAuth("john.doe", "hunter2") | ||||||
|  | 	responseRecorder := httptest.NewRecorder() | ||||||
|  |  | ||||||
|  | 	handler.ServeHTTP(responseRecorder, request) | ||||||
|  |  | ||||||
|  | 	if responseRecorder.Code != http.StatusOK { | ||||||
|  | 		t.Error("Expected code to be 200, but was", responseRecorder.Code) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestHandlerWhenAuthenticatedWithBadCredentials(t *testing.T) { | ||||||
|  | 	handler := Handler(mockHandler, &Config{&BasicConfig{ | ||||||
|  | 		Username:           "john.doe", | ||||||
|  | 		PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22", | ||||||
|  | 	}}) | ||||||
|  | 	request, _ := http.NewRequest("GET", "/api/v1/results", nil) | ||||||
|  | 	request.SetBasicAuth("john.doe", "bad-password") | ||||||
|  | 	responseRecorder := httptest.NewRecorder() | ||||||
|  |  | ||||||
|  | 	handler.ServeHTTP(responseRecorder, request) | ||||||
|  |  | ||||||
|  | 	if responseRecorder.Code != http.StatusUnauthorized { | ||||||
|  | 		t.Error("Expected code to be 401, but was", responseRecorder.Code) | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										18
									
								
								security/security.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								security/security.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | |||||||
|  | package security | ||||||
|  |  | ||||||
|  | type Config struct { | ||||||
|  | 	Basic *BasicConfig `yaml:"basic"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (c *Config) IsValid() bool { | ||||||
|  | 	return c.Basic != nil && c.Basic.IsValid() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type BasicConfig struct { | ||||||
|  | 	Username           string `yaml:"username"` | ||||||
|  | 	PasswordSha512Hash string `yaml:"password-sha512"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (c *BasicConfig) IsValid() bool { | ||||||
|  | 	return len(c.Username) > 0 && len(c.PasswordSha512Hash) == 128 | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								security/security_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								security/security_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | |||||||
|  | package security | ||||||
|  |  | ||||||
|  | import "testing" | ||||||
|  |  | ||||||
|  | func TestBasicConfig_IsValid(t *testing.T) { | ||||||
|  | 	basicConfig := &BasicConfig{ | ||||||
|  | 		Username:           "admin", | ||||||
|  | 		PasswordSha512Hash: Sha512("test"), | ||||||
|  | 	} | ||||||
|  | 	if !basicConfig.IsValid() { | ||||||
|  | 		t.Error("basicConfig should've been valid") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestBasicConfig_IsValidWhenPasswordIsInvalid(t *testing.T) { | ||||||
|  | 	basicConfig := &BasicConfig{ | ||||||
|  | 		Username:           "admin", | ||||||
|  | 		PasswordSha512Hash: "", | ||||||
|  | 	} | ||||||
|  | 	if basicConfig.IsValid() { | ||||||
|  | 		t.Error("basicConfig shouldn't have been valid") | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										12
									
								
								security/sha512.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								security/sha512.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | |||||||
|  | package security | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"crypto/sha512" | ||||||
|  | 	"fmt" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func Sha512(s string) string { | ||||||
|  | 	hash := sha512.New() | ||||||
|  | 	hash.Write([]byte(s)) | ||||||
|  | 	return fmt.Sprintf("%x", hash.Sum(nil)) | ||||||
|  | } | ||||||
							
								
								
									
										12
									
								
								security/sha512_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								security/sha512_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | |||||||
|  | package security | ||||||
|  |  | ||||||
|  | import "testing" | ||||||
|  |  | ||||||
|  | func TestSha512(t *testing.T) { | ||||||
|  | 	input := "password" | ||||||
|  | 	expectedHash := "b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86" | ||||||
|  | 	hash := Sha512(input) | ||||||
|  | 	if hash != expectedHash { | ||||||
|  | 		t.Errorf("Expected hash to be '%s', but was '%s'", expectedHash, hash) | ||||||
|  | 	} | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user