mirror of
https://github.com/zitadel/zitadel.git
synced 2025-02-28 20:47:22 +00:00
feat(actions): add fields to complement token flow (#5336)
* deprecated `ctx.v1.userinfo`-field in "pre userinfo creation" trigger in favour of `ctx.v1.claims`. The trigger now behaves the same as "pre access token creation" * added `ctx.v1.claims` to "complement tokens" flow * added `ctx.v1.grants` to "complement tokens" flow * document `ctx.v1.getUser()` in "complement tokens" flow * feat(actions): add getUser() and grant * map user grants * map claims * feat(actions): claims in complement token ctx * docs(actions): add new fields of complement token * docs(actions): additions to complement token * docs(actions): correct field names
This commit is contained in:
parent
3042d7ef5c
commit
20e4f1ce57
@ -13,14 +13,21 @@ This trigger is called before userinfo are set in the token or response.
|
||||
- `ctx`
|
||||
The first parameter contains the following fields:
|
||||
- `v1`
|
||||
- `claims` [*Claims*](./objects#claims)
|
||||
- `getUser()` [*User*](./objects#user)
|
||||
- `user`
|
||||
- `getMetadata()` [*metadataResult*](./objects#metadata-result)
|
||||
- `grants` [*UserGrantList*](./objects#user-grant-list)
|
||||
- `api`
|
||||
The second parameter contains the following fields:
|
||||
- `v1`
|
||||
- `userinfo`
|
||||
- `userinfo`
|
||||
This function is deprecated, please use `api.v1.claims`
|
||||
- `setClaim(string, Any)`
|
||||
Key of the claim and any value
|
||||
Sets any value if the key is not already present. If it's already present there is a message added to `urn:zitadel:iam:action:${action.name}:log`
|
||||
- `claims`
|
||||
- `setClaim(string, Any)`
|
||||
Sets any value if the key is not already present. If it's already present there is a message added to `urn:zitadel:iam:action:${action.name}:log`
|
||||
- `user`
|
||||
- `setMetadata(string, Any)`
|
||||
Key of the metadata and any value
|
||||
@ -34,14 +41,17 @@ This trigger is called before the claims are set in the access token and the tok
|
||||
- `ctx`
|
||||
The first parameter contains the following fields:
|
||||
- `v1`
|
||||
- `claims` [*Claims*](./objects#claims)
|
||||
- `getUser()` [*User*](./objects#user)
|
||||
- `user`
|
||||
- `getMetadata()` [*metadataResult*](./objects#metadata-result)
|
||||
- `grants` [*UserGrantList*](./objects#user-grant-list)
|
||||
- `api`
|
||||
The second parameter contains the following fields:
|
||||
- `v1`
|
||||
- `claims`
|
||||
- `setClaim(string, Any)`
|
||||
Sets any value if the key is not already present
|
||||
Sets any value if the key is not already present. If it's already present there is a message added to `urn:zitadel:iam:action:${action.name}:log`
|
||||
- `appendLogIntoClaims(string)`
|
||||
Appends the entry into the claim `urn:zitadel:action:{action.name}:log` the value of the claim is an Array of *string*
|
||||
- `user`
|
||||
|
@ -166,4 +166,44 @@ This object is based on the Golang struct [http.Request](https://pkg.go.dev/net/
|
||||
- `form` Map *string* of Array of *string*
|
||||
- `postForm` Map *string* of Array of *string*
|
||||
- `remoteAddr` *string*
|
||||
- `headers` Map *string* of Array of *string*
|
||||
- `headers` Map *string* of Array of *string*
|
||||
|
||||
## Claims
|
||||
|
||||
This object represents [the claims](../openidoauth/claims) which will be written into the oidc token.
|
||||
|
||||
- `sub` *string*
|
||||
- `name` *string*
|
||||
- `email` *string*
|
||||
- `locale` *string*
|
||||
- `given_name` *string*
|
||||
- `family_name` *string*
|
||||
- `preferred_username` *string*
|
||||
- `email_verified` *bool*
|
||||
- `updated_at` *Number*
|
||||
|
||||
Additionally there could additional fields depending on the configuration of your [project](../../guides/manage/console/projects#role-settings) and your [application](../../guides/manage/console/applications#token-settings)
|
||||
|
||||
## user grant list
|
||||
|
||||
This object represents a list of user grant stored in ZITADEL.
|
||||
|
||||
- `count` *Number*
|
||||
- `sequence` *Number*
|
||||
- `timestamp` *Date*
|
||||
- `grants` Array of
|
||||
- `id` *string*
|
||||
- `projectGrantId` *string*
|
||||
The id of the [project grant](../../concepts/usecases/saas#project-grant)
|
||||
- `state` *Number*
|
||||
<ul><li>0: unspecified</li><li>1: active</li><li>2: inactive</li><li>3: removed</li></ul>
|
||||
- `creationDate` *Date*
|
||||
- `changeDate` *Date*
|
||||
- `sequence` *Number*
|
||||
- `userId` *string*
|
||||
- `roles` Array of *string*
|
||||
- `userResourceOwner` *string*
|
||||
- `userGrantResourceOwner` *string*
|
||||
- `userGrantResourceOwnerName` *string*
|
||||
- `projectId` *string*
|
||||
- `projectName` *string*
|
||||
|
@ -67,11 +67,11 @@ Please check below the matrix for an overview where which scope is asserted.
|
||||
|
||||
## Custom Claims
|
||||
|
||||
> This feature is not yet released
|
||||
You can add custom claims using the [complement token flow](/docs/apis/actions/complement-token) of the [actions feature](/docs/apis/actions/introduction).
|
||||
|
||||
## Reserved Claims
|
||||
|
||||
ZITADEL reserves some claims to assert certain data. Please check out the [reserved scopes](scopes#reserved-scopes).
|
||||
ZITADEL reserves some claims to assert certain data. Please check out the [reserved scopes](scopes#reserved-scopes).
|
||||
|
||||
| Claims | Example | Description |
|
||||
|:--------------------------------------------------|:-----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
|
@ -59,7 +59,16 @@ cookie_secure = false #localdev only false
|
||||
http_address = "127.0.0.1:4180" #localdev only
|
||||
```
|
||||
|
||||
> This was tested with version `oauth2-proxy v6.1.1 (built with go1.14.2)`
|
||||
> This was tested with version `oauth2-proxy v7.4.0 (built with go1.20.0)`
|
||||
|
||||
### Check for groups
|
||||
|
||||
If you want oauth2-proxy to check for roles in the tokens you have to add an [action](/docs/apis/actions/introduction) in ZITADEL to [complement the token](/docs/apis/actions/complement-token) according to [this example](https://github.com/zitadel/actions/blob/main/examples/custom_roles.js) and add the following configuration to the config:
|
||||
|
||||
```toml
|
||||
oidc_groups_claim = "{your_actions_group_key}"
|
||||
allowed_groups = ["list", "of", "allowed", "roles"]
|
||||
```
|
||||
|
||||
## Completion
|
||||
|
||||
|
2
go.mod
2
go.mod
@ -57,7 +57,7 @@ require (
|
||||
github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203
|
||||
github.com/ttacon/libphonenumber v1.2.1
|
||||
github.com/zitadel/logging v0.3.4
|
||||
github.com/zitadel/oidc/v2 v2.0.0-dynamic-issuer.8
|
||||
github.com/zitadel/oidc/v2 v2.0.0-dynamic-issuer.9
|
||||
github.com/zitadel/saml v0.0.10
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.27.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.27.0
|
||||
|
4
go.sum
4
go.sum
@ -1140,8 +1140,8 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
github.com/zitadel/logging v0.3.4 h1:9hZsTjMMTE3X2LUi0xcF9Q9EdLo+FAezeu52ireBbHM=
|
||||
github.com/zitadel/logging v0.3.4/go.mod h1:aPpLQhE+v6ocNK0TWrBrd363hZ95KcI17Q1ixAQwZF0=
|
||||
github.com/zitadel/oidc/v2 v2.0.0-dynamic-issuer.8 h1:e6sRhY3Lijku8XBzazLoWpJcjO/EniEA7C5UEgiApRY=
|
||||
github.com/zitadel/oidc/v2 v2.0.0-dynamic-issuer.8/go.mod h1:2jHMP6o/WK0EmcNJkz+FSpjeqcCuQG9YqqqzKZkfgIE=
|
||||
github.com/zitadel/oidc/v2 v2.0.0-dynamic-issuer.9 h1:P7xbgv2501rsW8E0Uj804LMBrabVuZYcstqoFVmgWjA=
|
||||
github.com/zitadel/oidc/v2 v2.0.0-dynamic-issuer.9/go.mod h1:2jHMP6o/WK0EmcNJkz+FSpjeqcCuQG9YqqqzKZkfgIE=
|
||||
github.com/zitadel/saml v0.0.10 h1:cyKd78Vat9vz55S74lggJrXMSqbAPsnJDrPFTPScNYY=
|
||||
github.com/zitadel/saml v0.0.10/go.mod h1:Hze1/zRN9j1uh7U+89vweP/OwLNO8BLHg3zU1Jtycdg=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
|
@ -1,10 +1,13 @@
|
||||
package object
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/actions"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
type UserGrants struct {
|
||||
@ -17,6 +20,32 @@ type UserGrant struct {
|
||||
Roles []string
|
||||
}
|
||||
|
||||
type userGrantList struct {
|
||||
Count uint64
|
||||
Sequence uint64
|
||||
Timestamp time.Time
|
||||
Grants []*userGrant
|
||||
}
|
||||
|
||||
type userGrant struct {
|
||||
Id string
|
||||
ProjectGrantId string
|
||||
State domain.UserGrantState
|
||||
UserGrantResourceOwner string
|
||||
UserGrantResourceOwnerName string
|
||||
|
||||
CreationDate time.Time
|
||||
ChangeDate time.Time
|
||||
Sequence uint64
|
||||
|
||||
UserId string
|
||||
UserResourceOwner string
|
||||
Roles []string
|
||||
|
||||
ProjectId string
|
||||
ProjectName string
|
||||
}
|
||||
|
||||
func AppendGrantFunc(userGrants *UserGrants) func(c *actions.FieldConfig) func(call goja.FunctionCall) goja.Value {
|
||||
return func(c *actions.FieldConfig) func(call goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
@ -29,6 +58,51 @@ func AppendGrantFunc(userGrants *UserGrants) func(c *actions.FieldConfig) func(c
|
||||
}
|
||||
}
|
||||
|
||||
func UserGrantsFromQuery(c *actions.FieldConfig, userGrants *query.UserGrants) goja.Value {
|
||||
grantList := &userGrantList{
|
||||
Count: userGrants.Count,
|
||||
Sequence: userGrants.Sequence,
|
||||
Timestamp: userGrants.Timestamp,
|
||||
Grants: make([]*userGrant, len(userGrants.UserGrants)),
|
||||
}
|
||||
|
||||
for i, grant := range userGrants.UserGrants {
|
||||
grantList.Grants[i] = &userGrant{
|
||||
Id: grant.ID,
|
||||
ProjectGrantId: grant.GrantID,
|
||||
State: grant.State,
|
||||
CreationDate: grant.CreationDate,
|
||||
ChangeDate: grant.ChangeDate,
|
||||
Sequence: grant.Sequence,
|
||||
UserId: grant.UserID,
|
||||
Roles: grant.Roles,
|
||||
UserResourceOwner: grant.UserResourceOwner,
|
||||
UserGrantResourceOwner: grant.ResourceOwner,
|
||||
UserGrantResourceOwnerName: grant.OrgName,
|
||||
ProjectId: grant.ProjectID,
|
||||
ProjectName: grant.ProjectName,
|
||||
}
|
||||
}
|
||||
|
||||
return c.Runtime.ToValue(grantList)
|
||||
}
|
||||
|
||||
func UserGrantsToDomain(userID string, actionUserGrants []UserGrant) []*domain.UserGrant {
|
||||
if actionUserGrants == nil {
|
||||
return nil
|
||||
}
|
||||
userGrants := make([]*domain.UserGrant, len(actionUserGrants))
|
||||
for i, grant := range actionUserGrants {
|
||||
userGrants[i] = &domain.UserGrant{
|
||||
UserID: userID,
|
||||
ProjectID: grant.ProjectID,
|
||||
ProjectGrantID: grant.ProjectGrantID,
|
||||
RoleKeys: grant.Roles,
|
||||
}
|
||||
}
|
||||
return userGrants
|
||||
}
|
||||
|
||||
func mapObjectToGrant(object *goja.Object, grant *UserGrant) {
|
||||
for _, key := range object.Keys() {
|
||||
switch key {
|
||||
@ -50,19 +124,3 @@ func mapObjectToGrant(object *goja.Object, grant *UserGrant) {
|
||||
panic("projectId not set")
|
||||
}
|
||||
}
|
||||
|
||||
func UserGrantsToDomain(userID string, actionUserGrants []UserGrant) []*domain.UserGrant {
|
||||
if actionUserGrants == nil {
|
||||
return nil
|
||||
}
|
||||
userGrants := make([]*domain.UserGrant, len(actionUserGrants))
|
||||
for i, grant := range actionUserGrants {
|
||||
userGrants[i] = &domain.UserGrant{
|
||||
UserID: userID,
|
||||
ProjectID: grant.ProjectID,
|
||||
ProjectGrantID: grant.ProjectGrantID,
|
||||
RoleKeys: grant.Roles,
|
||||
}
|
||||
}
|
||||
return userGrants
|
||||
}
|
||||
|
@ -327,21 +327,19 @@ func (o *OPStorage) setUserinfo(ctx context.Context, userInfo oidc.UserInfoSette
|
||||
}
|
||||
}
|
||||
|
||||
if len(roles) == 0 || applicationID == "" {
|
||||
return o.userinfoFlows(ctx, user.ResourceOwner, userInfo)
|
||||
}
|
||||
projectRoles, err := o.assertRoles(ctx, userID, applicationID, roles)
|
||||
userGrants, projectRoles, err := o.assertRoles(ctx, userID, applicationID, roles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(projectRoles) > 0 {
|
||||
userInfo.AppendClaims(ClaimProjectRoles, projectRoles)
|
||||
}
|
||||
|
||||
return o.userinfoFlows(ctx, user.ResourceOwner, userInfo)
|
||||
return o.userinfoFlows(ctx, user.ResourceOwner, userGrants, userInfo)
|
||||
}
|
||||
|
||||
func (o *OPStorage) userinfoFlows(ctx context.Context, resourceOwner string, userInfo oidc.UserInfoSetter) error {
|
||||
func (o *OPStorage) userinfoFlows(ctx context.Context, resourceOwner string, userGrants *query.UserGrants, userInfo oidc.UserInfoSetter) error {
|
||||
queriedActions, err := o.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, domain.TriggerTypePreUserinfoCreation, resourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -349,6 +347,16 @@ func (o *OPStorage) userinfoFlows(ctx context.Context, resourceOwner string, use
|
||||
|
||||
ctxFields := actions.SetContextFields(
|
||||
actions.SetFields("v1",
|
||||
actions.SetFields("claims", userinfoClaims(userInfo)),
|
||||
actions.SetFields("getUser", func(c *actions.FieldConfig) interface{} {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
user, err := o.query.GetUserByID(ctx, true, userInfo.GetSubject(), false)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return object.UserFromQuery(c, user)
|
||||
}
|
||||
}),
|
||||
actions.SetFields("user",
|
||||
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
|
||||
return func(goja.FunctionCall) goja.Value {
|
||||
@ -371,6 +379,9 @@ func (o *OPStorage) userinfoFlows(ctx context.Context, resourceOwner string, use
|
||||
return object.UserMetadataListFromQuery(c, metadata)
|
||||
}
|
||||
}),
|
||||
actions.SetFields("grants", func(c *actions.FieldConfig) interface{} {
|
||||
return object.UserGrantsFromQuery(c, userGrants)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -393,6 +404,18 @@ func (o *OPStorage) userinfoFlows(ctx context.Context, resourceOwner string, use
|
||||
claimLogs = append(claimLogs, entry)
|
||||
}),
|
||||
),
|
||||
actions.SetFields("claims",
|
||||
actions.SetFields("setClaim", func(key string, value interface{}) {
|
||||
if userInfo.GetClaim(key) == nil {
|
||||
userInfo.AppendClaims(key, value)
|
||||
return
|
||||
}
|
||||
claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key))
|
||||
}),
|
||||
actions.SetFields("appendLogIntoClaims", func(entry string) {
|
||||
claimLogs = append(claimLogs, entry)
|
||||
}),
|
||||
),
|
||||
actions.SetFields("user",
|
||||
actions.SetFields("setMetadata", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) != 2 {
|
||||
@ -480,21 +503,19 @@ func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clie
|
||||
}
|
||||
}
|
||||
|
||||
if len(roles) == 0 || clientID == "" {
|
||||
return o.privateClaimsFlows(ctx, userID, claims)
|
||||
}
|
||||
projectRoles, err := o.assertRoles(ctx, userID, clientID, roles)
|
||||
userGrants, projectRoles, err := o.assertRoles(ctx, userID, clientID, roles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(projectRoles) > 0 {
|
||||
claims = appendClaim(claims, ClaimProjectRoles, projectRoles)
|
||||
}
|
||||
|
||||
return o.privateClaimsFlows(ctx, userID, claims)
|
||||
return o.privateClaimsFlows(ctx, userID, userGrants, claims)
|
||||
}
|
||||
|
||||
func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, claims map[string]interface{}) (map[string]interface{}, error) {
|
||||
func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, userGrants *query.UserGrants, claims map[string]interface{}) (map[string]interface{}, error) {
|
||||
user, err := o.query.GetUserByID(ctx, true, userID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -506,6 +527,18 @@ func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, claim
|
||||
|
||||
ctxFields := actions.SetContextFields(
|
||||
actions.SetFields("v1",
|
||||
actions.SetFields("claims", func(c *actions.FieldConfig) interface{} {
|
||||
return c.Runtime.ToValue(claims)
|
||||
}),
|
||||
actions.SetFields("getUser", func(c *actions.FieldConfig) interface{} {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
user, err := o.query.GetUserByID(ctx, true, userID, false)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return object.UserFromQuery(c, user)
|
||||
}
|
||||
}),
|
||||
actions.SetFields("user",
|
||||
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
|
||||
return func(goja.FunctionCall) goja.Value {
|
||||
@ -528,6 +561,9 @@ func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, claim
|
||||
return object.UserMetadataListFromQuery(c, metadata)
|
||||
}
|
||||
}),
|
||||
actions.SetFields("grants", func(c *actions.FieldConfig) interface{} {
|
||||
return object.UserGrantsFromQuery(c, userGrants)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -598,24 +634,24 @@ func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, claim
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (o *OPStorage) assertRoles(ctx context.Context, userID, applicationID string, requestedRoles []string) (map[string]map[string]string, error) {
|
||||
func (o *OPStorage) assertRoles(ctx context.Context, userID, applicationID string, requestedRoles []string) (*query.UserGrants, map[string]map[string]string, error) {
|
||||
projectID, err := o.query.ProjectIDFromClientID(ctx, applicationID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
projectQuery, err := query.NewUserGrantProjectIDSearchQuery(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
userIDQuery, err := query.NewUserGrantUserIDSearchQuery(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
grants, err := o.query.UserGrants(ctx, &query.UserGrantsQueries{
|
||||
Queries: []query.SearchQuery{projectQuery, userIDQuery},
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
projectRoles := make(map[string]map[string]string)
|
||||
for _, requestedRole := range requestedRoles {
|
||||
@ -623,7 +659,7 @@ func (o *OPStorage) assertRoles(ctx context.Context, userID, applicationID strin
|
||||
checkGrantedRoles(projectRoles, grant, requestedRole)
|
||||
}
|
||||
}
|
||||
return projectRoles, nil
|
||||
return grants, projectRoles, nil
|
||||
}
|
||||
|
||||
func (o *OPStorage) assertUserMetaData(ctx context.Context, userID string) (map[string]string, error) {
|
||||
@ -689,3 +725,18 @@ func appendClaim(claims map[string]interface{}, claim string, value interface{})
|
||||
claims[claim] = value
|
||||
return claims
|
||||
}
|
||||
|
||||
func userinfoClaims(userInfo oidc.UserInfoSetter) func(c *actions.FieldConfig) interface{} {
|
||||
return func(c *actions.FieldConfig) interface{} {
|
||||
marshalled, err := json.Marshal(userInfo)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
claims := make(map[string]interface{}, 10)
|
||||
if err = json.Unmarshal(marshalled, &claims); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return c.Runtime.ToValue(claims)
|
||||
}
|
||||
}
|
||||
|
@ -18,13 +18,15 @@ import (
|
||||
)
|
||||
|
||||
type UserGrant struct {
|
||||
// ID represents the aggregate id (id of the user grant)
|
||||
ID string
|
||||
CreationDate time.Time
|
||||
ChangeDate time.Time
|
||||
Sequence uint64
|
||||
Roles database.StringArray
|
||||
GrantID string
|
||||
State domain.UserGrantState
|
||||
// GrantID represents the project grant id
|
||||
GrantID string
|
||||
State domain.UserGrantState
|
||||
|
||||
UserID string
|
||||
Username string
|
||||
|
Loading…
x
Reference in New Issue
Block a user