You've already forked oauth2-proxy
mirror of
https://github.com/oauth2-proxy/oauth2-proxy.git
synced 2025-06-15 00:15:00 +02:00
Merge pull request #1128 from oauth2-proxy/proxy-router
Use gorilla mux for OAuth Proxy routing
This commit is contained in:
@ -7,6 +7,8 @@
|
|||||||
## Breaking Changes
|
## Breaking Changes
|
||||||
|
|
||||||
## Changes since v7.1.3
|
## Changes since v7.1.3
|
||||||
|
|
||||||
|
- [#1128](https://github.com/oauth2-proxy/oauth2-proxy/pull/1128) Use gorilla mux for OAuth Proxy routing (@JoelSpeed)
|
||||||
- [#1238](https://github.com/oauth2-proxy/oauth2-proxy/pull/1238) Added ADFS provider (@samirachoadi)
|
- [#1238](https://github.com/oauth2-proxy/oauth2-proxy/pull/1238) Added ADFS provider (@samirachoadi)
|
||||||
- [#1227](https://github.com/oauth2-proxy/oauth2-proxy/pull/1227) Fix Refresh Session not working for multiple cookies (@rishi1111)
|
- [#1227](https://github.com/oauth2-proxy/oauth2-proxy/pull/1227) Fix Refresh Session not working for multiple cookies (@rishi1111)
|
||||||
- [#1063](https://github.com/oauth2-proxy/oauth2-proxy/pull/1063) Add Redis lock feature to lock persistent sessions (@Bibob7)
|
- [#1063](https://github.com/oauth2-proxy/oauth2-proxy/pull/1063) Add Redis lock feature to lock persistent sessions (@Bibob7)
|
||||||
|
1
go.mod
1
go.mod
@ -16,6 +16,7 @@ require (
|
|||||||
github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32
|
github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32
|
||||||
github.com/go-redis/redis/v8 v8.2.3
|
github.com/go-redis/redis/v8 v8.2.3
|
||||||
github.com/google/uuid v1.2.0
|
github.com/google/uuid v1.2.0
|
||||||
|
github.com/gorilla/mux v1.8.0 // indirect
|
||||||
github.com/justinas/alice v1.2.0
|
github.com/justinas/alice v1.2.0
|
||||||
github.com/mbland/hmacauth v0.0.0-20170912233209-44256dfd4bfa
|
github.com/mbland/hmacauth v0.0.0-20170912233209-44256dfd4bfa
|
||||||
github.com/mitchellh/mapstructure v1.1.2
|
github.com/mitchellh/mapstructure v1.1.2
|
||||||
|
2
go.sum
2
go.sum
@ -188,6 +188,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR
|
|||||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
|
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||||
|
139
oauthproxy.go
139
oauthproxy.go
@ -15,6 +15,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
"github.com/justinas/alice"
|
"github.com/justinas/alice"
|
||||||
ipapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/ip"
|
ipapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/ip"
|
||||||
middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
|
middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
|
||||||
@ -38,6 +39,14 @@ const (
|
|||||||
schemeHTTP = "http"
|
schemeHTTP = "http"
|
||||||
schemeHTTPS = "https"
|
schemeHTTPS = "https"
|
||||||
applicationJSON = "application/json"
|
applicationJSON = "application/json"
|
||||||
|
|
||||||
|
robotsPath = "/robots.txt"
|
||||||
|
signInPath = "/sign_in"
|
||||||
|
signOutPath = "/sign_out"
|
||||||
|
oauthStartPath = "/start"
|
||||||
|
oauthCallbackPath = "/callback"
|
||||||
|
authOnlyPath = "/auth"
|
||||||
|
userInfoPath = "/userinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -63,13 +72,7 @@ type OAuthProxy struct {
|
|||||||
CookieOptions *options.Cookie
|
CookieOptions *options.Cookie
|
||||||
Validator func(string) bool
|
Validator func(string) bool
|
||||||
|
|
||||||
RobotsPath string
|
|
||||||
SignInPath string
|
SignInPath string
|
||||||
SignOutPath string
|
|
||||||
OAuthStartPath string
|
|
||||||
OAuthCallbackPath string
|
|
||||||
AuthOnlyPath string
|
|
||||||
UserInfoPath string
|
|
||||||
|
|
||||||
allowedRoutes []allowedRoute
|
allowedRoutes []allowedRoute
|
||||||
redirectURL *url.URL // the url to receive requests at
|
redirectURL *url.URL // the url to receive requests at
|
||||||
@ -78,7 +81,6 @@ type OAuthProxy struct {
|
|||||||
sessionStore sessionsapi.SessionStore
|
sessionStore sessionsapi.SessionStore
|
||||||
ProxyPrefix string
|
ProxyPrefix string
|
||||||
basicAuthValidator basic.Validator
|
basicAuthValidator basic.Validator
|
||||||
serveMux http.Handler
|
|
||||||
SkipProviderButton bool
|
SkipProviderButton bool
|
||||||
skipAuthPreflight bool
|
skipAuthPreflight bool
|
||||||
skipJwtBearerTokens bool
|
skipJwtBearerTokens bool
|
||||||
@ -90,6 +92,8 @@ type OAuthProxy struct {
|
|||||||
preAuthChain alice.Chain
|
preAuthChain alice.Chain
|
||||||
pageWriter pagewriter.Writer
|
pageWriter pagewriter.Writer
|
||||||
server proxyhttp.Server
|
server proxyhttp.Server
|
||||||
|
upstreamProxy http.Handler
|
||||||
|
serveMux *mux.Router
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOAuthProxy creates a new instance of OAuthProxy from the options provided
|
// NewOAuthProxy creates a new instance of OAuthProxy from the options provided
|
||||||
@ -176,18 +180,11 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
|
|||||||
CookieOptions: &opts.Cookie,
|
CookieOptions: &opts.Cookie,
|
||||||
Validator: validator,
|
Validator: validator,
|
||||||
|
|
||||||
RobotsPath: "/robots.txt",
|
|
||||||
SignInPath: fmt.Sprintf("%s/sign_in", opts.ProxyPrefix),
|
SignInPath: fmt.Sprintf("%s/sign_in", opts.ProxyPrefix),
|
||||||
SignOutPath: fmt.Sprintf("%s/sign_out", opts.ProxyPrefix),
|
|
||||||
OAuthStartPath: fmt.Sprintf("%s/start", opts.ProxyPrefix),
|
|
||||||
OAuthCallbackPath: fmt.Sprintf("%s/callback", opts.ProxyPrefix),
|
|
||||||
AuthOnlyPath: fmt.Sprintf("%s/auth", opts.ProxyPrefix),
|
|
||||||
UserInfoPath: fmt.Sprintf("%s/userinfo", opts.ProxyPrefix),
|
|
||||||
|
|
||||||
ProxyPrefix: opts.ProxyPrefix,
|
ProxyPrefix: opts.ProxyPrefix,
|
||||||
provider: opts.GetProvider(),
|
provider: opts.GetProvider(),
|
||||||
sessionStore: sessionStore,
|
sessionStore: sessionStore,
|
||||||
serveMux: upstreamProxy,
|
|
||||||
redirectURL: redirectURL,
|
redirectURL: redirectURL,
|
||||||
allowedRoutes: allowedRoutes,
|
allowedRoutes: allowedRoutes,
|
||||||
whitelistDomains: opts.WhitelistDomains,
|
whitelistDomains: opts.WhitelistDomains,
|
||||||
@ -202,7 +199,9 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
|
|||||||
headersChain: headersChain,
|
headersChain: headersChain,
|
||||||
preAuthChain: preAuthChain,
|
preAuthChain: preAuthChain,
|
||||||
pageWriter: pageWriter,
|
pageWriter: pageWriter,
|
||||||
|
upstreamProxy: upstreamProxy,
|
||||||
}
|
}
|
||||||
|
p.buildServeMux(opts.ProxyPrefix)
|
||||||
|
|
||||||
if err := p.setupServer(opts); err != nil {
|
if err := p.setupServer(opts); err != nil {
|
||||||
return nil, fmt.Errorf("error setting up server: %v", err)
|
return nil, fmt.Errorf("error setting up server: %v", err)
|
||||||
@ -258,6 +257,41 @@ func (p *OAuthProxy) setupServer(opts *options.Options) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *OAuthProxy) buildServeMux(proxyPrefix string) {
|
||||||
|
r := mux.NewRouter()
|
||||||
|
// Everything served by the router must go through the preAuthChain first.
|
||||||
|
r.Use(p.preAuthChain.Then)
|
||||||
|
|
||||||
|
// Register the robots path writer
|
||||||
|
r.Path(robotsPath).HandlerFunc(p.pageWriter.WriteRobotsTxt)
|
||||||
|
|
||||||
|
// The authonly path should be registered separately to prevent it from getting no-cache headers.
|
||||||
|
// We do this to allow users to have a short cache (via nginx) of the response to reduce the
|
||||||
|
// likelihood of multiple reuests trying to referesh sessions simultaneously.
|
||||||
|
r.Path(proxyPrefix + authOnlyPath).Handler(p.sessionChain.ThenFunc(p.AuthOnly))
|
||||||
|
|
||||||
|
// This will register all of the paths under the proxy prefix, except the auth only path so that no cache headers
|
||||||
|
// are not applied.
|
||||||
|
p.buildProxySubrouter(r.PathPrefix(proxyPrefix).Subrouter())
|
||||||
|
|
||||||
|
// Register serveHTTP last so it catches anything that isn't already caught earlier.
|
||||||
|
// Anything that got to this point needs to have a session loaded.
|
||||||
|
r.PathPrefix("/").Handler(p.sessionChain.ThenFunc(p.Proxy))
|
||||||
|
p.serveMux = r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OAuthProxy) buildProxySubrouter(s *mux.Router) {
|
||||||
|
s.Use(prepareNoCacheMiddleware)
|
||||||
|
|
||||||
|
s.Path(signInPath).HandlerFunc(p.SignIn)
|
||||||
|
s.Path(signOutPath).HandlerFunc(p.SignOut)
|
||||||
|
s.Path(oauthStartPath).HandlerFunc(p.OAuthStart)
|
||||||
|
s.Path(oauthCallbackPath).HandlerFunc(p.OAuthCallback)
|
||||||
|
|
||||||
|
// The userinfo endpoint needs to load sessions before handling the request
|
||||||
|
s.Path(userInfoPath).Handler(p.sessionChain.ThenFunc(p.UserInfo))
|
||||||
|
}
|
||||||
|
|
||||||
// buildPreAuthChain constructs a chain that should process every request before
|
// buildPreAuthChain constructs a chain that should process every request before
|
||||||
// the OAuth2 Proxy authentication logic kicks in.
|
// the OAuth2 Proxy authentication logic kicks in.
|
||||||
// For example forcing HTTPS or health checks.
|
// For example forcing HTTPS or health checks.
|
||||||
@ -478,39 +512,7 @@ func (p *OAuthProxy) IsValidRedirect(redirect string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *OAuthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
func (p *OAuthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
p.preAuthChain.Then(http.HandlerFunc(p.serveHTTP)).ServeHTTP(rw, req)
|
p.serveMux.ServeHTTP(rw, req)
|
||||||
}
|
|
||||||
|
|
||||||
func (p *OAuthProxy) serveHTTP(rw http.ResponseWriter, req *http.Request) {
|
|
||||||
if req.URL.Path != p.AuthOnlyPath && strings.HasPrefix(req.URL.Path, p.ProxyPrefix) {
|
|
||||||
prepareNoCache(rw)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch path := req.URL.Path; {
|
|
||||||
case path == p.RobotsPath:
|
|
||||||
p.RobotsTxt(rw, req)
|
|
||||||
case p.IsAllowedRequest(req):
|
|
||||||
p.SkipAuthProxy(rw, req)
|
|
||||||
case path == p.SignInPath:
|
|
||||||
p.SignIn(rw, req)
|
|
||||||
case path == p.SignOutPath:
|
|
||||||
p.SignOut(rw, req)
|
|
||||||
case path == p.OAuthStartPath:
|
|
||||||
p.OAuthStart(rw, req)
|
|
||||||
case path == p.OAuthCallbackPath:
|
|
||||||
p.OAuthCallback(rw, req)
|
|
||||||
case path == p.AuthOnlyPath:
|
|
||||||
p.AuthOnly(rw, req)
|
|
||||||
case path == p.UserInfoPath:
|
|
||||||
p.UserInfo(rw, req)
|
|
||||||
default:
|
|
||||||
p.Proxy(rw, req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RobotsTxt disallows scraping pages from the OAuthProxy
|
|
||||||
func (p *OAuthProxy) RobotsTxt(rw http.ResponseWriter, req *http.Request) {
|
|
||||||
p.pageWriter.WriteRobotsTxt(rw, req)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrorPage writes an error response
|
// ErrorPage writes an error response
|
||||||
@ -643,13 +645,22 @@ func (p *OAuthProxy) SignIn(rw http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
// UserInfo endpoint outputs session email and preferred username in JSON format
|
// UserInfo endpoint outputs session email and preferred username in JSON format
|
||||||
func (p *OAuthProxy) UserInfo(rw http.ResponseWriter, req *http.Request) {
|
func (p *OAuthProxy) UserInfo(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
|
||||||
session, err := p.getAuthenticatedSession(rw, req)
|
session, err := p.getAuthenticatedSession(rw, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
if session == nil {
|
||||||
|
if _, err := rw.Write([]byte("{}")); err != nil {
|
||||||
|
logger.Printf("Error encoding empty user info: %v", err)
|
||||||
|
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
userInfo := struct {
|
userInfo := struct {
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@ -662,10 +673,7 @@ func (p *OAuthProxy) UserInfo(rw http.ResponseWriter, req *http.Request) {
|
|||||||
PreferredUsername: session.PreferredUsername,
|
PreferredUsername: session.PreferredUsername,
|
||||||
}
|
}
|
||||||
|
|
||||||
rw.Header().Set("Content-Type", "application/json")
|
if err := json.NewEncoder(rw).Encode(userInfo); err != nil {
|
||||||
rw.WriteHeader(http.StatusOK)
|
|
||||||
err = json.NewEncoder(rw).Encode(userInfo)
|
|
||||||
if err != nil {
|
|
||||||
logger.Printf("Error encoding user info: %v", err)
|
logger.Printf("Error encoding user info: %v", err)
|
||||||
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
|
p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
@ -857,11 +865,6 @@ func (p *OAuthProxy) AuthOnly(rw http.ResponseWriter, req *http.Request) {
|
|||||||
})).ServeHTTP(rw, req)
|
})).ServeHTTP(rw, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SkipAuthProxy proxies allowlisted requests and skips authentication
|
|
||||||
func (p *OAuthProxy) SkipAuthProxy(rw http.ResponseWriter, req *http.Request) {
|
|
||||||
p.headersChain.Then(p.serveMux).ServeHTTP(rw, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxy proxies the user request if the user is authenticated else it prompts
|
// Proxy proxies the user request if the user is authenticated else it prompts
|
||||||
// them to authenticate
|
// them to authenticate
|
||||||
func (p *OAuthProxy) Proxy(rw http.ResponseWriter, req *http.Request) {
|
func (p *OAuthProxy) Proxy(rw http.ResponseWriter, req *http.Request) {
|
||||||
@ -870,7 +873,7 @@ func (p *OAuthProxy) Proxy(rw http.ResponseWriter, req *http.Request) {
|
|||||||
case nil:
|
case nil:
|
||||||
// we are authenticated
|
// we are authenticated
|
||||||
p.addHeadersForProxying(rw, session)
|
p.addHeadersForProxying(rw, session)
|
||||||
p.headersChain.Then(p.serveMux).ServeHTTP(rw, req)
|
p.headersChain.Then(p.upstreamProxy).ServeHTTP(rw, req)
|
||||||
case ErrNeedsLogin:
|
case ErrNeedsLogin:
|
||||||
// we need to send the user to a login screen
|
// we need to send the user to a login screen
|
||||||
if isAjax(req) {
|
if isAjax(req) {
|
||||||
@ -910,6 +913,13 @@ func prepareNoCache(w http.ResponseWriter) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prepareNoCacheMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
prepareNoCache(rw)
|
||||||
|
next.ServeHTTP(rw, req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// getOAuthRedirectURI returns the redirectURL that the upstream OAuth Provider will
|
// getOAuthRedirectURI returns the redirectURL that the upstream OAuth Provider will
|
||||||
// redirect clients to once authenticated.
|
// redirect clients to once authenticated.
|
||||||
// This is usually the OAuthProxy callback URL.
|
// This is usually the OAuthProxy callback URL.
|
||||||
@ -1095,12 +1105,12 @@ func validOptionalPort(port string) bool {
|
|||||||
// - `nil, ErrAccessDenied` if the authenticated user is not authorized
|
// - `nil, ErrAccessDenied` if the authenticated user is not authorized
|
||||||
// Set-Cookie headers may be set on the response as a side-effect of calling this method.
|
// Set-Cookie headers may be set on the response as a side-effect of calling this method.
|
||||||
func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.Request) (*sessionsapi.SessionState, error) {
|
func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.Request) (*sessionsapi.SessionState, error) {
|
||||||
var session *sessionsapi.SessionState
|
session := middlewareapi.GetRequestScope(req).Session
|
||||||
|
|
||||||
getSession := p.sessionChain.Then(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
// Check this after loading the session so that if a valid session exists, we can add headers from it
|
||||||
session = middlewareapi.GetRequestScope(req).Session
|
if p.IsAllowedRequest(req) {
|
||||||
}))
|
return session, nil
|
||||||
getSession.ServeHTTP(rw, req)
|
}
|
||||||
|
|
||||||
if session == nil {
|
if session == nil {
|
||||||
return nil, ErrNeedsLogin
|
return nil, ErrNeedsLogin
|
||||||
@ -1190,6 +1200,9 @@ func decodeState(req *http.Request) (string, string, error) {
|
|||||||
|
|
||||||
// addHeadersForProxying adds the appropriate headers the request / response for proxying
|
// addHeadersForProxying adds the appropriate headers the request / response for proxying
|
||||||
func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, session *sessionsapi.SessionState) {
|
func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, session *sessionsapi.SessionState) {
|
||||||
|
if session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if session.Email == "" {
|
if session.Email == "" {
|
||||||
rw.Header().Set("GAP-Auth", session.User)
|
rw.Header().Set("GAP-Auth", session.User)
|
||||||
} else {
|
} else {
|
||||||
|
Reference in New Issue
Block a user