mirror of
https://github.com/volatiletech/authboss.git
synced 2025-02-09 13:47:09 +02:00
Add more flexibility to authboss.Middleware
- Add requirements and responses for the authboss middleware. This lets us later add new types that don't break the API instead of a list of bools.
This commit is contained in:
parent
adaf5a9192
commit
6f3e7ca54a
@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
|||||||
|
|
||||||
- Add e-mail confirmation before 2fa setup feature
|
- Add e-mail confirmation before 2fa setup feature
|
||||||
- Add config value TwoFactorEmailAuthRequired
|
- Add config value TwoFactorEmailAuthRequired
|
||||||
|
- Add a more flexible way of adding behaviors and requirements to
|
||||||
|
authboss.Middleware. This API is at authboss.Middleware2 temporarily
|
||||||
|
until we can make a breaking change.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@ -31,6 +34,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
|||||||
|
|
||||||
- Deprecate the config field ConfirmMethod in favor of MailRouteMethod. See
|
- Deprecate the config field ConfirmMethod in favor of MailRouteMethod. See
|
||||||
documentation for these config fields to understand how to use them now.
|
documentation for these config fields to understand how to use them now.
|
||||||
|
- Deprecate Middleware/MountedMiddleware for Middleware2 and MountedMiddleware2
|
||||||
|
as these new APIs are more flexible. When v3 hits (Mounted)Middleware2 will
|
||||||
|
become just (Mounted)Middleware.
|
||||||
|
- Deprecate RoutesRedirectOnUnauthed in favor of ResponseOnUnauthed
|
||||||
|
|
||||||
## [2.1.0] - 2018-10-28
|
## [2.1.0] - 2018-10-28
|
||||||
|
|
||||||
|
99
authboss.go
99
authboss.go
@ -82,38 +82,96 @@ func (a *Authboss) UpdatePassword(ctx context.Context, user AuthableUser, newPas
|
|||||||
return rmStorer.DelRememberTokens(ctx, user.GetPID())
|
return rmStorer.DelRememberTokens(ctx, user.GetPID())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware prevents someone from accessing a route that should be
|
// MWRequirements are user requirements for authboss.Middleware
|
||||||
// only allowed for users who are logged in.
|
// in order to access the routes in protects. Requirements is a bit-set integer
|
||||||
// It allows the user through if they are logged in (SessionKey).
|
// to be able to easily combine requirements like so:
|
||||||
//
|
//
|
||||||
// If redirectToLogin is true, the user will be redirected to the
|
// authboss.RequireFullAuth | authboss.Require2FA
|
||||||
// login page, otherwise they will get a 404.
|
type MWRequirements int
|
||||||
// The redirect goes to: mountPath/login, this means it's expected that
|
|
||||||
// the auth module is loaded if this is set to true.
|
// MWRespondOnFailure tells authboss.Middleware how to respond to
|
||||||
//
|
// a failure to meet the requirements.
|
||||||
// If forceFullAuth is true then half-authed users (SessionHalfAuth)
|
type MWRespondOnFailure int
|
||||||
// are not allowed through, otherwise a half-authed user will be allowed through.
|
|
||||||
//
|
// Middleware requirements
|
||||||
// If force2fa is true, then users must have been logged in
|
const (
|
||||||
// with 2fa (Session2FA) otherwise they will not be allowed through.
|
RequireNone MWRequirements = 0x00
|
||||||
|
// RequireFullAuth means half-authed users will also be rejected
|
||||||
|
RequireFullAuth MWRequirements = 0x01
|
||||||
|
// Require2FA means that users who have not authed with 2fa will
|
||||||
|
// be rejected.
|
||||||
|
Require2FA MWRequirements = 0x02
|
||||||
|
)
|
||||||
|
|
||||||
|
// Middleware response types
|
||||||
|
const (
|
||||||
|
// RespondNotFound does not allow users who are not logged in to know a
|
||||||
|
// route exists by responding with a 404.
|
||||||
|
RespondNotFound MWRespondOnFailure = iota
|
||||||
|
// RespondRedirect redirects users to the login page
|
||||||
|
RespondRedirect
|
||||||
|
// RespondUnauthorized provides a 401, this allows users to know the page
|
||||||
|
// exists unlike the 404 option.
|
||||||
|
RespondUnauthorized
|
||||||
|
)
|
||||||
|
|
||||||
|
// Middleware is deprecated. See Middleware2.
|
||||||
func Middleware(ab *Authboss, redirectToLogin bool, forceFullAuth bool, force2fa bool) func(http.Handler) http.Handler {
|
func Middleware(ab *Authboss, redirectToLogin bool, forceFullAuth bool, force2fa bool) func(http.Handler) http.Handler {
|
||||||
return MountedMiddleware(ab, false, redirectToLogin, forceFullAuth, force2fa)
|
return MountedMiddleware(ab, false, redirectToLogin, forceFullAuth, force2fa)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MountedMiddleware hides an option from typical users in "mountPathed".
|
// MountedMiddleware is deprecated. See MountedMiddleware2.
|
||||||
|
func MountedMiddleware(ab *Authboss, mountPathed, redirectToLogin, forceFullAuth, force2fa bool) func(http.Handler) http.Handler {
|
||||||
|
var reqs MWRequirements
|
||||||
|
failResponse := RespondNotFound
|
||||||
|
if forceFullAuth {
|
||||||
|
reqs |= RequireFullAuth
|
||||||
|
}
|
||||||
|
if force2fa {
|
||||||
|
reqs |= Require2FA
|
||||||
|
}
|
||||||
|
if redirectToLogin {
|
||||||
|
failResponse = RespondRedirect
|
||||||
|
}
|
||||||
|
return MountedMiddleware2(ab, mountPathed, reqs, failResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware2 prevents someone from accessing a route that should be
|
||||||
|
// only allowed for users who are logged in.
|
||||||
|
// It allows the user through if they are logged in (SessionKey is present in
|
||||||
|
// the session).
|
||||||
|
//
|
||||||
|
// requirements are set by logical or'ing together requirements. eg:
|
||||||
|
//
|
||||||
|
// authboss.RequireFullAuth | authboss.Require2FA
|
||||||
|
//
|
||||||
|
// failureResponse is how the middleware rejects the users that don't meet
|
||||||
|
// the criteria. This should be chosen from the MWRespondOnFailure constants.
|
||||||
|
func Middleware2(ab *Authboss, requirements MWRequirements, failureResponse MWRespondOnFailure) func(http.Handler) http.Handler {
|
||||||
|
return MountedMiddleware2(ab, false, requirements, failureResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountedMiddleware2 hides an option from typical users in "mountPathed".
|
||||||
// Normal routes should never need this only authboss routes (since they
|
// Normal routes should never need this only authboss routes (since they
|
||||||
// are behind mountPath typically). This method is exported only for use
|
// are behind mountPath typically). This method is exported only for use
|
||||||
// by Authboss modules, normal users should use Middleware instead.
|
// by Authboss modules, normal users should use Middleware instead.
|
||||||
//
|
//
|
||||||
// If mountPathed is true, then before redirecting to a URL it will add
|
// If mountPathed is true, then before redirecting to a URL it will add
|
||||||
// the mountpath to the front of it.
|
// the mountpath to the front of it.
|
||||||
func MountedMiddleware(ab *Authboss, mountPathed, redirectToLogin, forceFullAuth, force2fa bool) func(http.Handler) http.Handler {
|
func MountedMiddleware2(ab *Authboss, mountPathed bool, reqs MWRequirements, failResponse MWRespondOnFailure) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
log := ab.RequestLogger(r)
|
log := ab.RequestLogger(r)
|
||||||
|
|
||||||
fail := func(w http.ResponseWriter, r *http.Request) {
|
fail := func(w http.ResponseWriter, r *http.Request) {
|
||||||
if redirectToLogin {
|
switch failResponse {
|
||||||
|
case RespondNotFound:
|
||||||
|
log.Infof("not found for unauthorized user at: %s", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
case RespondUnauthorized:
|
||||||
|
log.Infof("unauthorized for unauthorized user at: %s", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
case RespondRedirect:
|
||||||
log.Infof("redirecting unauthorized user to login from: %s", r.URL.Path)
|
log.Infof("redirecting unauthorized user to login from: %s", r.URL.Path)
|
||||||
vals := make(url.Values)
|
vals := make(url.Values)
|
||||||
|
|
||||||
@ -134,12 +192,9 @@ func MountedMiddleware(ab *Authboss, mountPathed, redirectToLogin, forceFullAuth
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("not found for unauthorized user at: %s", r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if forceFullAuth && !IsFullyAuthed(r) || force2fa && !IsTwoFactored(r) {
|
if hasBit(reqs, RequireFullAuth) && !IsFullyAuthed(r) || hasBit(reqs, Require2FA) && !IsTwoFactored(r) {
|
||||||
fail(w, r)
|
fail(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -157,3 +212,7 @@ func MountedMiddleware(ab *Authboss, mountPathed, redirectToLogin, forceFullAuth
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasBit(reqs, req MWRequirements) bool {
|
||||||
|
return reqs&req == req
|
||||||
|
}
|
||||||
|
@ -5,8 +5,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davecgh/go-spew/spew"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAuthBossInit(t *testing.T) {
|
func TestAuthBossInit(t *testing.T) {
|
||||||
@ -137,8 +135,6 @@ func TestAuthbossMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
rec, called, hadUser := setupMore(false, false, false, false)
|
rec, called, hadUser := setupMore(false, false, false, false)
|
||||||
|
|
||||||
spew.Dump(ab.Storage)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Error("wrong code:", rec.Code)
|
t.Error("wrong code:", rec.Code)
|
||||||
}
|
}
|
||||||
@ -149,6 +145,40 @@ func TestAuthbossMiddleware(t *testing.T) {
|
|||||||
t.Error("should not have had user")
|
t.Error("should not have had user")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
t.Run("Reject401", func(t *testing.T) {
|
||||||
|
ab.Storage.SessionState = mockClientStateReadWriter{}
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/super/secret", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
w := ab.NewResponse(rec)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
r, err = ab.LoadClientState(w, r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mid func(http.Handler) http.Handler
|
||||||
|
mid = Middleware2(ab, RequireNone, RespondUnauthorized)
|
||||||
|
var called, hadUser bool
|
||||||
|
server := mid(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
hadUser = r.Context().Value(CTXKeyUser) != nil
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
server.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Error("wrong code:", rec.Code)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("should not have been called")
|
||||||
|
}
|
||||||
|
if hadUser {
|
||||||
|
t.Error("should not have had user")
|
||||||
|
}
|
||||||
|
})
|
||||||
t.Run("RejectRedirect", func(t *testing.T) {
|
t.Run("RejectRedirect", func(t *testing.T) {
|
||||||
redir := &testRedirector{}
|
redir := &testRedirector{}
|
||||||
ab.Config.Core.Redirector = redir
|
ab.Config.Core.Redirector = redir
|
||||||
|
14
config.go
14
config.go
@ -96,7 +96,7 @@ type Config struct {
|
|||||||
//
|
//
|
||||||
// This configuration setting deprecates ConfirmMethod.
|
// This configuration setting deprecates ConfirmMethod.
|
||||||
// If ConfirmMethod is set to the default value (GET) then
|
// If ConfirmMethod is set to the default value (GET) then
|
||||||
// MailRouteMethod is used. If ConfirMethod is not the default value
|
// MailRouteMethod is used. If ConfirmMethod is not the default value
|
||||||
// then it is used until Authboss v3 when only MailRouteMethod will be
|
// then it is used until Authboss v3 when only MailRouteMethod will be
|
||||||
// used.
|
// used.
|
||||||
MailRouteMethod string
|
MailRouteMethod string
|
||||||
@ -138,6 +138,7 @@ type Config struct {
|
|||||||
// a qr code for google authenticator.
|
// a qr code for google authenticator.
|
||||||
TOTP2FAIssuer string
|
TOTP2FAIssuer string
|
||||||
|
|
||||||
|
// DEPRECATED: See ResponseOnUnauthed
|
||||||
// RoutesRedirectOnUnauthed controls whether or not a user is redirected
|
// RoutesRedirectOnUnauthed controls whether or not a user is redirected
|
||||||
// or given a 404 when they are unauthenticated and attempting to access
|
// or given a 404 when they are unauthenticated and attempting to access
|
||||||
// a route that's login-protected inside Authboss itself.
|
// a route that's login-protected inside Authboss itself.
|
||||||
@ -145,6 +146,17 @@ type Config struct {
|
|||||||
// their routes and this is the redirectToLogin parameter in that
|
// their routes and this is the redirectToLogin parameter in that
|
||||||
// middleware that they pass through.
|
// middleware that they pass through.
|
||||||
RoutesRedirectOnUnauthed bool
|
RoutesRedirectOnUnauthed bool
|
||||||
|
|
||||||
|
// ResponseOnUnauthed controls how a user is responded to when
|
||||||
|
// attempting to access a route that's login-protected inside Authboss
|
||||||
|
// itself. The otp/twofactor modules all use authboss.Middleware2 to
|
||||||
|
// protect their routes and this is the failResponse parameter in that
|
||||||
|
// middleware that they pass through.
|
||||||
|
//
|
||||||
|
// This deprecates RoutesRedirectOnUnauthed. If RoutesRedirectOnUnauthed
|
||||||
|
// is true, the value of this will be set to RespondRedirect until
|
||||||
|
// authboss v3.
|
||||||
|
ResponseOnUnauthed MWRespondOnFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
Mail struct {
|
Mail struct {
|
||||||
|
@ -75,7 +75,13 @@ func (o *OTP) Init(ab *authboss.Authboss) (err error) {
|
|||||||
o.Authboss.Config.Core.Router.Get("/otp/login", o.Authboss.Core.ErrorHandler.Wrap(o.LoginGet))
|
o.Authboss.Config.Core.Router.Get("/otp/login", o.Authboss.Core.ErrorHandler.Wrap(o.LoginGet))
|
||||||
o.Authboss.Config.Core.Router.Post("/otp/login", o.Authboss.Core.ErrorHandler.Wrap(o.LoginPost))
|
o.Authboss.Config.Core.Router.Post("/otp/login", o.Authboss.Core.ErrorHandler.Wrap(o.LoginPost))
|
||||||
|
|
||||||
middleware := authboss.MountedMiddleware(ab, true, ab.Config.Modules.RoutesRedirectOnUnauthed, false, false)
|
var unauthedResponse authboss.MWRespondOnFailure
|
||||||
|
if ab.Config.Modules.ResponseOnUnauthed != 0 {
|
||||||
|
unauthedResponse = ab.Config.Modules.ResponseOnUnauthed
|
||||||
|
} else if ab.Config.Modules.RoutesRedirectOnUnauthed {
|
||||||
|
unauthedResponse = authboss.RespondRedirect
|
||||||
|
}
|
||||||
|
middleware := authboss.MountedMiddleware2(ab, true, authboss.RequireNone, unauthedResponse)
|
||||||
o.Authboss.Config.Core.Router.Get("/otp/add", middleware(o.Authboss.Core.ErrorHandler.Wrap(o.AddGet)))
|
o.Authboss.Config.Core.Router.Get("/otp/add", middleware(o.Authboss.Core.ErrorHandler.Wrap(o.AddGet)))
|
||||||
o.Authboss.Config.Core.Router.Post("/otp/add", middleware(o.Authboss.Core.ErrorHandler.Wrap(o.AddPost)))
|
o.Authboss.Config.Core.Router.Post("/otp/add", middleware(o.Authboss.Core.ErrorHandler.Wrap(o.AddPost)))
|
||||||
|
|
||||||
|
@ -98,7 +98,13 @@ func (s *SMS) Setup() error {
|
|||||||
return errors.New("must have SMS.Sender set")
|
return errors.New("must have SMS.Sender set")
|
||||||
}
|
}
|
||||||
|
|
||||||
abmw := authboss.MountedMiddleware(s.Authboss, true, s.Authboss.Config.Modules.RoutesRedirectOnUnauthed, false, false)
|
var unauthedResponse authboss.MWRespondOnFailure
|
||||||
|
if s.Config.Modules.ResponseOnUnauthed != 0 {
|
||||||
|
unauthedResponse = s.Config.Modules.ResponseOnUnauthed
|
||||||
|
} else if s.Config.Modules.RoutesRedirectOnUnauthed {
|
||||||
|
unauthedResponse = authboss.RespondRedirect
|
||||||
|
}
|
||||||
|
abmw := authboss.MountedMiddleware2(s.Authboss, true, authboss.RequireFullAuth, unauthedResponse)
|
||||||
|
|
||||||
var middleware, verified func(func(w http.ResponseWriter, r *http.Request) error) http.Handler
|
var middleware, verified func(func(w http.ResponseWriter, r *http.Request) error) http.Handler
|
||||||
middleware = func(handler func(http.ResponseWriter, *http.Request) error) http.Handler {
|
middleware = func(handler func(http.ResponseWriter, *http.Request) error) http.Handler {
|
||||||
|
@ -67,7 +67,13 @@ type TOTP struct {
|
|||||||
|
|
||||||
// Setup the module
|
// Setup the module
|
||||||
func (t *TOTP) Setup() error {
|
func (t *TOTP) Setup() error {
|
||||||
abmw := authboss.MountedMiddleware(t.Authboss, true, t.Authboss.Config.Modules.RoutesRedirectOnUnauthed, true, false)
|
var unauthedResponse authboss.MWRespondOnFailure
|
||||||
|
if t.Config.Modules.ResponseOnUnauthed != 0 {
|
||||||
|
unauthedResponse = t.Config.Modules.ResponseOnUnauthed
|
||||||
|
} else if t.Config.Modules.RoutesRedirectOnUnauthed {
|
||||||
|
unauthedResponse = authboss.RespondRedirect
|
||||||
|
}
|
||||||
|
abmw := authboss.MountedMiddleware2(t.Authboss, true, authboss.RequireFullAuth, unauthedResponse)
|
||||||
|
|
||||||
var middleware, verified func(func(w http.ResponseWriter, r *http.Request) error) http.Handler
|
var middleware, verified func(func(w http.ResponseWriter, r *http.Request) error) http.Handler
|
||||||
middleware = func(handler func(http.ResponseWriter, *http.Request) error) http.Handler {
|
middleware = func(handler func(http.ResponseWriter, *http.Request) error) http.Handler {
|
||||||
|
@ -18,7 +18,13 @@ type Recovery struct {
|
|||||||
|
|
||||||
// Setup the module to provide recovery regeneration routes
|
// Setup the module to provide recovery regeneration routes
|
||||||
func (rc *Recovery) Setup() error {
|
func (rc *Recovery) Setup() error {
|
||||||
middleware := authboss.MountedMiddleware(rc.Authboss, true, rc.Authboss.Config.Modules.RoutesRedirectOnUnauthed, true, false)
|
var unauthedResponse authboss.MWRespondOnFailure
|
||||||
|
if rc.Config.Modules.ResponseOnUnauthed != 0 {
|
||||||
|
unauthedResponse = rc.Config.Modules.ResponseOnUnauthed
|
||||||
|
} else if rc.Config.Modules.RoutesRedirectOnUnauthed {
|
||||||
|
unauthedResponse = authboss.RespondRedirect
|
||||||
|
}
|
||||||
|
middleware := authboss.MountedMiddleware2(rc.Authboss, true, authboss.RequireFullAuth, unauthedResponse)
|
||||||
rc.Authboss.Core.Router.Get("/2fa/recovery/regen", middleware(rc.Authboss.Core.ErrorHandler.Wrap(rc.GetRegen)))
|
rc.Authboss.Core.Router.Get("/2fa/recovery/regen", middleware(rc.Authboss.Core.ErrorHandler.Wrap(rc.GetRegen)))
|
||||||
rc.Authboss.Core.Router.Post("/2fa/recovery/regen", middleware(rc.Authboss.Core.ErrorHandler.Wrap(rc.PostRegen)))
|
rc.Authboss.Core.Router.Post("/2fa/recovery/regen", middleware(rc.Authboss.Core.ErrorHandler.Wrap(rc.PostRegen)))
|
||||||
|
|
||||||
|
@ -38,7 +38,13 @@ func SetupEmailVerify(ab *authboss.Authboss, twofactorKind, setupURL string) (Em
|
|||||||
TwofactorSetupURL: setupURL,
|
TwofactorSetupURL: setupURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware := authboss.MountedMiddleware(ab, true, ab.Config.Modules.RoutesRedirectOnUnauthed, true, false)
|
var unauthedResponse authboss.MWRespondOnFailure
|
||||||
|
if ab.Config.Modules.ResponseOnUnauthed != 0 {
|
||||||
|
unauthedResponse = ab.Config.Modules.ResponseOnUnauthed
|
||||||
|
} else if ab.Config.Modules.RoutesRedirectOnUnauthed {
|
||||||
|
unauthedResponse = authboss.RespondRedirect
|
||||||
|
}
|
||||||
|
middleware := authboss.MountedMiddleware2(ab, true, authboss.RequireFullAuth, unauthedResponse)
|
||||||
e.Authboss.Core.Router.Get("/2fa/"+twofactorKind+"/email/verify", middleware(ab.Core.ErrorHandler.Wrap(e.GetStart)))
|
e.Authboss.Core.Router.Get("/2fa/"+twofactorKind+"/email/verify", middleware(ab.Core.ErrorHandler.Wrap(e.GetStart)))
|
||||||
e.Authboss.Core.Router.Post("/2fa/"+twofactorKind+"/email/verify", middleware(ab.Core.ErrorHandler.Wrap(e.PostStart)))
|
e.Authboss.Core.Router.Post("/2fa/"+twofactorKind+"/email/verify", middleware(ab.Core.ErrorHandler.Wrap(e.PostStart)))
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user