mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-30 18:17:55 +00:00
Support pre-hashed passwords with userpass backend (#25862)
* allows use of pre-hashed passwords with userpass backend * Remove unneeded error * Single error check after switch * use param name quoted in error message * updated test for quoted param in error * white space fixes for markdown doc * More whitespace fixes * added changelog * Password/pre-hashed password are only required on 'create' operation * docs indentation * Update website/content/docs/auth/userpass.mdx Co-authored-by: Sarah Chavis <62406755+schavis@users.noreply.github.com> * Updated docs * Check length of hash too * Update builtin/credential/userpass/path_user_password_test.go :) Co-authored-by: Kuba Wieczorek <kuba.wieczorek@hashicorp.com> --------- Co-authored-by: Sarah Chavis <62406755+schavis@users.noreply.github.com> Co-authored-by: Kuba Wieczorek <kuba.wieczorek@hashicorp.com>
This commit is contained in:
@@ -12,7 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-test/deep"
|
"github.com/go-test/deep"
|
||||||
sockaddr "github.com/hashicorp/go-sockaddr"
|
"github.com/hashicorp/go-sockaddr"
|
||||||
logicaltest "github.com/hashicorp/vault/helper/testhelpers/logical"
|
logicaltest "github.com/hashicorp/vault/helper/testhelpers/logical"
|
||||||
"github.com/hashicorp/vault/sdk/helper/policyutil"
|
"github.com/hashicorp/vault/sdk/helper/policyutil"
|
||||||
"github.com/hashicorp/vault/sdk/helper/tokenutil"
|
"github.com/hashicorp/vault/sdk/helper/tokenutil"
|
||||||
|
|||||||
@@ -6,15 +6,37 @@ package userpass
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/hashicorp/vault/sdk/framework"
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
"github.com/hashicorp/vault/sdk/logical"
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pathUserPasswordHelpDesc = `
|
||||||
|
This endpoint allows resetting the user's password.
|
||||||
|
`
|
||||||
|
pathUserPasswordHelpSyn = `
|
||||||
|
Reset user's password.
|
||||||
|
`
|
||||||
|
|
||||||
|
// The name of the username parameter supplied via the API.
|
||||||
|
paramUsername = "username"
|
||||||
|
|
||||||
|
// The name of the password parameter supplied via the API.
|
||||||
|
paramPassword = "password"
|
||||||
|
|
||||||
|
// The name of the password hash parameter supplied via the API.
|
||||||
|
paramPasswordHash = "password_hash"
|
||||||
|
|
||||||
|
// The expected length of any hash generated by bcrypt.
|
||||||
|
bcryptHashLength = 60
|
||||||
|
)
|
||||||
|
|
||||||
func pathUserPassword(b *backend) *framework.Path {
|
func pathUserPassword(b *backend) *framework.Path {
|
||||||
return &framework.Path{
|
return &framework.Path{
|
||||||
Pattern: "users/" + framework.GenericNameRegex("username") + "/password$",
|
Pattern: "users/" + framework.GenericNameRegex(paramUsername) + "/password$",
|
||||||
|
|
||||||
DisplayAttrs: &framework.DisplayAttributes{
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
OperationPrefix: operationPrefixUserpass,
|
OperationPrefix: operationPrefixUserpass,
|
||||||
@@ -23,15 +45,20 @@ func pathUserPassword(b *backend) *framework.Path {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Fields: map[string]*framework.FieldSchema{
|
Fields: map[string]*framework.FieldSchema{
|
||||||
"username": {
|
paramUsername: {
|
||||||
Type: framework.TypeString,
|
Type: framework.TypeString,
|
||||||
Description: "Username for this user.",
|
Description: "Username for this user.",
|
||||||
},
|
},
|
||||||
|
|
||||||
"password": {
|
paramPassword: {
|
||||||
Type: framework.TypeString,
|
Type: framework.TypeString,
|
||||||
Description: "Password for this user.",
|
Description: "Password for this user.",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
paramPasswordHash: {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Pre-hashed password in bcrypt format for this user.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
Callbacks: map[logical.Operation]framework.OperationFunc{
|
Callbacks: map[logical.Operation]framework.OperationFunc{
|
||||||
@@ -44,7 +71,7 @@ func pathUserPassword(b *backend) *framework.Path {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *backend) pathUserPasswordUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
func (b *backend) pathUserPasswordUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
||||||
username := d.Get("username").(string)
|
username := d.Get(paramUsername).(string)
|
||||||
|
|
||||||
userEntry, err := b.user(ctx, req.Storage, username)
|
userEntry, err := b.user(ctx, req.Storage, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,24 +92,51 @@ func (b *backend) pathUserPasswordUpdate(ctx context.Context, req *logical.Reque
|
|||||||
return nil, b.setUser(ctx, req.Storage, username, userEntry)
|
return nil, b.setUser(ctx, req.Storage, username, userEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *backend) updateUserPassword(req *logical.Request, d *framework.FieldData, userEntry *UserEntry) (error, error) {
|
func (b *backend) updateUserPassword(_ *logical.Request, d *framework.FieldData, userEntry *UserEntry) (error, error) {
|
||||||
password := d.Get("password").(string)
|
password := d.Get(paramPassword).(string)
|
||||||
if password == "" {
|
passwordHash := d.Get(paramPasswordHash).(string)
|
||||||
return fmt.Errorf("missing password"), nil
|
|
||||||
|
var hash []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case password != "" && passwordHash != "":
|
||||||
|
return fmt.Errorf("%q and %q cannot be supplied together", paramPassword, paramPasswordHash), nil
|
||||||
|
case password == "" && passwordHash == "":
|
||||||
|
return fmt.Errorf("%q or %q must be supplied", paramPassword, paramPasswordHash), nil
|
||||||
|
case password != "":
|
||||||
|
hash, err = bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
case passwordHash != "":
|
||||||
|
hash, err = parsePasswordHash(passwordHash)
|
||||||
}
|
}
|
||||||
// Generate a hash of the password
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
userEntry.PasswordHash = hash
|
userEntry.PasswordHash = hash
|
||||||
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathUserPasswordHelpSyn = `
|
// parsePasswordHash is used to parse a password hash that follows the bcrypt standard.
|
||||||
Reset user's password.
|
// It examines the prefix of the string supplied to verify it complies with a supported
|
||||||
`
|
// version before returning the string in bytes.
|
||||||
|
func parsePasswordHash(passwordHash string) ([]byte, error) {
|
||||||
|
var res []byte
|
||||||
|
|
||||||
const pathUserPasswordHelpDesc = `
|
switch {
|
||||||
This endpoint allows resetting the user's password.
|
// All bcrypt hashes should be the same length.
|
||||||
`
|
case len(passwordHash) != bcryptHashLength:
|
||||||
|
return nil, fmt.Errorf("password hash has incorrect length")
|
||||||
|
// See: https://en.wikipedia.org/wiki/Bcrypt for versioning history.
|
||||||
|
case strings.HasPrefix(passwordHash, "$2a$"), // $2a% (non-ASCII character support)
|
||||||
|
strings.HasPrefix(passwordHash, "$2y$"), // $2y$ (PHP fixed)
|
||||||
|
strings.HasPrefix(passwordHash, "$2b$"): // $2b$ (truncation fix)
|
||||||
|
res = []byte(passwordHash)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("password hash has incorrect prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|||||||
92
builtin/credential/userpass/path_user_password_test.go
Normal file
92
builtin/credential/userpass/path_user_password_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package userpass
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUserPass_ParseHash ensures that we correctly validate password hashes that
|
||||||
|
// conform to the bcrypt standard based on the prefix of the hash.
|
||||||
|
func TestUserPass_ParseHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := map[string]struct {
|
||||||
|
input string
|
||||||
|
isErrorExpected bool
|
||||||
|
expectedErrorMessage string
|
||||||
|
}{
|
||||||
|
"too-short": {
|
||||||
|
input: "too short",
|
||||||
|
isErrorExpected: true,
|
||||||
|
expectedErrorMessage: "password hash has incorrect length",
|
||||||
|
},
|
||||||
|
"60-spaces": {
|
||||||
|
input: " ",
|
||||||
|
isErrorExpected: true,
|
||||||
|
expectedErrorMessage: "password hash has incorrect prefix",
|
||||||
|
},
|
||||||
|
"jibberish": {
|
||||||
|
input: "jibberfishjibberfishjibberfishjibberfishjibberfishjibberfish",
|
||||||
|
isErrorExpected: true,
|
||||||
|
expectedErrorMessage: "password hash has incorrect prefix",
|
||||||
|
},
|
||||||
|
"non-ascii-prefix": {
|
||||||
|
input: "$2a$qwertyjibberfishjibberfishjibberfishjibberfishjibberfish",
|
||||||
|
isErrorExpected: false,
|
||||||
|
},
|
||||||
|
"truncation-prefix": {
|
||||||
|
input: "$2b$qwertyjibberfishjibberfishjibberfishjibberfishjibberfish",
|
||||||
|
isErrorExpected: false,
|
||||||
|
},
|
||||||
|
"php-only-fixed-prefix": {
|
||||||
|
input: "$2y$qwertyjibberfishjibberfishjibberfishjibberfishjibberfish",
|
||||||
|
isErrorExpected: false,
|
||||||
|
},
|
||||||
|
"php-only-existing": {
|
||||||
|
input: "$2x$qwertyjibberfishjibberfishjibberfishjibberfishjibberfish",
|
||||||
|
isErrorExpected: true,
|
||||||
|
expectedErrorMessage: "password hash has incorrect prefix",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range tests {
|
||||||
|
name := name
|
||||||
|
tc := tc
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := parsePasswordHash(tc.input)
|
||||||
|
switch {
|
||||||
|
case tc.isErrorExpected:
|
||||||
|
require.EqualError(t, err, tc.expectedErrorMessage)
|
||||||
|
default:
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tc.input, string(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUserPass_BcryptHashLength ensures that using the bcrypt library to generate
|
||||||
|
// a hash from a password always produces the same length.
|
||||||
|
func TestUserPass_BcryptHashLength(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []string{
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
"foo",
|
||||||
|
"this is a long password woo",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, input := range tests {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(input), bcrypt.DefaultCost)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, hash, bcryptHashLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
sockaddr "github.com/hashicorp/go-sockaddr"
|
"github.com/hashicorp/go-sockaddr"
|
||||||
"github.com/hashicorp/vault/sdk/framework"
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
"github.com/hashicorp/vault/sdk/helper/tokenutil"
|
"github.com/hashicorp/vault/sdk/helper/tokenutil"
|
||||||
"github.com/hashicorp/vault/sdk/logical"
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
@@ -37,7 +37,7 @@ func pathUsersList(b *backend) *framework.Path {
|
|||||||
|
|
||||||
func pathUsers(b *backend) *framework.Path {
|
func pathUsers(b *backend) *framework.Path {
|
||||||
p := &framework.Path{
|
p := &framework.Path{
|
||||||
Pattern: "users/" + framework.GenericNameRegex("username"),
|
Pattern: "users/" + framework.GenericNameRegex(paramUsername),
|
||||||
|
|
||||||
DisplayAttrs: &framework.DisplayAttributes{
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
OperationPrefix: operationPrefixUserpass,
|
OperationPrefix: operationPrefixUserpass,
|
||||||
@@ -47,12 +47,12 @@ func pathUsers(b *backend) *framework.Path {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Fields: map[string]*framework.FieldSchema{
|
Fields: map[string]*framework.FieldSchema{
|
||||||
"username": {
|
paramUsername: {
|
||||||
Type: framework.TypeString,
|
Type: framework.TypeString,
|
||||||
Description: "Username for this user.",
|
Description: "Username for this user.",
|
||||||
},
|
},
|
||||||
|
|
||||||
"password": {
|
paramPassword: {
|
||||||
Type: framework.TypeString,
|
Type: framework.TypeString,
|
||||||
Description: "Password for this user.",
|
Description: "Password for this user.",
|
||||||
DisplayAttrs: &framework.DisplayAttributes{
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
@@ -60,6 +60,14 @@ func pathUsers(b *backend) *framework.Path {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
paramPasswordHash: {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Pre-hashed password in bcrypt format for this user.",
|
||||||
|
DisplayAttrs: &framework.DisplayAttributes{
|
||||||
|
Sensitive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
"policies": {
|
"policies": {
|
||||||
Type: framework.TypeCommaStringSlice,
|
Type: framework.TypeCommaStringSlice,
|
||||||
Description: tokenutil.DeprecationText("token_policies"),
|
Description: tokenutil.DeprecationText("token_policies"),
|
||||||
@@ -103,7 +111,7 @@ func pathUsers(b *backend) *framework.Path {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *backend) userExistenceCheck(ctx context.Context, req *logical.Request, d *framework.FieldData) (bool, error) {
|
func (b *backend) userExistenceCheck(ctx context.Context, req *logical.Request, d *framework.FieldData) (bool, error) {
|
||||||
userEntry, err := b.user(ctx, req.Storage, d.Get("username").(string))
|
userEntry, err := b.user(ctx, req.Storage, d.Get(paramUsername).(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -163,7 +171,7 @@ func (b *backend) pathUserList(ctx context.Context, req *logical.Request, d *fra
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *backend) pathUserDelete(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
func (b *backend) pathUserDelete(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
||||||
err := req.Storage.Delete(ctx, "user/"+strings.ToLower(d.Get("username").(string)))
|
err := req.Storage.Delete(ctx, "user/"+strings.ToLower(d.Get(paramUsername).(string)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -172,7 +180,7 @@ func (b *backend) pathUserDelete(ctx context.Context, req *logical.Request, d *f
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *backend) pathUserRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
func (b *backend) pathUserRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
||||||
user, err := b.user(ctx, req.Storage, strings.ToLower(d.Get("username").(string)))
|
user, err := b.user(ctx, req.Storage, strings.ToLower(d.Get(paramUsername).(string)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -203,7 +211,7 @@ func (b *backend) pathUserRead(ctx context.Context, req *logical.Request, d *fra
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *backend) userCreateUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
func (b *backend) userCreateUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
||||||
username := strings.ToLower(d.Get("username").(string))
|
username := strings.ToLower(d.Get(paramUsername).(string))
|
||||||
userEntry, err := b.user(ctx, req.Storage, username)
|
userEntry, err := b.user(ctx, req.Storage, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -217,7 +225,7 @@ func (b *backend) userCreateUpdate(ctx context.Context, req *logical.Request, d
|
|||||||
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := d.GetOk("password"); ok {
|
if d.Get(paramPassword).(string) != "" || d.Get(paramPasswordHash).(string) != "" {
|
||||||
userErr, intErr := b.updateUserPassword(req, d, userEntry)
|
userErr, intErr := b.updateUserPassword(req, d, userEntry)
|
||||||
if intErr != nil {
|
if intErr != nil {
|
||||||
return nil, intErr
|
return nil, intErr
|
||||||
@@ -250,11 +258,18 @@ func (b *backend) userCreateUpdate(ctx context.Context, req *logical.Request, d
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *backend) pathUserWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
func (b *backend) pathUserWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
||||||
password := d.Get("password").(string)
|
password := d.Get(paramPassword).(string)
|
||||||
if req.Operation == logical.CreateOperation && password == "" {
|
passwordHash := d.Get(paramPasswordHash).(string)
|
||||||
return logical.ErrorResponse("missing password"), logical.ErrInvalidRequest
|
|
||||||
|
switch {
|
||||||
|
case password != "" && passwordHash != "":
|
||||||
|
return logical.ErrorResponse(fmt.Sprintf("%q and %q cannot be supplied together", paramPassword, paramPasswordHash)), logical.ErrInvalidRequest
|
||||||
|
case password == "" && passwordHash == "" && req.Operation == logical.CreateOperation:
|
||||||
|
// Password or pre-hashed password are only required on 'create'.
|
||||||
|
return logical.ErrorResponse(fmt.Sprintf("%q or %q must be supplied", paramPassword, paramPasswordHash)), logical.ErrInvalidRequest
|
||||||
|
default:
|
||||||
|
return b.userCreateUpdate(ctx, req, d)
|
||||||
}
|
}
|
||||||
return b.userCreateUpdate(ctx, req, d)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserEntry struct {
|
type UserEntry struct {
|
||||||
|
|||||||
3
changelog/25862.txt
Normal file
3
changelog/25862.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
```release-note: enhancement
|
||||||
|
auth/userpass: Support supplying of a pre-hashed password instead of the password itself
|
||||||
|
```
|
||||||
@@ -27,8 +27,8 @@ Create a new user or update an existing user. This path honors the distinction b
|
|||||||
### Parameters
|
### Parameters
|
||||||
|
|
||||||
- `username` `(string: <required>)` – The username for the user. Accepted characters: alphanumeric plus "_", "-", "." (underscore, hyphen and period); username cannot begin with a hyphen, nor can it begin or end with a period.
|
- `username` `(string: <required>)` – The username for the user. Accepted characters: alphanumeric plus "_", "-", "." (underscore, hyphen and period); username cannot begin with a hyphen, nor can it begin or end with a period.
|
||||||
- `password` `(string: <required>)` - The password for the user. Only required
|
- `password` `(string: <required if password_hash is not given>)` - Password for the current user. Only required when creating the user. Mutually exclusive with `password_hash`.
|
||||||
when creating the user.
|
- `password_hash` `(string: <required if password is not given>)` - Pre-hashed password for the current user in bcrypt format. Mutually exclusive with `password`.
|
||||||
|
|
||||||
@include 'tokenfields.mdx'
|
@include 'tokenfields.mdx'
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ management tool.
|
|||||||
$ vault auth enable userpass
|
$ vault auth enable userpass
|
||||||
```
|
```
|
||||||
|
|
||||||
This enables the userpass auth method at `auth/userpass`. To enable it at a different path, use the `-path` flag:
|
Enable the `userpass` auth method at the default `auth/userpass` path.
|
||||||
|
You can choose to enable the auth method at a different path with the `-path` flag:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
$ vault auth enable -path=<path> userpass
|
$ vault auth enable -path=<path> userpass
|
||||||
|
|||||||
Reference in New Issue
Block a user