create: pkg jwt
This commit is contained in:
parent
1e5a63ba68
commit
d3d2549829
2
go.mod
2
go.mod
@ -1,3 +1,5 @@
|
||||
module git.daebt.dev/auth
|
||||
|
||||
go 1.23.3
|
||||
|
||||
require github.com/gofrs/uuid v4.4.0+incompatible
|
||||
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
@ -0,0 +1,2 @@
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
30
jwt/header.go
Normal file
30
jwt/header.go
Normal file
@ -0,0 +1,30 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
|
||||
"git.daebt.dev/auth/algo"
|
||||
)
|
||||
|
||||
type Header struct {
|
||||
*Map
|
||||
}
|
||||
|
||||
func (h *Header) GetKeyId() ([]byte, error) {
|
||||
var val string
|
||||
if err := h.Unmarshal("kid", &val); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.DecodeString(val)
|
||||
}
|
||||
|
||||
func (h *Header) GetType() (string, error) {
|
||||
var val string
|
||||
return val, h.Unmarshal("typ", &val)
|
||||
}
|
||||
|
||||
func (h *Header) GetAlgorithm() (algo.AlgorithmType, error) {
|
||||
var val algo.AlgorithmType
|
||||
return val, h.Unmarshal("alg", &val)
|
||||
}
|
||||
165
jwt/jwt.go
Normal file
165
jwt/jwt.go
Normal file
@ -0,0 +1,165 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.daebt.dev/auth/algo"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrKeyNotExist = errors.New("key does not exist")
|
||||
ErrKeyEmpty = errors.New("key is empty")
|
||||
ErrKeyInvalidType = errors.New("key is of invalid type")
|
||||
|
||||
ErrPayloadEmpty = errors.New("payload is empty")
|
||||
|
||||
ErrAlgorithmNil = errors.New("algorithm is nil")
|
||||
|
||||
ErrTokenMalformed = errors.New("token is malformed")
|
||||
//
|
||||
ErrNotJWTType = errors.New("token of not JWT type")
|
||||
ErrAlgorithmMismatch = errors.New("token is signed by another algorithm")
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
Header *Header
|
||||
Payload *Payload
|
||||
body []byte
|
||||
sign []byte
|
||||
}
|
||||
|
||||
func (t *Token) Sign(a algo.Algorithm) (string, error) {
|
||||
if a == nil {
|
||||
return "", ErrAlgorithmNil
|
||||
}
|
||||
|
||||
t.Header.AppendArg("alg", a.Algo())
|
||||
|
||||
h, err := t.encodeSegment(t.Header.Map)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
p, err := t.encodeSegment(t.Payload.Map)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
t.body = []byte(h + "." + p)
|
||||
|
||||
t.sign, err = a.Sign(t.body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.%s", string(t.body),
|
||||
base64.RawURLEncoding.EncodeToString(t.sign),
|
||||
), nil
|
||||
}
|
||||
|
||||
func (t *Token) encodeSegment(val any) (string, error) {
|
||||
buf, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func (t *Token) decodeSegment(str string, val any) error {
|
||||
buf, err := base64.RawURLEncoding.DecodeString(str)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(buf, val)
|
||||
}
|
||||
|
||||
func (t *Token) Verify(a algo.Algorithm) error {
|
||||
if a == nil {
|
||||
return ErrAlgorithmNil
|
||||
}
|
||||
|
||||
if val, err := t.Header.GetAlgorithm(); err != nil {
|
||||
return err
|
||||
} else if val != a.Algo() {
|
||||
return ErrAlgorithmMismatch
|
||||
}
|
||||
|
||||
return a.Verify(t.body, t.sign)
|
||||
}
|
||||
|
||||
func New(o ...Option) *Token {
|
||||
t := &Token{
|
||||
Header: &Header{
|
||||
Map: &Map{
|
||||
v: map[string]json.RawMessage{},
|
||||
},
|
||||
},
|
||||
Payload: &Payload{
|
||||
Map: &Map{
|
||||
v: map[string]json.RawMessage{},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, f := range o {
|
||||
f(t)
|
||||
}
|
||||
|
||||
t.Header.AppendArg("typ", "JWT")
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func Parse(token string) (*Token, error) {
|
||||
arr := strings.Split(token, ".")
|
||||
if len(arr) != 3 {
|
||||
return nil, ErrTokenMalformed
|
||||
}
|
||||
|
||||
// decode signature
|
||||
s, err := base64.RawURLEncoding.DecodeString(arr[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hp := new(bytes.Buffer)
|
||||
hp.WriteString(arr[0])
|
||||
hp.WriteByte(0x2E) // .
|
||||
hp.WriteString(arr[1])
|
||||
defer hp.Reset()
|
||||
|
||||
t := &Token{
|
||||
Header: &Header{
|
||||
Map: &Map{},
|
||||
},
|
||||
Payload: &Payload{
|
||||
Map: &Map{},
|
||||
},
|
||||
body: hp.Bytes(),
|
||||
sign: s,
|
||||
}
|
||||
|
||||
// decode header
|
||||
if err := t.decodeSegment(arr[0], t.Header.Map); err != nil {
|
||||
return t, err
|
||||
}
|
||||
|
||||
if val, err := t.Header.GetType(); err != nil {
|
||||
return t, err
|
||||
} else if val != "JWT" {
|
||||
return t, ErrNotJWTType
|
||||
}
|
||||
|
||||
// decode payload
|
||||
if err := t.decodeSegment(arr[1], t.Payload.Map); err != nil {
|
||||
return t, err
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
135
jwt/jwt_test.go
Normal file
135
jwt/jwt_test.go
Normal file
@ -0,0 +1,135 @@
|
||||
package jwt_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.daebt.dev/auth/algo/rs"
|
||||
"git.daebt.dev/auth/jwt"
|
||||
)
|
||||
|
||||
var key = `-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEAzIWvl1OtwExnQ3HvoYk6bFRIlcCjzdv1yHazJfr6jxk6w+tC
|
||||
sIWEdtKsAPm3gmmFG+mTuHq+H53sahm6DD9YC5ZQjnvSYkBKv70Zw331/tg9VLbf
|
||||
Jc+gN7kbD3xMQsucYD0973r7l9pEPH4Qw/I+BEKHMxlTmynStgKxnfyO6iPkL5jT
|
||||
zpUXlD4V9xqUoMY/uX3EpGwbJJKFJuphYX3jzJQ++tovQGGep7RgNeMEoWjyAkJ2
|
||||
yb7tiSWsw7qk6GO2z6NDmnc1UsdSBZ6Vg7BPUp8EINAdX1wbmB0+QH/vp0huM6lS
|
||||
Y6NMBugwptQUe5UCaly9fN4kb26U0qglEoGB6wIDAQABAoIBADU244UgRKkwN/4Y
|
||||
ex0ws37UPz6XrQc3IDBUkjBjqSXqjpvDbsq3Mswn7JEkaFcKVZP5pnHtneJkGMtS
|
||||
flIJeUMqjTNFjGv8Bnb1IOr4rzTr1qlgG5ee+jUFeMECumT2zW1NAfx5p1TPecmz
|
||||
k3EoanJ5TOxCvro0m5q4ALb2q8jHrtfvtqEBrHepeEp3Lyh7m4ZUib+0yWXs0EPC
|
||||
HhF9kLpCy+tVXUPDyLCt4cldTUda/3xeswzmxVRrHkt/idsNuTAi7o1Cx/OfZYMI
|
||||
AzQo8OTh1Bg2DlXKtOX40frIxy3/K77F3ozwV4a3FravUO+wvcQdIyi6KAmNBFS3
|
||||
9IVA7IECgYEA4x0TAn7vOvazILmg4Es0/gsWlh7RGmNTqVYtj5TfwOviUGeaculR
|
||||
BSsyX8pgaROJKjGDcNzSQQEhHfJXrhNXeMJ0zPUIsJCBmih8oaCpAScWpV+qpdky
|
||||
1Eb2akEg7XbpqBJJ1jnoEvIhd4feCAN8Gv8vcmdER7HaGdyef4XxlFsCgYEA5okG
|
||||
tbyTtD2cfmYjYsoGqEfGH0Pe9vxc+MBthiPg0f2lpg+YuSPx92ZuJciLNyNWo9qf
|
||||
NFnzbSEFxzomK/Bgq9ujGnbPyLOCadIADM4/njEEPe+IsagDxBgTrCEUJ56W9MLj
|
||||
N+b4d/gnBkK4roDW8gjy7x4MbePByoDfaWtU/bECgYEApn8RCZpe7V4gMdSEIQph
|
||||
fgBI/aL37p10nsbDvegJJRiIoCNjsexj7iMd2eW2SjH9M4Z68smgBfG7AoZASyh4
|
||||
ztnX4M2eIjq+GHKn86GhZGvwiSoaI12YitC/I2Q9rHipkQJfSQLIpOMHL+bWGg/b
|
||||
8rqzYO5duyWiW6VGOPzL/tMCgYB3JVSZcrfnzHvn+8PIF9+u80FbAUnn3m/yhAlW
|
||||
7Y4RGYWWOLNW5FP26DJ/RpFk0tfBYYksllywBwQkflIiHV7pE1/NmqAy+0uog0dR
|
||||
VvscN/sYQ4cjQlGH9GWebY4sF9Ou9lZWmwHJhzAsFSm7zozIlIVxvdbwqGiMz2Qn
|
||||
6LgJUQKBgQC9H2JGm54wg0YPuDig5LjymUxYJrEiJT0IXz4vy+UEMxw+1EmeD5sm
|
||||
kSqHkwNDp7D+3nik5HzoFVifJAvqFWU73fpvqQlvZSNfVrtq8UvJBIuH7eHkrJrC
|
||||
L8dEn16HWjLX50GlT+9eYyHWtYI4sMdnzz1/JS6PwQRxKlFQN9HJYg==
|
||||
-----END RSA PRIVATE KEY-----`
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
alg, err := rs.NewRS256(
|
||||
rs.WithPEM(nil, []byte(key)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
|
||||
tm := time.Now()
|
||||
|
||||
var data = [][]jwt.Option{
|
||||
{
|
||||
jwt.WithHeaderKeyId([]byte("019451f1-f789-72a6-836d-3bc6146ad76a")),
|
||||
jwt.WithIssuer("https://git.daebt.dev"),
|
||||
jwt.WithAudience("https://0.example.com"),
|
||||
jwt.WithSubject("example:0"),
|
||||
},
|
||||
{
|
||||
jwt.WithHeaderKeyId([]byte("019451f1-f789-72a6-836d-3bc6146ad76a")),
|
||||
jwt.WithIssuer("https://git.daebt.dev"),
|
||||
jwt.WithAudience("https://1.example.com"),
|
||||
jwt.WithSubject("example:1"),
|
||||
jwt.WithIssuedAt(tm),
|
||||
jwt.WithExpirationTime(tm.Add(time.Hour)),
|
||||
},
|
||||
{
|
||||
jwt.WithHeaderKeyId([]byte("019451f1-f789-72a6-2911-3bc6146ad76a")),
|
||||
jwt.WithIssuer("https://git.daebt.dev"),
|
||||
jwt.WithAudience("https://2.example.com"),
|
||||
jwt.WithSubject("example:2"),
|
||||
jwt.WithIssuedAt(tm),
|
||||
jwt.WithNotBefore(tm),
|
||||
},
|
||||
{
|
||||
jwt.WithHeaderKeyId([]byte("019451f1-f789-72a6-2911-3bc6146ad76a")),
|
||||
jwt.WithIssuer("https://git.daebt.dev"),
|
||||
jwt.WithAudience("https://3.example.com"),
|
||||
jwt.WithSubject("example:3"),
|
||||
jwt.WithIssuedAt(tm),
|
||||
jwt.WithNotBefore(tm.Add(time.Minute)),
|
||||
},
|
||||
{
|
||||
jwt.WithHeaderKeyId([]byte("019451f1-f789-72a6-2911-3bc6146ad76a")),
|
||||
jwt.WithIssuer("https://git.daebt.dev"),
|
||||
jwt.WithAudience("https://4.example.com"),
|
||||
jwt.WithSubject("example:4"),
|
||||
jwt.WithIssuedAt(tm),
|
||||
jwt.WithNotBefore(tm.Add(time.Minute)),
|
||||
jwt.WithExpirationTime(tm.Add(time.Hour)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, v := range data {
|
||||
// t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||
val, err := jwt.New(v...).Sign(alg)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
// t.Log(val)
|
||||
fmt.Printf(`"%s",`, val)
|
||||
// })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// func TestJwt(t *testing.T) {
|
||||
// alg, err := rs.NewRS256(
|
||||
// rs.WithGenerateKey(2048),
|
||||
// )
|
||||
// if err != nil {
|
||||
// t.Fatal(err.Error())
|
||||
// }
|
||||
|
||||
// tkn := New(
|
||||
// WithSubject("main-jwt-token"),
|
||||
// )
|
||||
|
||||
// str, err := tkn.Sign(alg)
|
||||
// if err != nil {
|
||||
// t.Fatal(err.Error())
|
||||
// }
|
||||
|
||||
// tkn, err = Parse(str)
|
||||
// if err != nil {
|
||||
// t.Fatal(err.Error())
|
||||
// }
|
||||
|
||||
// if err := tkn.Verify(alg); err != nil {
|
||||
// t.Fatal(err.Error())
|
||||
// }
|
||||
|
||||
// tkn.Payload.Range(func(key string, val any) bool {
|
||||
// t.Log(key, val)
|
||||
// return true
|
||||
// })
|
||||
// }
|
||||
70
jwt/map.go
Normal file
70
jwt/map.go
Normal file
@ -0,0 +1,70 @@
|
||||
package jwt
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Map struct {
|
||||
v map[string]json.RawMessage
|
||||
}
|
||||
|
||||
func (m *Map) UnmarshalJSON(buf []byte) error {
|
||||
if m.v == nil {
|
||||
m.v = map[string]json.RawMessage{}
|
||||
}
|
||||
return json.Unmarshal(buf, &m.v)
|
||||
}
|
||||
|
||||
func (m *Map) MarshalJSON() ([]byte, error) {
|
||||
if m.v == nil || len(m.v) < 1 {
|
||||
return nil, ErrPayloadEmpty
|
||||
}
|
||||
|
||||
return json.Marshal(m.v)
|
||||
}
|
||||
|
||||
// AppendArg добавляет аргумент
|
||||
func (m *Map) AppendArg(key string, val any) error {
|
||||
if key == "" {
|
||||
return ErrKeyEmpty
|
||||
}
|
||||
|
||||
var err error
|
||||
m.v[key], err = json.Marshal(val)
|
||||
return err
|
||||
}
|
||||
|
||||
// AppendArgs добавляет аргументы
|
||||
func (m *Map) AppendArgs(kv map[string]any) error {
|
||||
var err error
|
||||
|
||||
for k, v := range kv {
|
||||
if k == "" {
|
||||
return ErrKeyEmpty
|
||||
}
|
||||
|
||||
m.v[k], err = json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Map) Unmarshal(key string, val any) error {
|
||||
buf, is := m.v[key]
|
||||
if !is {
|
||||
return ErrKeyNotExist
|
||||
}
|
||||
|
||||
return json.Unmarshal(buf, val)
|
||||
}
|
||||
|
||||
func (m *Map) Range(f func(key string, val any) bool) {
|
||||
for k := range m.v {
|
||||
var val any
|
||||
m.Unmarshal(k, &val)
|
||||
if !f(k, val) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
90
jwt/option.go
Normal file
90
jwt/option.go
Normal file
@ -0,0 +1,90 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
type Option func(*Token)
|
||||
|
||||
// WithIssuer устанавливает идентификатор принципала, выдавшего JWT (string, URL)
|
||||
func WithIssuer(iss string) Option {
|
||||
return func(t *Token) {
|
||||
t.Payload.AppendArg("iss", iss)
|
||||
}
|
||||
}
|
||||
|
||||
// WithSubject устанавливает идентификатор принципала, который является предметом JWT (string, URL)
|
||||
func WithSubject(sub string) Option {
|
||||
return func(t *Token) {
|
||||
t.Payload.AppendArg("sub", sub)
|
||||
}
|
||||
}
|
||||
|
||||
// WithAudience устанавливает идентификатор получателей, для которых предназначен JWT
|
||||
func WithAudience(aud ...string) Option {
|
||||
return func(t *Token) {
|
||||
if len(aud) == 1 {
|
||||
t.Payload.AppendArg("aud", aud[0])
|
||||
return
|
||||
}
|
||||
|
||||
t.Payload.AppendArg("aud", aud)
|
||||
}
|
||||
}
|
||||
|
||||
// WithExpirationTime устанавливает время истечения срока действия, по истечении которого JWT НЕ ДОЛЖЕН быть принят к обработке
|
||||
func WithExpirationTime(exp time.Time) Option {
|
||||
return func(t *Token) {
|
||||
t.Payload.AppendArg("exp", exp)
|
||||
}
|
||||
}
|
||||
|
||||
// WithNotBefore устанавливает время, до которого JWT НЕ ДОЛЖЕН быть принят к обработке
|
||||
func WithNotBefore(nbf time.Time) Option {
|
||||
return func(t *Token) {
|
||||
t.Payload.AppendArg("nbf", nbf)
|
||||
}
|
||||
}
|
||||
|
||||
// WithIssuedAt устанавливает время, когда был создан JWT
|
||||
func WithIssuedAt(iat time.Time) Option {
|
||||
return func(t *Token) {
|
||||
t.Payload.AppendArg("iat", iat)
|
||||
}
|
||||
}
|
||||
|
||||
// WithIssuedAtNow устанавливает текущее время создания JWT
|
||||
func WithIssuedAtNow() Option {
|
||||
return func(t *Token) {
|
||||
t.Payload.AppendArg("iat", time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
// WithIssuedAt устанавливает уникальный идентификатор для JWT
|
||||
func WithJwtId(jti string) Option {
|
||||
return func(t *Token) {
|
||||
t.Payload.AppendArg("jti", jti)
|
||||
}
|
||||
}
|
||||
|
||||
// WithGenJwtId генерирует уникальный идентификатор для JWT (uuid v7)
|
||||
func WithGenJwtId() Option {
|
||||
return func(t *Token) {
|
||||
jti := hex.EncodeToString([]byte(time.Now().String()))
|
||||
if val, err := uuid.NewV7(); err == nil {
|
||||
jti = val.String()
|
||||
}
|
||||
t.Payload.AppendArg("jti", jti)
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderKeyId устанавливает в заголовке параметр kid
|
||||
func WithHeaderKeyId(kid []byte) Option {
|
||||
return func(t *Token) {
|
||||
t.Header.AppendArg("kid", base64.RawURLEncoding.EncodeToString(kid))
|
||||
}
|
||||
}
|
||||
76
jwt/payload.go
Normal file
76
jwt/payload.go
Normal file
@ -0,0 +1,76 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Payload struct {
|
||||
*Map
|
||||
}
|
||||
|
||||
// GetExpirationTime возвращает срок действия JWT
|
||||
func (p *Payload) GetExpirationTime() (time.Time, error) {
|
||||
return p.GetTime("exp")
|
||||
}
|
||||
|
||||
// GetIssuedAt возвращает время создания JWT
|
||||
func (p *Payload) GetIssuedAt() (time.Time, error) {
|
||||
return p.GetTime("iat")
|
||||
}
|
||||
|
||||
// GetNotBefore возвращает время начала действия JWT
|
||||
func (p *Payload) GetNotBefore() (time.Time, error) {
|
||||
return p.GetTime("nbf")
|
||||
}
|
||||
|
||||
// GetIssuer возвращает идентификатор принципала, выдавшего JWT
|
||||
func (p *Payload) GetIssuer() (string, error) {
|
||||
var val string
|
||||
return val, p.Unmarshal("iss", &val)
|
||||
}
|
||||
|
||||
// GetSubject возвращает идентификатор принципала, который является предметом JWT
|
||||
func (p *Payload) GetSubject() (string, error) {
|
||||
var val string
|
||||
return val, p.Unmarshal("sub", &val)
|
||||
}
|
||||
|
||||
// GetAudience возвращает идентификатор получателей
|
||||
func (p *Payload) GetAudience() ([]string, error) {
|
||||
var val any
|
||||
if err := p.Unmarshal("aud", &val); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
return []string{v}, nil
|
||||
case []string:
|
||||
return v, nil
|
||||
}
|
||||
|
||||
return nil, ErrKeyInvalidType
|
||||
}
|
||||
|
||||
func (p *Payload) GetTime(key string) (time.Time, error) {
|
||||
var val json.Number
|
||||
if err := p.Unmarshal(key, &val); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
num, err := val.Float64()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
round, frac := math.Modf(num)
|
||||
return time.Unix(int64(round), int64(frac*1e9)), nil
|
||||
}
|
||||
|
||||
// GetAny получает значение из полезной нагрузки
|
||||
func (p *Payload) GetAny(key string) (any, error) {
|
||||
var val any
|
||||
return val, p.Unmarshal(key, &val)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user