mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-24 16:17:11 +08:00
This change adds validation for ID tokens generated for GCP VMs. (#24492)
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright 2023 Gravitational, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package gcp
|
||||
|
||||
import (
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
)
|
||||
|
||||
// defaultIssuerHost is the issuer for GCP ID tokens.
|
||||
const defaultIssuerHost = "accounts.google.com"
|
||||
|
||||
// ComputeEngine contains VM-specific token claims.
|
||||
type computeEngine struct {
|
||||
// The ID of the instance's project.
|
||||
ProjectID string `json:"project_id"`
|
||||
// The instance's zone.
|
||||
Zone string `json:"zone"`
|
||||
// The instance's ID.
|
||||
InstanceID string `json:"instance_id"`
|
||||
// The instance's name.
|
||||
InstanceName string `json:"instance_name"`
|
||||
}
|
||||
|
||||
// Google contains Google-specific token claims.
|
||||
type google struct {
|
||||
ComputeEngine computeEngine `json:"compute_engine"`
|
||||
}
|
||||
|
||||
// IDTokenClaims is the set of claims in a GCP ID token. GCP documentation for
|
||||
// claims can be found at
|
||||
// https://cloud.google.com/compute/docs/instances/verifying-instance-identity#payload
|
||||
type IDTokenClaims struct {
|
||||
// The email of the service account that this token was issued for.
|
||||
Email string `json:"email"`
|
||||
Google google `json:"google"`
|
||||
}
|
||||
|
||||
// JoinAuditAttributes returns a series of attributes that can be inserted into
|
||||
// audit events related to a specific join.
|
||||
func (c *IDTokenClaims) JoinAuditAttributes() (map[string]interface{}, error) {
|
||||
res := map[string]interface{}{}
|
||||
d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
TagName: "json",
|
||||
Result: &res,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
if err := d.Decode(c); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Copyright 2023 Gravitational, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package gcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/jonboulle/clockwork"
|
||||
|
||||
"github.com/gravitational/teleport/lib/jwt"
|
||||
)
|
||||
|
||||
// IDTokenValidatorConfig is the config for IDTokenValidator.
|
||||
type IDTokenValidatorConfig struct {
|
||||
// Clock is used by the validator when checking expiry and issuer times of
|
||||
// tokens. If omitted, a real clock will be used.
|
||||
Clock clockwork.Clock
|
||||
// issuerHost is the host of the Issuer for tokens issued by Google, to be
|
||||
// overridden in tests. Defaults to "accounts.google.com".
|
||||
issuerHost string
|
||||
// insecure configures the validator to use HTTP rather than HTTPS, to be
|
||||
// overridden in tests.
|
||||
insecure bool
|
||||
}
|
||||
|
||||
// IDTokenValidator validates ID tokens from GCP.
|
||||
type IDTokenValidator struct {
|
||||
IDTokenValidatorConfig
|
||||
}
|
||||
|
||||
func NewIDTokenValidator(cfg IDTokenValidatorConfig) *IDTokenValidator {
|
||||
if cfg.Clock == nil {
|
||||
cfg.Clock = clockwork.NewRealClock()
|
||||
}
|
||||
if cfg.issuerHost == "" {
|
||||
cfg.issuerHost = defaultIssuerHost
|
||||
}
|
||||
return &IDTokenValidator{
|
||||
IDTokenValidatorConfig: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (id *IDTokenValidator) issuerURL() string {
|
||||
scheme := "https"
|
||||
if id.insecure {
|
||||
scheme = "http"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, id.issuerHost)
|
||||
}
|
||||
|
||||
// Validate validates an ID token.
|
||||
func (id *IDTokenValidator) Validate(ctx context.Context, token string) (*IDTokenClaims, error) {
|
||||
p, err := oidc.NewProvider(ctx, id.issuerURL())
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
verifier := p.Verifier(&oidc.Config{
|
||||
ClientID: "teleport.cluster.local",
|
||||
Now: id.Clock.Now,
|
||||
})
|
||||
|
||||
idToken, err := verifier.Verify(ctx, token)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// `go-oidc` does not implement not before check, so we need to manually
|
||||
// perform this
|
||||
if err := jwt.CheckNotBefore(id.Clock.Now(), time.Minute*2, idToken); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
claims := IDTokenClaims{}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
Copyright 2023 Gravitational, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package gcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jonboulle/clockwork"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
"gopkg.in/square/go-jose.v2/jwt"
|
||||
)
|
||||
|
||||
type fakeIDP struct {
|
||||
t *testing.T
|
||||
signer jose.Signer
|
||||
privateKey *rsa.PrivateKey
|
||||
server *httptest.Server
|
||||
}
|
||||
|
||||
func newFakeIDP(t *testing.T) *fakeIDP {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
signer, err := jose.NewSigner(
|
||||
jose.SigningKey{Algorithm: jose.RS256, Key: privateKey},
|
||||
(&jose.SignerOptions{}).WithType("JWT"),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
f := &fakeIDP{
|
||||
signer: signer,
|
||||
privateKey: privateKey,
|
||||
t: t,
|
||||
}
|
||||
|
||||
providerMux := http.NewServeMux()
|
||||
providerMux.HandleFunc(
|
||||
"/.well-known/openid-configuration",
|
||||
f.handleOpenIDConfig,
|
||||
)
|
||||
providerMux.HandleFunc(
|
||||
"/.well-known/jwks",
|
||||
f.handleJWKSEndpoint,
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(providerMux)
|
||||
t.Cleanup(srv.Close)
|
||||
f.server = srv
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeIDP) issuer() string {
|
||||
return f.server.URL
|
||||
}
|
||||
|
||||
func (f *fakeIDP) handleOpenIDConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response := map[string]interface{}{
|
||||
"claims_supported": []string{
|
||||
"sub",
|
||||
"aud",
|
||||
"exp",
|
||||
"iat",
|
||||
"iss",
|
||||
"azp",
|
||||
"email",
|
||||
"google",
|
||||
},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"issuer": f.issuer(),
|
||||
"jwks_uri": f.issuer() + "/.well-known/jwks",
|
||||
"response_types_supported": []string{"id_token"},
|
||||
"scopes_supported": []string{"openid"},
|
||||
"subject_types_supported": []string{"public", "pairwise"},
|
||||
}
|
||||
responseBytes, err := json.Marshal(response)
|
||||
require.NoError(f.t, err)
|
||||
_, err = w.Write(responseBytes)
|
||||
require.NoError(f.t, err)
|
||||
}
|
||||
|
||||
func (f *fakeIDP) handleJWKSEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
jwks := jose.JSONWebKeySet{
|
||||
Keys: []jose.JSONWebKey{
|
||||
{
|
||||
Key: &f.privateKey.PublicKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
responseBytes, err := json.Marshal(jwks)
|
||||
require.NoError(f.t, err)
|
||||
_, err = w.Write(responseBytes)
|
||||
require.NoError(f.t, err)
|
||||
}
|
||||
|
||||
func (f *fakeIDP) issueToken(
|
||||
t *testing.T,
|
||||
issuer,
|
||||
audience,
|
||||
sub string,
|
||||
claims computeEngine,
|
||||
issuedAt time.Time,
|
||||
expiry time.Time,
|
||||
) string {
|
||||
stdClaims := jwt.Claims{
|
||||
Issuer: issuer,
|
||||
Subject: sub,
|
||||
Audience: jwt.Audience{audience},
|
||||
IssuedAt: jwt.NewNumericDate(issuedAt),
|
||||
NotBefore: jwt.NewNumericDate(issuedAt),
|
||||
Expiry: jwt.NewNumericDate(expiry),
|
||||
}
|
||||
token, err := jwt.Signed(f.signer).
|
||||
Claims(stdClaims).
|
||||
Claims(IDTokenClaims{
|
||||
Google: google{
|
||||
ComputeEngine: claims,
|
||||
},
|
||||
}).
|
||||
CompactSerialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
func TestIDTokenValidator_Validate(t *testing.T) {
|
||||
t.Parallel()
|
||||
idp := newFakeIDP(t)
|
||||
clock := clockwork.NewFakeClock()
|
||||
|
||||
sampleCE := computeEngine{
|
||||
ProjectID: "12345678",
|
||||
Zone: "z",
|
||||
InstanceID: "87654321",
|
||||
InstanceName: "test-instance",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
assertError require.ErrorAssertionFunc
|
||||
want computeEngine
|
||||
token string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
assertError: require.NoError,
|
||||
token: idp.issueToken(
|
||||
t,
|
||||
idp.issuer(),
|
||||
"teleport.cluster.local",
|
||||
"abcd1234",
|
||||
sampleCE,
|
||||
clock.Now().Add(-5*time.Minute),
|
||||
clock.Now().Add(5*time.Minute),
|
||||
),
|
||||
want: sampleCE,
|
||||
},
|
||||
{
|
||||
name: "expired",
|
||||
assertError: require.Error,
|
||||
token: idp.issueToken(
|
||||
t,
|
||||
idp.issuer(),
|
||||
"teleport.cluster.local",
|
||||
"abcd1234",
|
||||
sampleCE,
|
||||
clock.Now().Add(-15*time.Minute),
|
||||
clock.Now().Add(-5*time.Minute),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "future",
|
||||
assertError: require.Error,
|
||||
token: idp.issueToken(
|
||||
t,
|
||||
idp.issuer(),
|
||||
"teleport.cluster.local",
|
||||
"abcd1234",
|
||||
sampleCE,
|
||||
clock.Now().Add(10*time.Minute),
|
||||
clock.Now().Add(20*time.Minute),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "invalid audience",
|
||||
assertError: require.Error,
|
||||
token: idp.issueToken(
|
||||
t,
|
||||
idp.issuer(),
|
||||
"incorrect.audience",
|
||||
"abcd1234",
|
||||
sampleCE,
|
||||
clock.Now().Add(-5*time.Minute),
|
||||
clock.Now().Add(5*time.Minute),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "invalid issuer",
|
||||
assertError: require.Error,
|
||||
token: idp.issueToken(
|
||||
t,
|
||||
"http://the.wrong.issuer",
|
||||
"teleport.cluster.local",
|
||||
"abcd1234",
|
||||
sampleCE,
|
||||
clock.Now().Add(-5*time.Minute),
|
||||
clock.Now().Add(5*time.Minute),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
v := NewIDTokenValidator(IDTokenValidatorConfig{
|
||||
Clock: clock,
|
||||
issuerHost: idp.server.Listener.Addr().String(),
|
||||
insecure: true,
|
||||
})
|
||||
claims, err := v.Validate(ctx, tc.token)
|
||||
tc.assertError(t, err)
|
||||
if err == nil {
|
||||
require.NotNil(t, claims)
|
||||
require.Equal(t, tc.want, claims.Google.ComputeEngine)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,14 @@ package githubactions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/jonboulle/clockwork"
|
||||
|
||||
"github.com/gravitational/teleport/lib/jwt"
|
||||
)
|
||||
|
||||
type IDTokenValidatorConfig struct {
|
||||
@@ -90,7 +91,7 @@ func (id *IDTokenValidator) Validate(ctx context.Context, GHESHost string, token
|
||||
|
||||
// `go-oidc` does not implement not before check, so we need to manually
|
||||
// perform this
|
||||
if err := checkNotBefore(id.Clock.Now(), time.Minute*2, idToken); err != nil {
|
||||
if err := jwt.CheckNotBefore(id.Clock.Now(), time.Minute*2, idToken); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
@@ -100,50 +101,3 @@ func (id *IDTokenValidator) Validate(ctx context.Context, GHESHost string, token
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
// checkNotBefore ensures the token was not issued in the future.
|
||||
// https://www.rfc-editor.org/rfc/rfc7519#section-4.1.5
|
||||
// 4.1.5. "nbf" (Not Before) Claim
|
||||
// TODO(strideynet): upstream support for `nbf` into the go-oidc lib.
|
||||
func checkNotBefore(now time.Time, leeway time.Duration, token *oidc.IDToken) error {
|
||||
claims := struct {
|
||||
NotBefore *jsonTime `json:"nbf"`
|
||||
}{}
|
||||
if err := token.Claims(&claims); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
if claims.NotBefore != nil {
|
||||
adjustedNow := now.Add(leeway)
|
||||
nbf := time.Time(*claims.NotBefore)
|
||||
if adjustedNow.Before(nbf) {
|
||||
return trace.AccessDenied("token not before in future")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonTime unmarshaling sourced from https://github.com/gravitational/go-oidc/blob/master/oidc.go#L295
|
||||
// TODO(strideynet): upstream support for `nbf` into the go-oidc lib.
|
||||
type jsonTime time.Time
|
||||
|
||||
func (j *jsonTime) UnmarshalJSON(b []byte) error {
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
var unix int64
|
||||
|
||||
if t, err := n.Int64(); err == nil {
|
||||
unix = t
|
||||
} else {
|
||||
f, err := n.Float64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unix = int64(f)
|
||||
}
|
||||
*j = jsonTime(time.Unix(unix, 0))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +26,7 @@ import (
|
||||
"github.com/jonboulle/clockwork"
|
||||
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
"github.com/gravitational/teleport/lib/jwt"
|
||||
"github.com/gravitational/teleport/lib/services"
|
||||
)
|
||||
|
||||
@@ -105,7 +105,7 @@ func (id *IDTokenValidator) Validate(
|
||||
|
||||
// `go-oidc` does not implement not before check, so we need to manually
|
||||
// perform this
|
||||
if err := checkNotBefore(
|
||||
if err := jwt.CheckNotBefore(
|
||||
id.Clock.Now(), time.Minute*2, idToken,
|
||||
); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
@@ -117,53 +117,3 @@ func (id *IDTokenValidator) Validate(
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
// checkNotBefore ensures the token was not issued in the future.
|
||||
// https://www.rfc-editor.org/rfc/rfc7519#section-4.1.5
|
||||
// 4.1.5. "nbf" (Not Before) Claim
|
||||
// TODO(strideynet): upstream support for `nbf` into the go-oidc lib.
|
||||
func checkNotBefore(
|
||||
now time.Time, leeway time.Duration, token *oidc.IDToken,
|
||||
) error {
|
||||
claims := struct {
|
||||
NotBefore *jsonTime `json:"nbf"`
|
||||
}{}
|
||||
if err := token.Claims(&claims); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
if claims.NotBefore != nil {
|
||||
adjustedNow := now.Add(leeway)
|
||||
nbf := time.Time(*claims.NotBefore)
|
||||
if adjustedNow.Before(nbf) {
|
||||
return trace.AccessDenied("token not before in future")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonTime unmarshaling sourced from
|
||||
// https://github.com/gravitational/go-oidc/blob/master/oidc.go#L295
|
||||
// TODO(strideynet): upstream support for `nbf` into the go-oidc lib.
|
||||
type jsonTime time.Time
|
||||
|
||||
func (j *jsonTime) UnmarshalJSON(b []byte) error {
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
var unix int64
|
||||
|
||||
if t, err := n.Int64(); err == nil {
|
||||
unix = t
|
||||
} else {
|
||||
f, err := n.Float64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unix = int64(f)
|
||||
}
|
||||
*j = jsonTime(time.Unix(unix, 0))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,10 +24,12 @@ import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/jonboulle/clockwork"
|
||||
@@ -524,3 +526,50 @@ func GenerateKeyPair() ([]byte, []byte, error) {
|
||||
|
||||
return public, private, nil
|
||||
}
|
||||
|
||||
// CheckNotBefore ensures the token was not issued in the future.
|
||||
// https://www.rfc-editor.org/rfc/rfc7519#section-4.1.5
|
||||
// 4.1.5. "nbf" (Not Before) Claim
|
||||
// TODO(strideynet): upstream support for `nbf` into the go-oidc lib.
|
||||
func CheckNotBefore(now time.Time, leeway time.Duration, token *oidc.IDToken) error {
|
||||
claims := struct {
|
||||
NotBefore *JSONTime `json:"nbf"`
|
||||
}{}
|
||||
if err := token.Claims(&claims); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
if claims.NotBefore != nil {
|
||||
adjustedNow := now.Add(leeway)
|
||||
nbf := time.Time(*claims.NotBefore)
|
||||
if adjustedNow.Before(nbf) {
|
||||
return trace.AccessDenied("token not before in future")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// JSONTime unmarshaling sourced from https://github.com/gravitational/go-oidc/blob/master/oidc.go#L295
|
||||
// TODO(strideynet): upstream support for `nbf` into the go-oidc lib.
|
||||
type JSONTime time.Time
|
||||
|
||||
func (j *JSONTime) UnmarshalJSON(b []byte) error {
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
var unix int64
|
||||
|
||||
if t, err := n.Int64(); err == nil {
|
||||
unix = t
|
||||
} else {
|
||||
f, err := n.Float64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unix = int64(f)
|
||||
}
|
||||
*j = JSONTime(time.Unix(unix, 0))
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user