mirror of
https://github.com/zitadel/zitadel.git
synced 2025-06-04 20:38:19 +00:00
feat: select idp and auto register (#2336)
* faet: auto regsiter config on idp * feat: auto register on login * feat: auto register on register * feat: redirect to selected identity provider * fix: test * fix: test * fix: user by id request org id * fix: migration version and test Co-authored-by: Livio Amstutz <livio.a@gmail.com>
This commit is contained in:
parent
79fb8aa37a
commit
e4bdaf26b0
@ -30,5 +30,6 @@ In addition to the standard compliant scopes we utilize the following scopes.
|
||||
| `urn:zitadel:iam:org:project:id:{projectid}:aud` | ZITADEL's Project id is `urn:zitadel:iam:org:project:id:69234237810729019:aud` | By adding this scope, the requested projectid will be added to the audience of the access and id token |
|
||||
| urn:zitadel:iam:user:metadata | `urn:zitadel:iam:user:metadata` | By adding this scope, the metadata of the user will be included in the token. The values are base64 encoded. |
|
||||
| urn:zitadel:iam:user:resourceowner | `urn:zitadel:iam:user:resourceowner` | By adding this scope, the resourceowner (id, name, primary_domain) of the user will be included in the token. |
|
||||
| urn:zitadel:iam:org:idp:id:{idp_id} | `urn:zitadel:iam:org:idp:id:76625965177954913` | By adding this scope the user will directly be redirected to the identity provider to authenticate. Make sure you also send the primary domain scope if a custom login policy is configured. Otherwise the system will not be able to identify the identity provider. |
|
||||
|
||||
> If access to ZITADEL's API's is needed with a service user the scope `urn:zitadel:iam:org:project:id:69234237810729019:aud` needs to be used with the JWT Profile request
|
||||
|
@ -1201,6 +1201,7 @@ This is an empty request
|
||||
| scopes | repeated string | - | |
|
||||
| display_name_mapping | zitadel.idp.v1.OIDCMappingField | - | enum.defined_only: true<br /> |
|
||||
| username_mapping | zitadel.idp.v1.OIDCMappingField | - | enum.defined_only: true<br /> |
|
||||
| auto_register | bool | - | |
|
||||
|
||||
|
||||
|
||||
@ -2887,6 +2888,7 @@ This is an empty request
|
||||
| idp_id | string | - | string.min_len: 1<br /> string.max_len: 200<br /> |
|
||||
| name | string | - | string.min_len: 1<br /> string.max_len: 200<br /> |
|
||||
| styling_type | zitadel.idp.v1.IDPStylingType | - | enum.defined_only: true<br /> |
|
||||
| auto_register | bool | - | |
|
||||
|
||||
|
||||
|
||||
|
@ -22,6 +22,7 @@ title: zitadel/idp.proto
|
||||
| styling_type | IDPStylingType | - | |
|
||||
| owner | IDPOwnerType | - | |
|
||||
| [**oneof**](https://developers.google.com/protocol-buffers/docs/proto3#oneof) config.oidc_config | OIDCConfig | - | |
|
||||
| auto_register | bool | - | |
|
||||
|
||||
|
||||
|
||||
|
@ -3154,6 +3154,7 @@ This is an empty request
|
||||
| scopes | repeated string | - | |
|
||||
| display_name_mapping | zitadel.idp.v1.OIDCMappingField | - | enum.defined_only: true<br /> |
|
||||
| username_mapping | zitadel.idp.v1.OIDCMappingField | - | enum.defined_only: true<br /> |
|
||||
| auto_register | bool | - | |
|
||||
|
||||
|
||||
|
||||
@ -7379,6 +7380,7 @@ This is an empty request
|
||||
| idp_id | string | - | string.min_len: 1<br /> string.max_len: 200<br /> |
|
||||
| name | string | - | string.min_len: 1<br /> string.max_len: 200<br /> |
|
||||
| styling_type | zitadel.idp.v1.IDPStylingType | - | enum.defined_only: true<br /> |
|
||||
| auto_register | bool | - | |
|
||||
|
||||
|
||||
|
||||
|
@ -16,6 +16,7 @@ func addOIDCIDPRequestToDomain(req *admin_pb.AddOIDCIDPRequest) *domain.IDPConfi
|
||||
OIDCConfig: addOIDCIDPRequestToDomainOIDCIDPConfig(req),
|
||||
StylingType: idp_grpc.IDPStylingTypeToDomain(req.StylingType),
|
||||
Type: domain.IDPConfigTypeOIDC,
|
||||
AutoRegister: req.AutoRegister,
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,6 +36,7 @@ func updateIDPToDomain(req *admin_pb.UpdateIDPRequest) *domain.IDPConfig {
|
||||
IDPConfigID: req.IdpId,
|
||||
Name: req.Name,
|
||||
StylingType: idp_grpc.IDPStylingTypeToDomain(req.StylingType),
|
||||
AutoRegister: req.AutoRegister,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -28,6 +28,7 @@ func Test_addOIDCIDPRequestToDomain(t *testing.T) {
|
||||
Scopes: []string{"email", "profile"},
|
||||
DisplayNameMapping: idp.OIDCMappingField_OIDC_MAPPING_FIELD_EMAIL,
|
||||
UsernameMapping: idp.OIDCMappingField_OIDC_MAPPING_FIELD_PREFERRED_USERNAME,
|
||||
AutoRegister: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -101,6 +102,7 @@ func Test_updateIDPToDomain(t *testing.T) {
|
||||
IdpId: "13523",
|
||||
Name: "new name",
|
||||
StylingType: idp.IDPStylingType_STYLING_TYPE_GOOGLE,
|
||||
AutoRegister: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -22,13 +22,14 @@ func ModelIDPViewToPb(idp *iam_model.IDPConfigView) *idp_pb.IDP {
|
||||
State: ModelIDPStateToPb(idp.State),
|
||||
Name: idp.Name,
|
||||
StylingType: ModelIDPStylingTypeToPb(idp.StylingType),
|
||||
AutoRegister: idp.AutoRegister,
|
||||
Owner: ModelIDPProviderTypeToPb(idp.IDPProviderType),
|
||||
Config: ModelIDPViewToConfigPb(idp),
|
||||
Details: obj_grpc.ToViewDetailsPb(
|
||||
idp.Sequence,
|
||||
idp.CreationDate,
|
||||
idp.ChangeDate,
|
||||
"", //TODO: backend
|
||||
idp.AggregateID,
|
||||
),
|
||||
}
|
||||
}
|
||||
@ -39,8 +40,9 @@ func IDPViewToPb(idp *domain.IDPConfigView) *idp_pb.IDP {
|
||||
State: IDPStateToPb(idp.State),
|
||||
Name: idp.Name,
|
||||
StylingType: IDPStylingTypeToPb(idp.StylingType),
|
||||
AutoRegister: idp.AutoRegister,
|
||||
Config: IDPViewToConfigPb(idp),
|
||||
Details: obj_grpc.ToViewDetailsPb(idp.Sequence, idp.CreationDate, idp.ChangeDate, ""), //TODO: resource owner in view
|
||||
Details: obj_grpc.ToViewDetailsPb(idp.Sequence, idp.CreationDate, idp.ChangeDate, idp.AggregateID),
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ func updateIDPToDomain(req *mgmt_pb.UpdateOrgIDPRequest) *domain.IDPConfig {
|
||||
IDPConfigID: req.IdpId,
|
||||
Name: req.Name,
|
||||
StylingType: idp_grpc.IDPStylingTypeToDomain(req.StylingType),
|
||||
AutoRegister: req.AutoRegister,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -28,6 +28,7 @@ func Test_addOIDCIDPRequestToDomain(t *testing.T) {
|
||||
Scopes: []string{"email", "profile"},
|
||||
DisplayNameMapping: idp.OIDCMappingField_OIDC_MAPPING_FIELD_EMAIL,
|
||||
UsernameMapping: idp.OIDCMappingField_OIDC_MAPPING_FIELD_PREFERRED_USERNAME,
|
||||
AutoRegister: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -45,6 +46,7 @@ func Test_addOIDCIDPRequestToDomain(t *testing.T) {
|
||||
"OIDCConfig.AuthorizationEndpoint",
|
||||
"OIDCConfig.TokenEndpoint",
|
||||
"Type", //TODO: default (0) is oidc
|
||||
"AutoRegister",
|
||||
)
|
||||
})
|
||||
}
|
||||
@ -101,6 +103,7 @@ func Test_updateIDPToDomain(t *testing.T) {
|
||||
IdpId: "13523",
|
||||
Name: "new name",
|
||||
StylingType: idp.IDPStylingType_STYLING_TYPE_GOOGLE,
|
||||
AutoRegister: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -3,6 +3,7 @@ package oidc
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/caos/oidc/pkg/oidc"
|
||||
@ -10,6 +11,7 @@ import (
|
||||
"golang.org/x/text/language"
|
||||
|
||||
http_utils "github.com/caos/zitadel/internal/api/http"
|
||||
model2 "github.com/caos/zitadel/internal/auth_request/model"
|
||||
"github.com/caos/zitadel/internal/domain"
|
||||
"github.com/caos/zitadel/internal/errors"
|
||||
"github.com/caos/zitadel/internal/user/model"
|
||||
@ -123,6 +125,7 @@ func CreateAuthRequestToBusiness(ctx context.Context, authReq *oidc.AuthRequest,
|
||||
PossibleLOAs: ACRValuesToBusiness(authReq.ACRValues),
|
||||
UiLocales: UILocalesToBusiness(authReq.UILocales),
|
||||
LoginHint: authReq.LoginHint,
|
||||
SelectedIDPConfigID: GetSelectedIDPIDFromScopes(authReq.Scopes),
|
||||
MaxAuthAge: MaxAgeToBusiness(authReq.MaxAge),
|
||||
UserID: userID,
|
||||
Request: &domain.AuthRequestOIDC{
|
||||
@ -196,6 +199,15 @@ func UILocalesToBusiness(tags []language.Tag) []string {
|
||||
return locales
|
||||
}
|
||||
|
||||
func GetSelectedIDPIDFromScopes(scopes oidc.SpaceDelimitedArray) string {
|
||||
for _, scope := range scopes {
|
||||
if strings.HasPrefix(scope, model2.SelectIDPScope) {
|
||||
return strings.TrimPrefix(scope, model2.SelectIDPScope)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func MaxAgeToBusiness(maxAge *uint) *time.Duration {
|
||||
if maxAge == nil {
|
||||
return nil
|
||||
|
@ -106,6 +106,9 @@ func (c *Client) IsScopeAllowed(scope string) bool {
|
||||
if strings.HasPrefix(scope, authreq_model.ProjectIDScope) {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(scope, authreq_model.SelectIDPScope) {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(scope, ScopeUserMetaData) {
|
||||
return true
|
||||
}
|
||||
|
@ -133,6 +133,7 @@ func (repo *AuthRequestRepo) CreateAuthRequest(ctx context.Context, request *dom
|
||||
err = repo.checkLoginName(ctx, request, request.LoginHint)
|
||||
logging.LogWithFields("EVENT-aG311", "login name", request.LoginHint, "id", request.ID, "applicationID", request.ApplicationID, "traceID", tracing.TraceIDFromCtx(ctx)).OnError(err).Debug("login hint invalid")
|
||||
}
|
||||
|
||||
err = repo.AuthRequests.SaveAuthRequest(ctx, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -642,7 +643,13 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(users) > 0 || domain.IsPrompt(request.Prompt, domain.PromptSelectAccount) {
|
||||
if domain.IsPrompt(request.Prompt, domain.PromptSelectAccount) {
|
||||
steps = append(steps, &domain.SelectUserStep{Users: users})
|
||||
}
|
||||
if request.SelectedIDPConfigID != "" {
|
||||
steps = append(steps, &domain.RedirectToExternalIDPStep{})
|
||||
}
|
||||
if len(request.Prompt) == 0 && len(users) > 0 {
|
||||
steps = append(steps, &domain.SelectUserStep{Users: users})
|
||||
}
|
||||
}
|
||||
|
@ -305,6 +305,15 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
|
||||
[]domain.NextStep{&domain.ExternalNotFoundOptionStep{}},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"user not set no active session selected idp, redirect to external idp step",
|
||||
fields{
|
||||
userSessionViewProvider: &mockViewNoUserSession{},
|
||||
},
|
||||
args{&domain.AuthRequest{SelectedIDPConfigID: "id"}, false},
|
||||
[]domain.NextStep{&domain.LoginStep{}, &domain.RedirectToExternalIDPStep{}},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"user not set, prompt select account and internal error, internal error",
|
||||
fields{
|
||||
|
@ -23,6 +23,7 @@ const (
|
||||
OrgDomainPrimaryClaim = "urn:zitadel:iam:org:domain:primary"
|
||||
ProjectIDScope = "urn:zitadel:iam:org:project:id:"
|
||||
AudSuffix = ":aud"
|
||||
SelectIDPScope = "urn:zitadel:iam:org:idp:id:"
|
||||
)
|
||||
|
||||
type AuthRequestOIDC struct {
|
||||
|
@ -135,6 +135,7 @@ func writeModelToIDPConfig(wm *IDPConfigWriteModel) *domain.IDPConfig {
|
||||
Name: wm.Name,
|
||||
State: wm.State,
|
||||
StylingType: wm.StylingType,
|
||||
AutoRegister: wm.AutoRegister,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -37,6 +37,7 @@ func (c *Commands) AddDefaultIDPConfig(ctx context.Context, config *domain.IDPCo
|
||||
config.Name,
|
||||
config.Type,
|
||||
config.StylingType,
|
||||
config.AutoRegister,
|
||||
),
|
||||
iam_repo.NewIDPOIDCConfigAddedEvent(
|
||||
ctx,
|
||||
@ -73,11 +74,11 @@ func (c *Commands) ChangeDefaultIDPConfig(ctx context.Context, config *domain.ID
|
||||
return nil, err
|
||||
}
|
||||
if existingIDP.State == domain.IDPConfigStateRemoved || existingIDP.State == domain.IDPConfigStateUnspecified {
|
||||
return nil, caos_errs.ThrowNotFound(nil, "IAM-4M9so", "Errors.IDPConfig.NotExisting")
|
||||
return nil, caos_errs.ThrowNotFound(nil, "IAM-m0e3r", "Errors.IDPConfig.NotExisting")
|
||||
}
|
||||
|
||||
iamAgg := IAMAggregateFromWriteModel(&existingIDP.WriteModel)
|
||||
changedEvent, hasChanged := existingIDP.NewChangedEvent(ctx, iamAgg, config.IDPConfigID, config.Name, config.StylingType)
|
||||
changedEvent, hasChanged := existingIDP.NewChangedEvent(ctx, iamAgg, config.IDPConfigID, config.Name, config.StylingType, config.AutoRegister)
|
||||
if !hasChanged {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "IAM-4M9vs", "Errors.IAM.LabelPolicy.NotChanged")
|
||||
}
|
||||
@ -98,7 +99,7 @@ func (c *Commands) DeactivateDefaultIDPConfig(ctx context.Context, idpID string)
|
||||
return nil, err
|
||||
}
|
||||
if existingIDP.State != domain.IDPConfigStateActive {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "IAM-4M9so", "Errors.IAM.IDPConfig.NotActive")
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "IAM-2n0fs", "Errors.IAM.IDPConfig.NotActive")
|
||||
}
|
||||
iamAgg := IAMAggregateFromWriteModel(&existingIDP.WriteModel)
|
||||
pushedEvents, err := c.eventstore.PushEvents(ctx, iam_repo.NewIDPConfigDeactivatedEvent(ctx, iamAgg, idpID))
|
||||
@ -173,7 +174,7 @@ func (c *Commands) getIAMIDPConfigByID(ctx context.Context, idpID string) (*doma
|
||||
return nil, err
|
||||
}
|
||||
if !config.State.Exists() {
|
||||
return nil, caos_errs.ThrowNotFound(nil, "IAM-4M9so", "Errors.IDPConfig.NotExisting")
|
||||
return nil, caos_errs.ThrowNotFound(nil, "IAM-p0pFF", "Errors.IDPConfig.NotExisting")
|
||||
}
|
||||
return writeModelToIDPConfig(&config.IDPConfigWriteModel), nil
|
||||
}
|
||||
|
@ -100,6 +100,7 @@ func (wm *IAMIDPConfigWriteModel) NewChangedEvent(
|
||||
configID,
|
||||
name string,
|
||||
stylingType domain.IDPConfigStylingType,
|
||||
autoRegister bool,
|
||||
) (*iam.IDPConfigChangedEvent, bool) {
|
||||
|
||||
changes := make([]idpconfig.IDPConfigChanges, 0)
|
||||
@ -111,6 +112,9 @@ func (wm *IAMIDPConfigWriteModel) NewChangedEvent(
|
||||
if stylingType.Valid() && wm.StylingType != stylingType {
|
||||
changes = append(changes, idpconfig.ChangeStyleType(stylingType))
|
||||
}
|
||||
if wm.AutoRegister != autoRegister {
|
||||
changes = append(changes, idpconfig.ChangeAutoRegister(autoRegister))
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
@ -68,6 +68,7 @@ func TestCommandSide_AddDefaultIDPConfig(t *testing.T) {
|
||||
"name1",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeGoogle,
|
||||
true,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
@ -101,6 +102,7 @@ func TestCommandSide_AddDefaultIDPConfig(t *testing.T) {
|
||||
config: &domain.IDPConfig{
|
||||
Name: "name1",
|
||||
StylingType: domain.IDPConfigStylingTypeGoogle,
|
||||
AutoRegister: true,
|
||||
OIDCConfig: &domain.OIDCIDPConfig{
|
||||
ClientID: "clientid1",
|
||||
Issuer: "issuer",
|
||||
@ -123,6 +125,7 @@ func TestCommandSide_AddDefaultIDPConfig(t *testing.T) {
|
||||
Name: "name1",
|
||||
StylingType: domain.IDPConfigStylingTypeGoogle,
|
||||
State: domain.IDPConfigStateActive,
|
||||
AutoRegister: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -212,6 +215,7 @@ func TestCommandSide_ChangeDefaultIDPConfig(t *testing.T) {
|
||||
"name1",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeGoogle,
|
||||
true,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
@ -237,7 +241,7 @@ func TestCommandSide_ChangeDefaultIDPConfig(t *testing.T) {
|
||||
expectPush(
|
||||
[]*repository.Event{
|
||||
eventFromEventPusher(
|
||||
newDefaultIDPConfigChangedEvent(context.Background(), "config1", "name1", "name2", domain.IDPConfigStylingTypeUnspecified),
|
||||
newDefaultIDPConfigChangedEvent(context.Background(), "config1", "name1", "name2", domain.IDPConfigStylingTypeUnspecified, false),
|
||||
),
|
||||
},
|
||||
uniqueConstraintsFromEventConstraint(idpconfig.NewRemoveIDPConfigNameUniqueConstraint("name1", "IAM")),
|
||||
@ -251,6 +255,7 @@ func TestCommandSide_ChangeDefaultIDPConfig(t *testing.T) {
|
||||
IDPConfigID: "config1",
|
||||
Name: "name2",
|
||||
StylingType: domain.IDPConfigStylingTypeUnspecified,
|
||||
AutoRegister: false,
|
||||
},
|
||||
},
|
||||
res: res{
|
||||
@ -263,6 +268,7 @@ func TestCommandSide_ChangeDefaultIDPConfig(t *testing.T) {
|
||||
Name: "name2",
|
||||
StylingType: domain.IDPConfigStylingTypeUnspecified,
|
||||
State: domain.IDPConfigStateActive,
|
||||
AutoRegister: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -286,7 +292,7 @@ func TestCommandSide_ChangeDefaultIDPConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func newDefaultIDPConfigChangedEvent(ctx context.Context, configID, oldName, newName string, stylingType domain.IDPConfigStylingType) *iam.IDPConfigChangedEvent {
|
||||
func newDefaultIDPConfigChangedEvent(ctx context.Context, configID, oldName, newName string, stylingType domain.IDPConfigStylingType, autoRegister bool) *iam.IDPConfigChangedEvent {
|
||||
event, _ := iam.NewIDPConfigChangedEvent(ctx,
|
||||
&iam.NewAggregate().Aggregate,
|
||||
configID,
|
||||
@ -294,6 +300,7 @@ func newDefaultIDPConfigChangedEvent(ctx context.Context, configID, oldName, new
|
||||
[]idpconfig.IDPConfigChanges{
|
||||
idpconfig.ChangeName(newName),
|
||||
idpconfig.ChangeStyleType(stylingType),
|
||||
idpconfig.ChangeAutoRegister(autoRegister),
|
||||
},
|
||||
)
|
||||
return event
|
||||
|
@ -84,6 +84,7 @@ func TestCommandSide_ChangeDefaultIDPOIDCConfig(t *testing.T) {
|
||||
"name1",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeGoogle,
|
||||
true,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
@ -138,6 +139,7 @@ func TestCommandSide_ChangeDefaultIDPOIDCConfig(t *testing.T) {
|
||||
"name1",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeGoogle,
|
||||
true,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
@ -193,6 +195,7 @@ func TestCommandSide_ChangeDefaultIDPOIDCConfig(t *testing.T) {
|
||||
"name1",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeGoogle,
|
||||
true,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
|
@ -344,6 +344,7 @@ func TestCommandSide_AddIDPProviderDefaultLoginPolicy(t *testing.T) {
|
||||
"name",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeUnspecified,
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -391,6 +392,7 @@ func TestCommandSide_AddIDPProviderDefaultLoginPolicy(t *testing.T) {
|
||||
"name",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeUnspecified,
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -13,6 +13,7 @@ type IDPConfigWriteModel struct {
|
||||
|
||||
ConfigID string
|
||||
Name string
|
||||
AutoRegister bool
|
||||
StylingType domain.IDPConfigStylingType
|
||||
}
|
||||
|
||||
@ -42,6 +43,7 @@ func (rm *IDPConfigWriteModel) reduceConfigAddedEvent(e *idpconfig.IDPConfigAdde
|
||||
rm.ConfigID = e.ConfigID
|
||||
rm.Name = e.Name
|
||||
rm.StylingType = e.StylingType
|
||||
rm.AutoRegister = e.AutoRegister
|
||||
rm.State = domain.IDPConfigStateActive
|
||||
}
|
||||
|
||||
@ -52,6 +54,9 @@ func (rm *IDPConfigWriteModel) reduceConfigChangedEvent(e *idpconfig.IDPConfigCh
|
||||
if e.StylingType != nil && e.StylingType.Valid() {
|
||||
rm.StylingType = *e.StylingType
|
||||
}
|
||||
if e.AutoRegister != nil {
|
||||
rm.AutoRegister = *e.AutoRegister
|
||||
}
|
||||
}
|
||||
|
||||
func (rm *IDPConfigWriteModel) reduceConfigStateChanged(configID string, state domain.IDPConfigState) {
|
||||
|
@ -40,6 +40,7 @@ func (c *Commands) AddIDPConfig(ctx context.Context, config *domain.IDPConfig, r
|
||||
config.Name,
|
||||
config.Type,
|
||||
config.StylingType,
|
||||
config.AutoRegister,
|
||||
),
|
||||
org_repo.NewIDPOIDCConfigAddedEvent(
|
||||
ctx,
|
||||
@ -69,12 +70,12 @@ func (c *Commands) ChangeIDPConfig(ctx context.Context, config *domain.IDPConfig
|
||||
if resourceOwner == "" {
|
||||
return nil, caos_errs.ThrowInvalidArgument(nil, "Org-Gh8ds", "Errors.ResourceOwnerMissing")
|
||||
}
|
||||
existingIDP, err := c.orgIDPConfigWriteModelByID(ctx, config.IDPConfigID, config.AggregateID)
|
||||
existingIDP, err := c.orgIDPConfigWriteModelByID(ctx, config.IDPConfigID, resourceOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existingIDP.State == domain.IDPConfigStateRemoved || existingIDP.State == domain.IDPConfigStateUnspecified {
|
||||
return nil, caos_errs.ThrowNotFound(nil, "Org-4M9so", "Errors.Org.IDPConfig.NotExisting")
|
||||
return nil, caos_errs.ThrowNotFound(nil, "Org-1J9fs", "Errors.Org.IDPConfig.NotExisting")
|
||||
}
|
||||
|
||||
orgAgg := OrgAggregateFromWriteModel(&existingIDP.WriteModel)
|
||||
@ -83,7 +84,8 @@ func (c *Commands) ChangeIDPConfig(ctx context.Context, config *domain.IDPConfig
|
||||
orgAgg,
|
||||
config.IDPConfigID,
|
||||
config.Name,
|
||||
config.StylingType)
|
||||
config.StylingType,
|
||||
config.AutoRegister)
|
||||
|
||||
if !hasChanged {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "Org-4M9vs", "Errors.Org.LabelPolicy.NotChanged")
|
||||
@ -105,7 +107,7 @@ func (c *Commands) DeactivateIDPConfig(ctx context.Context, idpID, orgID string)
|
||||
return nil, err
|
||||
}
|
||||
if existingIDP.State != domain.IDPConfigStateActive {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "Org-4M9so", "Errors.Org.IDPConfig.NotActive")
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "Org-BBmd0", "Errors.Org.IDPConfig.NotActive")
|
||||
}
|
||||
orgAgg := OrgAggregateFromWriteModel(&existingIDP.WriteModel)
|
||||
pushedEvents, err := c.eventstore.PushEvents(ctx, org_repo.NewIDPConfigDeactivatedEvent(ctx, orgAgg, idpID))
|
||||
@ -185,7 +187,7 @@ func (c *Commands) getOrgIDPConfigByID(ctx context.Context, idpID, orgID string)
|
||||
return nil, err
|
||||
}
|
||||
if !config.State.Exists() {
|
||||
return nil, caos_errs.ThrowNotFound(nil, "ORG-4M9so", "Errors.Org.IDPConfig.NotExisting")
|
||||
return nil, caos_errs.ThrowNotFound(nil, "ORG-2m90f", "Errors.Org.IDPConfig.NotExisting")
|
||||
}
|
||||
return writeModelToIDPConfig(&config.IDPConfigWriteModel), nil
|
||||
}
|
||||
|
@ -100,6 +100,7 @@ func (wm *OrgIDPConfigWriteModel) NewChangedEvent(
|
||||
configID,
|
||||
name string,
|
||||
stylingType domain.IDPConfigStylingType,
|
||||
autoRegister bool,
|
||||
) (*org.IDPConfigChangedEvent, bool) {
|
||||
|
||||
changes := make([]idpconfig.IDPConfigChanges, 0)
|
||||
@ -111,6 +112,9 @@ func (wm *OrgIDPConfigWriteModel) NewChangedEvent(
|
||||
if stylingType.Valid() && wm.StylingType != stylingType {
|
||||
changes = append(changes, idpconfig.ChangeStyleType(stylingType))
|
||||
}
|
||||
if wm.AutoRegister != autoRegister {
|
||||
changes = append(changes, idpconfig.ChangeAutoRegister(autoRegister))
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
@ -52,6 +52,7 @@ func TestCommandSide_AddIDPConfig(t *testing.T) {
|
||||
config: &domain.IDPConfig{
|
||||
Name: "name1",
|
||||
StylingType: domain.IDPConfigStylingTypeGoogle,
|
||||
AutoRegister: true,
|
||||
OIDCConfig: &domain.OIDCIDPConfig{
|
||||
ClientID: "clientid1",
|
||||
Issuer: "issuer",
|
||||
@ -96,6 +97,7 @@ func TestCommandSide_AddIDPConfig(t *testing.T) {
|
||||
"name1",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeGoogle,
|
||||
true,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
@ -130,6 +132,7 @@ func TestCommandSide_AddIDPConfig(t *testing.T) {
|
||||
config: &domain.IDPConfig{
|
||||
Name: "name1",
|
||||
StylingType: domain.IDPConfigStylingTypeGoogle,
|
||||
AutoRegister: true,
|
||||
OIDCConfig: &domain.OIDCIDPConfig{
|
||||
ClientID: "clientid1",
|
||||
Issuer: "issuer",
|
||||
@ -152,6 +155,7 @@ func TestCommandSide_AddIDPConfig(t *testing.T) {
|
||||
Name: "name1",
|
||||
StylingType: domain.IDPConfigStylingTypeGoogle,
|
||||
State: domain.IDPConfigStateActive,
|
||||
AutoRegister: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -260,6 +264,7 @@ func TestCommandSide_ChangeIDPConfig(t *testing.T) {
|
||||
"name1",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeGoogle,
|
||||
true,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
@ -300,6 +305,7 @@ func TestCommandSide_ChangeIDPConfig(t *testing.T) {
|
||||
IDPConfigID: "config1",
|
||||
Name: "name2",
|
||||
StylingType: domain.IDPConfigStylingTypeUnspecified,
|
||||
AutoRegister: true,
|
||||
},
|
||||
},
|
||||
res: res{
|
||||
@ -312,6 +318,7 @@ func TestCommandSide_ChangeIDPConfig(t *testing.T) {
|
||||
Name: "name2",
|
||||
StylingType: domain.IDPConfigStylingTypeUnspecified,
|
||||
State: domain.IDPConfigStateActive,
|
||||
AutoRegister: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -104,6 +104,7 @@ func TestCommandSide_ChangeIDPOIDCConfig(t *testing.T) {
|
||||
"name1",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeGoogle,
|
||||
true,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
@ -159,6 +160,7 @@ func TestCommandSide_ChangeIDPOIDCConfig(t *testing.T) {
|
||||
"name1",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeGoogle,
|
||||
true,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
@ -215,6 +217,7 @@ func TestCommandSide_ChangeIDPOIDCConfig(t *testing.T) {
|
||||
"name1",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeGoogle,
|
||||
true,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
|
@ -659,6 +659,7 @@ func TestCommandSide_AddIDPProviderLoginPolicy(t *testing.T) {
|
||||
"name",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeUnspecified,
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -710,6 +711,7 @@ func TestCommandSide_AddIDPProviderLoginPolicy(t *testing.T) {
|
||||
"name",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeUnspecified,
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -164,6 +164,7 @@ func TestCommandSide_BulkAddExternalIDPs(t *testing.T) {
|
||||
"name",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeUnspecified,
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -213,6 +214,7 @@ func TestCommandSide_BulkAddExternalIDPs(t *testing.T) {
|
||||
"name",
|
||||
domain.IDPConfigTypeOIDC,
|
||||
domain.IDPConfigStylingTypeUnspecified,
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -15,6 +15,7 @@ type IDPConfig struct {
|
||||
StylingType IDPConfigStylingType
|
||||
State IDPConfigState
|
||||
OIDCConfig *OIDCIDPConfig
|
||||
AutoRegister bool
|
||||
}
|
||||
|
||||
type IDPConfigView struct {
|
||||
@ -27,6 +28,7 @@ type IDPConfigView struct {
|
||||
ChangeDate time.Time
|
||||
Sequence uint64
|
||||
IDPProviderType IdentityProviderType
|
||||
AutoRegister bool
|
||||
|
||||
IsOIDC bool
|
||||
OIDCClientID string
|
||||
|
@ -27,6 +27,7 @@ const (
|
||||
NextStepPasswordlessRegistrationPrompt
|
||||
NextStepRegistration
|
||||
NextStepProjectRequired
|
||||
NextStepRedirectToExternalIDP
|
||||
)
|
||||
|
||||
type LoginStep struct{}
|
||||
@ -67,6 +68,12 @@ const (
|
||||
UserSessionStateTerminated
|
||||
)
|
||||
|
||||
type RedirectToExternalIDPStep struct{}
|
||||
|
||||
func (s *RedirectToExternalIDPStep) Type() NextStepType {
|
||||
return NextStepRedirectToExternalIDP
|
||||
}
|
||||
|
||||
type InitUserStep struct {
|
||||
PasswordSet bool
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ type IDPConfigView struct {
|
||||
IDPConfigID string
|
||||
Name string
|
||||
StylingType IDPStylingType
|
||||
AutoRegister bool
|
||||
State IDPConfigState
|
||||
CreationDate time.Time
|
||||
ChangeDate time.Time
|
||||
|
@ -33,6 +33,7 @@ type IDPConfigView struct {
|
||||
ChangeDate time.Time `json:"-" gorm:"column:change_date"`
|
||||
IDPState int32 `json:"-" gorm:"column:idp_state"`
|
||||
IDPProviderType int32 `json:"-" gorm:"column:idp_provider_type"`
|
||||
AutoRegister bool `json:"autoRegister" gorm:"column:auto_register"`
|
||||
|
||||
IsOIDC bool `json:"-" gorm:"column:is_oidc"`
|
||||
OIDCClientID string `json:"clientId" gorm:"column:oidc_client_id"`
|
||||
@ -54,6 +55,7 @@ func IDPConfigViewToModel(idp *IDPConfigView) *model.IDPConfigView {
|
||||
State: model.IDPConfigState(idp.IDPState),
|
||||
Name: idp.Name,
|
||||
StylingType: model.IDPStylingType(idp.StylingType),
|
||||
AutoRegister: idp.AutoRegister,
|
||||
Sequence: idp.Sequence,
|
||||
CreationDate: idp.CreationDate,
|
||||
ChangeDate: idp.ChangeDate,
|
||||
|
@ -12,6 +12,7 @@ type IDPConfigReadModel struct {
|
||||
State domain.IDPConfigState
|
||||
ConfigID string
|
||||
Name string
|
||||
AutoRegister bool
|
||||
StylingType domain.IDPConfigStylingType
|
||||
ProviderType domain.IdentityProviderType
|
||||
|
||||
@ -77,6 +78,7 @@ func (rm *IDPConfigReadModel) reduceConfigAddedEvent(e *idpconfig.IDPConfigAdded
|
||||
rm.Name = e.Name
|
||||
rm.StylingType = e.StylingType
|
||||
rm.State = domain.IDPConfigStateActive
|
||||
rm.AutoRegister = e.AutoRegister
|
||||
}
|
||||
|
||||
func (rm *IDPConfigReadModel) reduceConfigChangedEvent(e *idpconfig.IDPConfigChangedEvent) {
|
||||
@ -86,6 +88,9 @@ func (rm *IDPConfigReadModel) reduceConfigChangedEvent(e *idpconfig.IDPConfigCha
|
||||
if e.StylingType != nil && e.StylingType.Valid() {
|
||||
rm.StylingType = *e.StylingType
|
||||
}
|
||||
if e.AutoRegister != nil {
|
||||
rm.AutoRegister = *e.AutoRegister
|
||||
}
|
||||
}
|
||||
|
||||
func (rm *IDPConfigReadModel) reduceConfigStateChanged(configID string, state domain.IDPConfigState) {
|
||||
|
@ -28,6 +28,7 @@ func NewIDPConfigAddedEvent(
|
||||
name string,
|
||||
configType domain.IDPConfigType,
|
||||
stylingType domain.IDPConfigStylingType,
|
||||
autoRegister bool,
|
||||
) *IDPConfigAddedEvent {
|
||||
|
||||
return &IDPConfigAddedEvent{
|
||||
@ -41,6 +42,7 @@ func NewIDPConfigAddedEvent(
|
||||
name,
|
||||
configType,
|
||||
stylingType,
|
||||
autoRegister,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
@ -33,6 +33,7 @@ type IDPConfigAddedEvent struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Typ domain.IDPConfigType `json:"idpType,omitempty"`
|
||||
StylingType domain.IDPConfigStylingType `json:"stylingType,omitempty"`
|
||||
AutoRegister bool `json:"autoRegister,omitempty"`
|
||||
}
|
||||
|
||||
func NewIDPConfigAddedEvent(
|
||||
@ -41,6 +42,7 @@ func NewIDPConfigAddedEvent(
|
||||
name string,
|
||||
configType domain.IDPConfigType,
|
||||
stylingType domain.IDPConfigStylingType,
|
||||
autoRegister bool,
|
||||
) *IDPConfigAddedEvent {
|
||||
return &IDPConfigAddedEvent{
|
||||
BaseEvent: *base,
|
||||
@ -48,6 +50,7 @@ func NewIDPConfigAddedEvent(
|
||||
Name: name,
|
||||
StylingType: stylingType,
|
||||
Typ: configType,
|
||||
AutoRegister: autoRegister,
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,6 +81,7 @@ type IDPConfigChangedEvent struct {
|
||||
ConfigID string `json:"idpConfigId"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
StylingType *domain.IDPConfigStylingType `json:"stylingType,omitempty"`
|
||||
AutoRegister *bool `json:"autoRegister,omitempty"`
|
||||
oldName string `json:"-"`
|
||||
}
|
||||
|
||||
@ -129,6 +133,12 @@ func ChangeStyleType(styleType domain.IDPConfigStylingType) func(*IDPConfigChang
|
||||
}
|
||||
}
|
||||
|
||||
func ChangeAutoRegister(autoRegister bool) func(*IDPConfigChangedEvent) {
|
||||
return func(e *IDPConfigChangedEvent) {
|
||||
e.AutoRegister = &autoRegister
|
||||
}
|
||||
}
|
||||
|
||||
func IDPConfigChangedEventMapper(event *repository.Event) (eventstore.EventReader, error) {
|
||||
e := &IDPConfigChangedEvent{
|
||||
BaseEvent: *eventstore.BaseEventFromRepo(event),
|
||||
|
@ -28,6 +28,7 @@ func NewIDPConfigAddedEvent(
|
||||
name string,
|
||||
configType domain.IDPConfigType,
|
||||
stylingType domain.IDPConfigStylingType,
|
||||
autoRegister bool,
|
||||
) *IDPConfigAddedEvent {
|
||||
|
||||
return &IDPConfigAddedEvent{
|
||||
@ -41,6 +42,7 @@ func NewIDPConfigAddedEvent(
|
||||
name,
|
||||
configType,
|
||||
stylingType,
|
||||
autoRegister,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
@ -160,9 +160,18 @@ func (l *Login) handleExternalUserAuthenticated(w http.ResponseWriter, r *http.R
|
||||
if errors.IsNotFound(err) {
|
||||
err = nil
|
||||
}
|
||||
if !idpConfig.AutoRegister {
|
||||
l.renderExternalNotFoundOption(w, r, authReq, err)
|
||||
return
|
||||
}
|
||||
authReq, err = l.authRepo.AuthRequestByID(r.Context(), authReq.ID, userAgentID)
|
||||
if err != nil {
|
||||
l.renderExternalNotFoundOption(w, r, authReq, err)
|
||||
return
|
||||
}
|
||||
l.handleAutoRegister(w, r, authReq)
|
||||
return
|
||||
}
|
||||
l.renderNextStep(w, r, authReq)
|
||||
}
|
||||
|
||||
|
@ -119,7 +119,27 @@ func (l *Login) handleExternalUserRegister(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
user, externalIDP := l.mapTokenToLoginHumanAndExternalIDP(orgIamPolicy, tokens, idpConfig)
|
||||
if !idpConfig.AutoRegister {
|
||||
l.renderExternalRegisterOverview(w, r, authReq, orgIamPolicy, user, externalIDP, nil)
|
||||
return
|
||||
}
|
||||
l.registerExternalUser(w, r, authReq, iam, user, externalIDP)
|
||||
}
|
||||
|
||||
func (l *Login) registerExternalUser(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, iam *iam_model.IAM, user *domain.Human, externalIDP *domain.ExternalIDP) {
|
||||
resourceOwner := iam.GlobalOrgID
|
||||
memberRoles := []string{domain.RoleOrgProjectCreator}
|
||||
|
||||
if authReq.RequestedOrgID != "" && authReq.RequestedOrgID != resourceOwner {
|
||||
memberRoles = nil
|
||||
resourceOwner = authReq.RequestedOrgID
|
||||
}
|
||||
_, err := l.command.RegisterHuman(setContext(r.Context(), resourceOwner), resourceOwner, user, externalIDP, memberRoles)
|
||||
if err != nil {
|
||||
l.renderRegisterOption(w, r, authReq, err)
|
||||
return
|
||||
}
|
||||
l.renderNextStep(w, r, authReq)
|
||||
}
|
||||
|
||||
func (l *Login) renderExternalRegisterOverview(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, orgIAMPolicy *iam_model.OrgIAMPolicyView, human *domain.Human, idp *domain.ExternalIDP, err error) {
|
||||
|
@ -223,7 +223,7 @@ func (l *Login) renderNextStep(w http.ResponseWriter, r *http.Request, authReq *
|
||||
userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
|
||||
authReq, err := l.authRepo.AuthRequestByID(r.Context(), authReq.ID, userAgentID)
|
||||
if err != nil {
|
||||
l.renderInternalError(w, r, authReq, caos_errs.ThrowInternal(err, "APP-sio0W", "could not get authreq"))
|
||||
l.renderInternalError(w, r, authReq, err)
|
||||
return
|
||||
}
|
||||
if len(authReq.PossibleSteps) == 0 {
|
||||
@ -257,6 +257,8 @@ func (l *Login) chooseNextStep(w http.ResponseWriter, r *http.Request, authReq *
|
||||
l.renderRegisterOption(w, r, authReq, nil)
|
||||
case *domain.SelectUserStep:
|
||||
l.renderUserSelection(w, r, authReq, step)
|
||||
case *domain.RedirectToExternalIDPStep:
|
||||
l.handleIDP(w, r, authReq, authReq.SelectedIDPConfigID)
|
||||
case *domain.InitPasswordStep:
|
||||
l.renderInitPassword(w, r, authReq, authReq.UserID, "", err)
|
||||
case *domain.PasswordStep:
|
||||
|
3
migrations/cockroach/V1.70__idp.sql
Normal file
3
migrations/cockroach/V1.70__idp.sql
Normal file
@ -0,0 +1,3 @@
|
||||
ALTER TABLE management.idp_configs ADD COLUMN auto_register BOOLEAN;
|
||||
ALTER TABLE adminapi.idp_configs ADD COLUMN auto_register BOOLEAN;
|
||||
ALTER TABLE auth.idp_configs ADD COLUMN auto_register BOOLEAN;
|
@ -2428,6 +2428,7 @@ message AddOIDCIDPRequest {
|
||||
description: "definition which field is mapped to the email of the user";
|
||||
}
|
||||
];
|
||||
bool auto_register = 9;
|
||||
}
|
||||
|
||||
message AddOIDCIDPResponse {
|
||||
@ -2458,6 +2459,7 @@ message UpdateIDPRequest {
|
||||
description: "some identity providers specify the styling of the button to their login";
|
||||
}
|
||||
];
|
||||
bool auto_register = 4;
|
||||
}
|
||||
|
||||
message UpdateIDPResponse {
|
||||
|
@ -38,6 +38,7 @@ message IDP {
|
||||
oneof config {
|
||||
OIDCConfig oidc_config = 7;
|
||||
}
|
||||
bool auto_register = 8;
|
||||
}
|
||||
|
||||
message IDPUserLink {
|
||||
|
@ -4884,6 +4884,7 @@ message AddOrgOIDCIDPRequest {
|
||||
description: "definition which field is mapped to the email of the user";
|
||||
}
|
||||
];
|
||||
bool auto_register = 9;
|
||||
}
|
||||
|
||||
message AddOrgOIDCIDPResponse {
|
||||
@ -4928,6 +4929,7 @@ message UpdateOrgIDPRequest {
|
||||
description: "some identity providers specify the styling of the button to their login";
|
||||
}
|
||||
];
|
||||
bool auto_register = 4;
|
||||
}
|
||||
|
||||
message UpdateOrgIDPResponse {
|
||||
|
Loading…
x
Reference in New Issue
Block a user