mirror of
https://github.com/oauth2-proxy/oauth2-proxy.git
synced 2025-01-10 04:18:14 +02:00
Merge pull request #1210 from pb82/keycloak-oidc-provider
Keycloak oidc provider
This commit is contained in:
commit
526aff8c84
@ -8,6 +8,8 @@
|
||||
deserialization from v6.0.0 (only) has been removed to improve performance. If you are on v6.0.0, either upgrade
|
||||
to a version before this first and allow legacy sessions to expire gracefully or change your `cookie-secret`
|
||||
value and force all sessions to reauthenticate.
|
||||
|
||||
- [#1210](https://github.com/oauth2-proxy/oauth2-proxy/pull/1210) A new `keycloak-oidc` provider has been added with support for role based authentication. The existing keycloak auth provider will eventually be deprecated and removed. Please switch to the new provider `keycloak-oidc`.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
@ -27,6 +29,7 @@
|
||||
- [#1142](https://github.com/oauth2-proxy/oauth2-proxy/pull/1142) Add pagewriter to upstream proxy (@JoelSpeed)
|
||||
- [#1181](https://github.com/oauth2-proxy/oauth2-proxy/pull/1181) Fix incorrect `cfg` name in show-debug-on-error flag (@iTaybb)
|
||||
- [#1207](https://github.com/oauth2-proxy/oauth2-proxy/pull/1207) Fix URI fragment handling on sign-in page, regression introduced in 7.1.0 (@tarvip)
|
||||
- [#1210](https://github.com/oauth2-proxy/oauth2-proxy/pull/1210) New Keycloak OIDC Provider (@pb82)
|
||||
- [#1244](https://github.com/oauth2-proxy/oauth2-proxy/pull/1244) Update Alpine image version to 3.14 (@ahovgaard)
|
||||
|
||||
|
||||
|
@ -250,6 +250,7 @@ make up the header value
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `groups` | _[]string_ | Group enables to restrict login to members of indicated group |
|
||||
| `roles` | _[]string_ | Role enables to restrict login to users with role (only available when using the keycloak-oidc provider) |
|
||||
|
||||
### LoginGovOptions
|
||||
|
||||
|
@ -146,12 +146,15 @@ If you are using GitHub enterprise, make sure you set the following to the appro
|
||||
|
||||
### Keycloak Auth Provider
|
||||
|
||||
1. Create new client in your Keycloak with **Access Type** 'confidental' and **Valid Redirect URIs** 'https://internal.yourcompany.com/oauth2/callback'
|
||||
1. Create new client in your Keycloak realm with **Access Type** 'confidental' and **Valid Redirect URIs** 'https://internal.yourcompany.com/oauth2/callback'
|
||||
2. Take note of the Secret in the credential tab of the client
|
||||
3. Create a mapper with **Mapper Type** 'Group Membership' and **Token Claim Name** 'groups'.
|
||||
|
||||
:::note this is the legacy Keycloak Auth Prodiver, use `keycloak-oidc` if possible. :::
|
||||
|
||||
Make sure you set the following to the appropriate url:
|
||||
|
||||
```
|
||||
--provider=keycloak
|
||||
--client-id=<client you have created>
|
||||
--client-secret=<your client's secret>
|
||||
@ -161,6 +164,7 @@ Make sure you set the following to the appropriate url:
|
||||
--validate-url="http(s)://<keycloak host>/auth/realms/<your realm>/protocol/openid-connect/userinfo"
|
||||
--keycloak-group=<first_allowed_user_group>
|
||||
--keycloak-group=<second_allowed_user_group>
|
||||
```
|
||||
|
||||
For group based authorization, the optional `--keycloak-group` (legacy) or `--allowed-group` (global standard)
|
||||
flags can be used to specify which groups to limit access to.
|
||||
@ -172,6 +176,25 @@ Keycloak userinfo endpoint response.
|
||||
The group management in keycloak is using a tree. If you create a group named admin in keycloak
|
||||
you should define the 'keycloak-group' value to /admin.
|
||||
|
||||
### Keycloak OIDC Auth Provider
|
||||
|
||||
1. Create new client in your Keycloak realm with **Access Type** 'confidental', **Client protocol** 'openid-connect' and **Valid Redirect URIs** 'https://internal.yourcompany.com/oauth2/callback'
|
||||
2. Take note of the Secret in the credential tab of the client
|
||||
3. Create a mapper with **Mapper Type** 'Group Membership' and **Token Claim Name** 'groups'.
|
||||
4. Create a mapper with **Mapper Type** 'Audience' and **Included Client Audience** and **Included Custom Audience** set to your client name.
|
||||
|
||||
Make sure you set the following to the appropriate url:
|
||||
|
||||
```
|
||||
--provider=keycloak-oidc
|
||||
--client-id=<your client's id>
|
||||
--client-secret=<your client's secret>
|
||||
--redirect-url=https://myapp.com/oauth2/callback
|
||||
--oidc-issuer-url=https://<keycloak host>/auth/<your realm>/basic
|
||||
--allowed-role=<realm role name> // Optional, required realm role
|
||||
--allowed-role=<client id>:<client role name> // Optional, required client role
|
||||
```
|
||||
|
||||
### GitLab Auth Provider
|
||||
|
||||
This auth provider has been tested against Gitlab version 12.X. Due to Gitlab API changes, it may not work for version prior to 12.X (see [994](https://github.com/oauth2-proxy/oauth2-proxy/issues/994)).
|
||||
|
@ -192,6 +192,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
|
||||
| `--tls-key-file` | string | path to private key file | |
|
||||
| `--upstream` | string \| list | the http url(s) of the upstream endpoint, file:// paths for static files or `static://<status_code>` for static response. Routing is based on the path | |
|
||||
| `--allowed-group` | string \| list | restrict logins to members of this group (may be given multiple times) | |
|
||||
| `--allowed-role` | string \| list | restrict logins to users with this role (may be given multiple times). Only works with the keycloak-oidc provider. | |
|
||||
| `--validate-url` | string | Access token validation endpoint | |
|
||||
| `--version` | n/a | print version string | |
|
||||
| `--whitelist-domain` | string \| list | allowed domains for redirection after authentication. Prefix domain with a `.` to allow subdomains (e.g. `.example.com`) \[[2](#footnote2)\] | |
|
||||
|
@ -508,6 +508,7 @@ type LegacyProvider struct {
|
||||
ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0
|
||||
UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"`
|
||||
AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"`
|
||||
AllowedRoles []string `flag:"allowed-role" cfg:"allowed_roles"`
|
||||
|
||||
AcrValues string `flag:"acr-values" cfg:"acr_values"`
|
||||
JWTKey string `flag:"jwt-key" cfg:"jwt_key"`
|
||||
@ -563,6 +564,7 @@ func legacyProviderFlagSet() *pflag.FlagSet {
|
||||
|
||||
flagSet.String("user-id-claim", providers.OIDCEmailClaim, "(DEPRECATED for `oidc-email-claim`) which claim contains the user ID")
|
||||
flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)")
|
||||
flagSet.StringSlice("allowed-role", []string{}, "(keycloak-oidc) restrict logins to members of these roles (may be given multiple times)")
|
||||
|
||||
return flagSet
|
||||
}
|
||||
@ -656,6 +658,11 @@ func (l *LegacyProvider) convert() (Providers, error) {
|
||||
Token: l.GitHubToken,
|
||||
Users: l.GitHubUsers,
|
||||
}
|
||||
case "keycloak-oidc":
|
||||
provider.KeycloakConfig = KeycloakOptions{
|
||||
Groups: l.KeycloakGroups,
|
||||
Roles: l.AllowedRoles,
|
||||
}
|
||||
case "keycloak":
|
||||
provider.KeycloakConfig = KeycloakOptions{
|
||||
Groups: l.KeycloakGroups,
|
||||
|
@ -78,6 +78,9 @@ type Provider struct {
|
||||
type KeycloakOptions struct {
|
||||
// Group enables to restrict login to members of indicated group
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
|
||||
// Role enables to restrict login to users with role (only available when using the keycloak-oidc provider)
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
}
|
||||
|
||||
type AzureOptions struct {
|
||||
|
@ -247,6 +247,11 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
|
||||
if len(o.Providers[0].KeycloakConfig.Groups) > 0 {
|
||||
p.SetAllowedGroups(o.Providers[0].KeycloakConfig.Groups)
|
||||
}
|
||||
case *providers.KeycloakOIDCProvider:
|
||||
if p.Verifier == nil {
|
||||
msgs = append(msgs, "keycloak-oidc provider requires an oidc issuer URL")
|
||||
}
|
||||
p.AddAllowedRoles(o.Providers[0].KeycloakConfig.Roles)
|
||||
case *providers.GoogleProvider:
|
||||
if o.Providers[0].GoogleConfig.ServiceAccountJSON != "" {
|
||||
file, err := os.Open(o.Providers[0].GoogleConfig.ServiceAccountJSON)
|
||||
|
142
providers/keycloak_oidc.go
Normal file
142
providers/keycloak_oidc.go
Normal file
@ -0,0 +1,142 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
|
||||
)
|
||||
|
||||
const keycloakOIDCProviderName = "Keycloak OIDC"
|
||||
|
||||
// KeycloakOIDCProvider creates a Keycloak provider based on OIDCProvider
|
||||
type KeycloakOIDCProvider struct {
|
||||
*OIDCProvider
|
||||
}
|
||||
|
||||
// NewKeycloakOIDCProvider makes a KeycloakOIDCProvider using the ProviderData
|
||||
func NewKeycloakOIDCProvider(p *ProviderData) *KeycloakOIDCProvider {
|
||||
p.ProviderName = keycloakOIDCProviderName
|
||||
return &KeycloakOIDCProvider{
|
||||
OIDCProvider: &OIDCProvider{
|
||||
ProviderData: p,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var _ Provider = (*KeycloakOIDCProvider)(nil)
|
||||
|
||||
// AddAllowedRoles sets Keycloak roles that are authorized.
|
||||
// Assumes `SetAllowedGroups` is already called on groups and appends to that
|
||||
// with `role:` prefixed roles.
|
||||
func (p *KeycloakOIDCProvider) AddAllowedRoles(roles []string) {
|
||||
if p.AllowedGroups == nil {
|
||||
p.AllowedGroups = make(map[string]struct{})
|
||||
}
|
||||
for _, role := range roles {
|
||||
p.AllowedGroups[formatRole(role)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// EnrichSession is called after Redeem to allow providers to enrich session fields
|
||||
// such as User, Email, Groups with provider specific API calls.
|
||||
func (p *KeycloakOIDCProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
|
||||
err := p.OIDCProvider.EnrichSession(ctx, s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not enrich oidc session: %v", err)
|
||||
}
|
||||
return p.extractRoles(ctx, s)
|
||||
}
|
||||
|
||||
// RefreshSession adds role extraction logic to the refresh flow
|
||||
func (p *KeycloakOIDCProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) {
|
||||
refreshed, err := p.OIDCProvider.RefreshSession(ctx, s)
|
||||
|
||||
// Refresh could have failed or there was not session to refresh (with no error raised)
|
||||
if err != nil || !refreshed {
|
||||
return refreshed, err
|
||||
}
|
||||
|
||||
return true, p.extractRoles(ctx, s)
|
||||
}
|
||||
|
||||
func (p *KeycloakOIDCProvider) extractRoles(ctx context.Context, s *sessions.SessionState) error {
|
||||
claims, err := p.getAccessClaims(ctx, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var roles []string
|
||||
roles = append(roles, claims.RealmAccess.Roles...)
|
||||
roles = append(roles, getClientRoles(claims)...)
|
||||
|
||||
// Add to groups list with `role:` prefix to distinguish from groups
|
||||
for _, role := range roles {
|
||||
s.Groups = append(s.Groups, formatRole(role))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type realmAccess struct {
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
|
||||
type accessClaims struct {
|
||||
RealmAccess realmAccess `json:"realm_access"`
|
||||
ResourceAccess map[string]interface{} `json:"resource_access"`
|
||||
}
|
||||
|
||||
func (p *KeycloakOIDCProvider) getAccessClaims(ctx context.Context, s *sessions.SessionState) (*accessClaims, error) {
|
||||
// HACK: This isn't an ID Token, but has similar structure & signing
|
||||
token, err := p.Verifier.Verify(ctx, s.AccessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var claims *accessClaims
|
||||
if err = token.Claims(&claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// getClientRoles extracts client roles from the `resource_access` claim with
|
||||
// the format `client:role`.
|
||||
//
|
||||
// ResourceAccess format:
|
||||
// "resource_access": {
|
||||
// "clientA": {
|
||||
// "roles": [
|
||||
// "roleA"
|
||||
// ]
|
||||
// },
|
||||
// "clientB": {
|
||||
// "roles": [
|
||||
// "roleA",
|
||||
// "roleB",
|
||||
// "roleC"
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
func getClientRoles(claims *accessClaims) []string {
|
||||
var clientRoles []string
|
||||
for clientName, access := range claims.ResourceAccess {
|
||||
accessMap, ok := access.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var roles interface{}
|
||||
if roles, ok = accessMap["roles"]; !ok {
|
||||
continue
|
||||
}
|
||||
for _, role := range roles.([]interface{}) {
|
||||
clientRoles = append(clientRoles, fmt.Sprintf("%s:%s", clientName, role))
|
||||
}
|
||||
}
|
||||
return clientRoles
|
||||
}
|
||||
|
||||
func formatRole(role string) string {
|
||||
return fmt.Sprintf("role:%s", role)
|
||||
}
|
189
providers/keycloak_oidc_test.go
Normal file
189
providers/keycloak_oidc_test.go
Normal file
@ -0,0 +1,189 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
|
||||
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const (
|
||||
accessTokenHeader = "ewogICJhbGciOiAiUlMyNTYiLAogICJ0eXAiOiAiSldUIgp9"
|
||||
accessTokenPayload = "eyJyZWFsbV9hY2Nlc3MiOiB7InJvbGVzIjogWyJ3cml0ZSJdfSwgInJlc291cmNlX2FjY2VzcyI6IHsiZGVmYXVsdCI6IHsicm9sZXMiOiBbInJlYWQiXX19fQ"
|
||||
accessTokenSignature = "dyt0CoTl4WoVjAHI9Q_CwSKhl6d_9rhM3NrXuJttkao"
|
||||
)
|
||||
|
||||
type DummyKeySet struct{}
|
||||
|
||||
func (DummyKeySet) VerifySignature(_ context.Context, _ string) (payload []byte, err error) {
|
||||
p, _ := base64.RawURLEncoding.DecodeString(accessTokenPayload)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func getAccessToken() string {
|
||||
return fmt.Sprintf("%s.%s.%s", accessTokenHeader, accessTokenPayload, accessTokenSignature)
|
||||
}
|
||||
|
||||
func newTestKeycloakOIDCSetup() (*httptest.Server, *KeycloakOIDCProvider) {
|
||||
redeemURL, server := newOIDCServer([]byte(fmt.Sprintf(`{"email": "new@thing.com", "expires_in": 300, "access_token": "%v"}`, getAccessToken())))
|
||||
provider := newKeycloakOIDCProvider(redeemURL)
|
||||
return server, provider
|
||||
}
|
||||
|
||||
func newKeycloakOIDCProvider(serverURL *url.URL) *KeycloakOIDCProvider {
|
||||
p := NewKeycloakOIDCProvider(
|
||||
&ProviderData{
|
||||
LoginURL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "keycloak-oidc.com",
|
||||
Path: "/oauth/auth"},
|
||||
RedeemURL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "keycloak-oidc.com",
|
||||
Path: "/oauth/token"},
|
||||
ProfileURL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "keycloak-oidc.com",
|
||||
Path: "/api/v3/user"},
|
||||
ValidateURL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "keycloak-oidc.com",
|
||||
Path: "/api/v3/user"},
|
||||
Scope: "openid email profile"})
|
||||
|
||||
if serverURL != nil {
|
||||
p.RedeemURL.Scheme = serverURL.Scheme
|
||||
p.RedeemURL.Host = serverURL.Host
|
||||
}
|
||||
|
||||
keyset := DummyKeySet{}
|
||||
p.Verifier = oidc.NewVerifier("", keyset, &oidc.Config{
|
||||
ClientID: "client",
|
||||
SkipIssuerCheck: true,
|
||||
SkipClientIDCheck: true,
|
||||
SkipExpiryCheck: true,
|
||||
})
|
||||
p.EmailClaim = "email"
|
||||
p.GroupsClaim = "groups"
|
||||
return p
|
||||
}
|
||||
|
||||
var _ = Describe("Keycloak OIDC Provider Tests", func() {
|
||||
Context("New Provider Init", func() {
|
||||
It("creates new keycloak oidc provider with expected defaults", func() {
|
||||
p := newKeycloakOIDCProvider(nil)
|
||||
providerData := p.Data()
|
||||
Expect(providerData.ProviderName).To(Equal(keycloakOIDCProviderName))
|
||||
Expect(providerData.LoginURL.String()).To(Equal("https://keycloak-oidc.com/oauth/auth"))
|
||||
Expect(providerData.RedeemURL.String()).To(Equal("https://keycloak-oidc.com/oauth/token"))
|
||||
Expect(providerData.ProfileURL.String()).To(Equal("https://keycloak-oidc.com/api/v3/user"))
|
||||
Expect(providerData.ValidateURL.String()).To(Equal("https://keycloak-oidc.com/api/v3/user"))
|
||||
Expect(providerData.Scope).To(Equal("openid email profile"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Allowed Roles", func() {
|
||||
It("should prefix allowed roles and add them to groups", func() {
|
||||
p := newKeycloakOIDCProvider(nil)
|
||||
p.AddAllowedRoles([]string{"admin", "editor"})
|
||||
Expect(p.AllowedGroups).To(HaveKey("role:admin"))
|
||||
Expect(p.AllowedGroups).To(HaveKey("role:editor"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Enrich Session", func() {
|
||||
It("should not fail when groups are not assigned", func() {
|
||||
server, provider := newTestKeycloakOIDCSetup()
|
||||
url, err := url.Parse(server.URL)
|
||||
Expect(err).To(BeNil())
|
||||
defer server.Close()
|
||||
|
||||
provider.ProfileURL = url
|
||||
|
||||
existingSession := &sessions.SessionState{
|
||||
User: "already",
|
||||
Email: "a@b.com",
|
||||
Groups: nil,
|
||||
IDToken: idToken,
|
||||
AccessToken: getAccessToken(),
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
expectedSession := &sessions.SessionState{
|
||||
User: "already",
|
||||
Email: "a@b.com",
|
||||
Groups: []string{"role:write", "role:default:read"},
|
||||
IDToken: idToken,
|
||||
AccessToken: getAccessToken(),
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
|
||||
err = provider.EnrichSession(context.Background(), existingSession)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(existingSession).To(Equal(expectedSession))
|
||||
})
|
||||
|
||||
It("should add roles to existing groups", func() {
|
||||
server, provider := newTestKeycloakOIDCSetup()
|
||||
url, err := url.Parse(server.URL)
|
||||
Expect(err).To(BeNil())
|
||||
defer server.Close()
|
||||
|
||||
provider.ProfileURL = url
|
||||
|
||||
existingSession := &sessions.SessionState{
|
||||
User: "already",
|
||||
Email: "a@b.com",
|
||||
Groups: []string{"existing", "group"},
|
||||
IDToken: idToken,
|
||||
AccessToken: getAccessToken(),
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
expectedSession := &sessions.SessionState{
|
||||
User: "already",
|
||||
Email: "a@b.com",
|
||||
Groups: []string{"existing", "group", "role:write", "role:default:read"},
|
||||
IDToken: idToken,
|
||||
AccessToken: getAccessToken(),
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
|
||||
err = provider.EnrichSession(context.Background(), existingSession)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(existingSession).To(Equal(expectedSession))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Refresh Session", func() {
|
||||
It("should refresh session and extract roles again", func() {
|
||||
server, provider := newTestKeycloakOIDCSetup()
|
||||
url, err := url.Parse(server.URL)
|
||||
Expect(err).To(BeNil())
|
||||
defer server.Close()
|
||||
|
||||
provider.ProfileURL = url
|
||||
|
||||
existingSession := &sessions.SessionState{
|
||||
User: "already",
|
||||
Email: "a@b.com",
|
||||
Groups: nil,
|
||||
IDToken: idToken,
|
||||
AccessToken: getAccessToken(),
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
|
||||
refreshed, err := provider.RefreshSession(context.Background(), existingSession)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(refreshed).To(BeTrue())
|
||||
Expect(existingSession.ExpiresOn).ToNot(BeNil())
|
||||
Expect(existingSession.CreatedAt).ToNot(BeNil())
|
||||
Expect(existingSession.Groups).To(BeEquivalentTo([]string{"role:write", "role:default:read"}))
|
||||
})
|
||||
})
|
||||
})
|
@ -31,6 +31,8 @@ func New(provider string, p *ProviderData) Provider {
|
||||
return NewGitHubProvider(p)
|
||||
case "keycloak":
|
||||
return NewKeycloakProvider(p)
|
||||
case "keycloak-oidc":
|
||||
return NewKeycloakOIDCProvider(p)
|
||||
case "azure":
|
||||
return NewAzureProvider(p)
|
||||
case "adfs":
|
||||
|
Loading…
Reference in New Issue
Block a user