feat: user v2alpha email API (#5708)

* chore(proto): update versions

* change protoc plugin

* some cleanups

* define api for setting emails in new api

* implement user.SetEmail

* move SetEmail buisiness logic into command

* resuse newCryptoCode

* command: add ChangeEmail unit tests

Not complete, was not able to mock the generator.

* Revert "resuse newCryptoCode"

This reverts commit c89e90ae35ae924a3f706a0a7394f933910c2e65.

* undo change to crypto code generators

* command: use a generator so we can test properly

* command: reorganise ChangeEmail

improve test coverage

* implement VerifyEmail

including unit tests

* add URL template tests

* proto: change context to object

* remove old auth option

* remove old auth option

* fix linting errors

run gci on modified files

* add permission checks and fix some errors

* comments

* comments

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
Co-authored-by: Tim Möhlmann <tim+github@zitadel.com>
This commit is contained in:
Silvan 2023-04-25 09:02:29 +02:00 committed by GitHub
parent 2a79e77c7b
commit 095ec21678
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 3589 additions and 399 deletions

View File

@ -16,11 +16,11 @@ ENV PROTOC_ARCH x86_64
## protoc and protoc-gen-grpc-web for later use ## protoc and protoc-gen-grpc-web for later use
####################### #######################
FROM ${BUILDARCH}-base FROM ${BUILDARCH}-base
ARG PROTOC_VERSION=3.18.0 ARG PROTOC_VERSION=22.3
ARG PROTOC_ZIP=protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip ARG PROTOC_ZIP=protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip
ARG GRPC_WEB_VERSION=1.3.0 ARG GRPC_WEB_VERSION=1.3.0
ARG GATEWAY_VERSION=2.15.1 ARG GATEWAY_VERSION=2.15.2
ARG VALIDATOR_VERSION=0.6.2 ARG VALIDATOR_VERSION=0.10.1
# no arm specific version available and x86 works fine at the moment: # no arm specific version available and x86 works fine at the moment:
ARG GRPC_WEB=protoc-gen-grpc-web-${GRPC_WEB_VERSION}-linux-x86_64 ARG GRPC_WEB=protoc-gen-grpc-web-${GRPC_WEB_VERSION}-linux-x86_64

View File

@ -73,7 +73,6 @@ COPY --from=go-stub /go/src/github.com/zitadel/zitadel/openapi/statik/statik.go
COPY --from=go-stub /go/src/github.com/zitadel/zitadel/pkg/grpc pkg/grpc COPY --from=go-stub /go/src/github.com/zitadel/zitadel/pkg/grpc pkg/grpc
COPY --from=go-stub /go/src/github.com/zitadel/zitadel/openapi/v2/zitadel openapi/v2/zitadel COPY --from=go-stub /go/src/github.com/zitadel/zitadel/openapi/v2/zitadel openapi/v2/zitadel
COPY --from=go-stub /go/src/github.com/zitadel/zitadel/openapi/statik/statik.go openapi/statik/statik.go COPY --from=go-stub /go/src/github.com/zitadel/zitadel/openapi/statik/statik.go openapi/statik/statik.go
COPY --from=go-stub /go/src/github.com/zitadel/zitadel/internal/protoc/protoc-gen-authoption/templates.gen.go internal/protoc/protoc-gen-authoption/templates.gen.go
COPY --from=go-stub /go/src/github.com/zitadel/zitadel/internal/protoc/protoc-gen-authoption/authoption/options.pb.go internal/protoc/protoc-gen-authoption/authoption/options.pb.go COPY --from=go-stub /go/src/github.com/zitadel/zitadel/internal/protoc/protoc-gen-authoption/authoption/options.pb.go internal/protoc/protoc-gen-authoption/authoption/options.pb.go
COPY --from=go-stub /go/src/github.com/zitadel/zitadel/docs/apis/proto docs/docs/apis/proto COPY --from=go-stub /go/src/github.com/zitadel/zitadel/docs/apis/proto docs/docs/apis/proto
COPY --from=go-stub /go/src/github.com/zitadel/zitadel/docs/apis/assets docs/docs/apis/assets COPY --from=go-stub /go/src/github.com/zitadel/zitadel/docs/apis/assets docs/docs/apis/assets

View File

@ -15,17 +15,11 @@ protoc \
-I=/proto/include/ \ -I=/proto/include/ \
--go_out $GOPATH/src \ --go_out $GOPATH/src \
--go-grpc_out $GOPATH/src \ --go-grpc_out $GOPATH/src \
--validate_out=lang=go:${GOPATH}/src \
$(find ${PROTO_PATH} -iname *.proto) $(find ${PROTO_PATH} -iname *.proto)
# generate authoptions code from templates
go-bindata \
-pkg main \
-prefix internal/protoc/protoc-gen-authoption \
-o ${ZITADEL_PATH}/internal/protoc/protoc-gen-authoption/templates.gen.go \
${ZITADEL_PATH}/internal/protoc/protoc-gen-authoption/templates
# install authoption proto compiler # install authoption proto compiler
go install ${ZITADEL_PATH}/internal/protoc/protoc-gen-authoption go install ${ZITADEL_PATH}/internal/protoc/protoc-gen-auth
# output folder for openapi v2 # output folder for openapi v2
mkdir -p ${OPENAPI_PATH} mkdir -p ${OPENAPI_PATH}
@ -39,28 +33,20 @@ protoc \
--grpc-gateway_opt logtostderr=true \ --grpc-gateway_opt logtostderr=true \
--openapiv2_out ${OPENAPI_PATH} \ --openapiv2_out ${OPENAPI_PATH} \
--openapiv2_opt logtostderr=true \ --openapiv2_opt logtostderr=true \
--authoption_out ${GRPC_PATH}/system \ --auth_out ${GOPATH}/src \
--validate_out=lang=go:${GOPATH}/src \ --validate_out=lang=go:${GOPATH}/src \
${PROTO_PATH}/system.proto ${PROTO_PATH}/system.proto
# authoptions are generated into the wrong folder
mv ${ZITADEL_PATH}/pkg/grpc/system/zitadel/* ${ZITADEL_PATH}/pkg/grpc/system
rm -r ${ZITADEL_PATH}/pkg/grpc/system/zitadel
protoc \ protoc \
-I=/proto/include \ -I=/proto/include \
--grpc-gateway_out ${GOPATH}/src \ --grpc-gateway_out ${GOPATH}/src \
--grpc-gateway_opt logtostderr=true \ --grpc-gateway_opt logtostderr=true \
--openapiv2_out ${OPENAPI_PATH} \ --openapiv2_out ${OPENAPI_PATH} \
--openapiv2_opt logtostderr=true \ --openapiv2_opt logtostderr=true \
--authoption_out ${GRPC_PATH}/admin \ --auth_out ${GOPATH}/src \
--validate_out=lang=go:${GOPATH}/src \ --validate_out=lang=go:${GOPATH}/src \
${PROTO_PATH}/admin.proto ${PROTO_PATH}/admin.proto
# authoptions are generated into the wrong folder
mv ${ZITADEL_PATH}/pkg/grpc/admin/zitadel/* ${ZITADEL_PATH}/pkg/grpc/admin
rm -r ${ZITADEL_PATH}/pkg/grpc/admin/zitadel
protoc \ protoc \
-I=/proto/include \ -I=/proto/include \
--grpc-gateway_out ${GOPATH}/src \ --grpc-gateway_out ${GOPATH}/src \
@ -69,14 +55,10 @@ protoc \
--openapiv2_out ${OPENAPI_PATH} \ --openapiv2_out ${OPENAPI_PATH} \
--openapiv2_opt logtostderr=true \ --openapiv2_opt logtostderr=true \
--openapiv2_opt allow_delete_body=true \ --openapiv2_opt allow_delete_body=true \
--authoption_out ${GRPC_PATH}/management \ --auth_out ${GOPATH}/src \
--validate_out=lang=go:${GOPATH}/src \ --validate_out=lang=go:${GOPATH}/src \
${PROTO_PATH}/management.proto ${PROTO_PATH}/management.proto
# authoptions are generated into the wrong folder
mv ${ZITADEL_PATH}/pkg/grpc/management/zitadel/* ${ZITADEL_PATH}/pkg/grpc/management
rm -r ${ZITADEL_PATH}/pkg/grpc/management/zitadel
protoc \ protoc \
-I=/proto/include \ -I=/proto/include \
--grpc-gateway_out ${GOPATH}/src \ --grpc-gateway_out ${GOPATH}/src \
@ -85,14 +67,10 @@ protoc \
--openapiv2_out ${OPENAPI_PATH} \ --openapiv2_out ${OPENAPI_PATH} \
--openapiv2_opt logtostderr=true \ --openapiv2_opt logtostderr=true \
--openapiv2_opt allow_delete_body=true \ --openapiv2_opt allow_delete_body=true \
--authoption_out=${GRPC_PATH}/auth \ --auth_out=${GOPATH}/src \
--validate_out=lang=go:${GOPATH}/src \ --validate_out=lang=go:${GOPATH}/src \
${PROTO_PATH}/auth.proto ${PROTO_PATH}/auth.proto
# authoptions are generated into the wrong folder
mv ${ZITADEL_PATH}/pkg/grpc/auth/zitadel/* ${ZITADEL_PATH}/pkg/grpc/auth
rm -r ${ZITADEL_PATH}/pkg/grpc/auth/zitadel
protoc \ protoc \
-I=/proto/include \ -I=/proto/include \
--grpc-gateway_out ${GOPATH}/src \ --grpc-gateway_out ${GOPATH}/src \
@ -101,14 +79,10 @@ protoc \
--openapiv2_out ${OPENAPI_PATH} \ --openapiv2_out ${OPENAPI_PATH} \
--openapiv2_opt logtostderr=true \ --openapiv2_opt logtostderr=true \
--openapiv2_opt allow_delete_body=true \ --openapiv2_opt allow_delete_body=true \
--authoption_out=${GRPC_PATH}/user \ --auth_out=${GOPATH}/src \
--validate_out=lang=go:${GOPATH}/src \ --validate_out=lang=go:${GOPATH}/src \
${PROTO_PATH}/user/v2alpha/user_service.proto ${PROTO_PATH}/user/v2alpha/user_service.proto
# authoptions are generated into the wrong folder
cp -r ${ZITADEL_PATH}/pkg/grpc/user/zitadel/* ${ZITADEL_PATH}/pkg/grpc
rm -r ${ZITADEL_PATH}/pkg/grpc/user/zitadel
protoc \ protoc \
-I=/proto/include \ -I=/proto/include \
--grpc-gateway_out ${GOPATH}/src \ --grpc-gateway_out ${GOPATH}/src \
@ -117,12 +91,8 @@ protoc \
--openapiv2_out ${OPENAPI_PATH} \ --openapiv2_out ${OPENAPI_PATH} \
--openapiv2_opt logtostderr=true \ --openapiv2_opt logtostderr=true \
--openapiv2_opt allow_delete_body=true \ --openapiv2_opt allow_delete_body=true \
--authoption_out=${GRPC_PATH}/session \ --auth_out=${GOPATH}/src \
--validate_out=lang=go:${GOPATH}/src \ --validate_out=lang=go:${GOPATH}/src \
${PROTO_PATH}/session/v2alpha/session_service.proto ${PROTO_PATH}/session/v2alpha/session_service.proto
# authoptions are generated into the wrong folder
cp -r ${ZITADEL_PATH}/pkg/grpc/session/zitadel/* ${ZITADEL_PATH}/pkg/grpc
rm -r ${ZITADEL_PATH}/pkg/grpc/session/zitadel
echo "done generating grpc" echo "done generating grpc"

View File

@ -76,6 +76,7 @@ func (mig *FirstInstance) Execute(ctx context.Context) error {
nil, nil,
nil, nil,
nil, nil,
nil,
) )
if err != nil { if err != nil {

View File

@ -33,7 +33,8 @@ func (mig *externalConfigChange) Check() bool {
} }
func (mig *externalConfigChange) Execute(ctx context.Context) error { func (mig *externalConfigChange) Execute(ctx context.Context) error {
cmd, err := command.StartCommands(mig.es, cmd, err := command.StartCommands(
mig.es,
systemdefaults.SystemDefaults{}, systemdefaults.SystemDefaults{},
nil, nil,
nil, nil,
@ -50,6 +51,7 @@ func (mig *externalConfigChange) Execute(ctx context.Context) error {
nil, nil,
nil, nil,
nil, nil,
nil,
) )
if err != nil { if err != nil {

View File

@ -146,6 +146,7 @@ func startZitadel(config *Config, masterKey string) error {
keys.OIDC, keys.OIDC,
keys.SAML, keys.SAML,
&http.Client{}, &http.Client{},
authZRepo,
) )
if err != nil { if err != nil {
return fmt.Errorf("cannot start commands: %w", err) return fmt.Errorf("cannot start commands: %w", err)
@ -248,7 +249,7 @@ func startAPIs(
if err := apis.RegisterServer(ctx, auth.CreateServer(commands, queries, authRepo, config.SystemDefaults, keys.User, config.ExternalSecure, config.AuditLogRetention)); err != nil { if err := apis.RegisterServer(ctx, auth.CreateServer(commands, queries, authRepo, config.SystemDefaults, keys.User, config.ExternalSecure, config.AuditLogRetention)); err != nil {
return err return err
} }
if err := apis.RegisterService(ctx, user.CreateServer(commands, queries)); err != nil { if err := apis.RegisterService(ctx, user.CreateServer(commands, queries, keys.User)); err != nil {
return err return err
} }
if err := apis.RegisterService(ctx, session.CreateServer(commands, queries)); err != nil { if err := apis.RegisterService(ctx, session.CreateServer(commands, queries)); err != nil {

View File

@ -14,11 +14,11 @@ const (
authenticated = "authenticated" authenticated = "authenticated"
) )
func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) { func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgIDHeader string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) {
ctx, span := tracing.NewServerInterceptorSpan(ctx) ctx, span := tracing.NewServerInterceptorSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgID, verifier, method) ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgIDHeader, verifier, method)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -29,7 +29,7 @@ func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID s
}, nil }, nil
} }
requestedPermissions, allPermissions, err := getUserMethodPermissions(ctx, verifier, requiredAuthOption.Permission, authConfig, ctxData) requestedPermissions, allPermissions, err := getUserPermissions(ctx, verifier, requiredAuthOption.Permission, authConfig.RolePermissionMappings, ctxData, ctxData.OrgID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -110,18 +110,6 @@ func HasGlobalPermission(perms []string) bool {
return false return false
} }
func HasGlobalExplicitPermission(perms []string, permToCheck string) bool {
for _, perm := range perms {
p, ctxID := SplitPermission(perm)
if p == permToCheck {
if ctxID == "" {
return true
}
}
}
return false
}
func GetAllPermissionCtxIDs(perms []string) []string { func GetAllPermissionCtxIDs(perms []string) []string {
ctxIDs := make([]string, 0) ctxIDs := make([]string, 0)
for _, perm := range perms { for _, perm := range perms {
@ -132,16 +120,3 @@ func GetAllPermissionCtxIDs(perms []string) []string {
} }
return ctxIDs return ctxIDs
} }
func GetExplicitPermissionCtxIDs(perms []string, searchPerm string) []string {
ctxIDs := make([]string, 0)
for _, perm := range perms {
p, ctxID := SplitPermission(perm)
if p == searchPerm {
if ctxID != "" {
ctxIDs = append(ctxIDs, ctxID)
}
}
}
return ctxIDs
}

View File

@ -14,11 +14,11 @@ type MethodMapping map[string]Option
type Option struct { type Option struct {
Permission string Permission string
CheckParam string CheckParam string
Feature string AllowSelf bool
} }
func (a *Config) getPermissionsFromRole(role string) []string { func getPermissionsFromRole(rolePermissionMappings []RoleMapping, role string) []string {
for _, roleMap := range a.RolePermissionMappings { for _, roleMap := range rolePermissionMappings {
if roleMap.Role == role { if roleMap.Role == role {
return roleMap.Permissions return roleMap.Permissions
} }

View File

@ -7,7 +7,28 @@ import (
"github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/telemetry/tracing"
) )
func getUserMethodPermissions(ctx context.Context, t *TokenVerifier, requiredPerm string, authConfig Config, ctxData CtxData) (requestedPermissions, allPermissions []string, err error) { func CheckPermission(ctx context.Context, resolver MembershipsResolver, roleMappings []RoleMapping, permission, orgID, resourceID string, allowSelf bool) (err error) {
ctxData := GetCtxData(ctx)
if allowSelf && ctxData.UserID == resourceID {
return nil
}
requestedPermissions, _, err := getUserPermissions(ctx, resolver, permission, roleMappings, ctxData, orgID)
if err != nil {
return err
}
_, userPermissionSpan := tracing.NewNamedSpan(ctx, "checkUserPermissions")
err = checkUserResourcePermissions(requestedPermissions, resourceID)
userPermissionSpan.EndWithError(err)
if err != nil {
return err
}
return nil
}
// getUserPermissions retrieves the memberships of the authenticated user (on instance and provided organisation level),
// and maps them to permissions. It will return the requested permission(s) and all other granted permissions separately.
func getUserPermissions(ctx context.Context, resolver MembershipsResolver, requiredPerm string, roleMappings []RoleMapping, ctxData CtxData, orgID string) (requestedPermissions, allPermissions []string, err error) {
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
@ -16,13 +37,13 @@ func getUserMethodPermissions(ctx context.Context, t *TokenVerifier, requiredPer
} }
ctx = context.WithValue(ctx, dataKey, ctxData) ctx = context.WithValue(ctx, dataKey, ctxData)
memberships, err := t.SearchMyMemberships(ctx) memberships, err := resolver.SearchMyMemberships(ctx, orgID)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
if len(memberships) == 0 { if len(memberships) == 0 {
err = retry(func() error { err = retry(func() error {
memberships, err = t.SearchMyMemberships(ctx) memberships, err = resolver.SearchMyMemberships(ctx, orgID)
if err != nil { if err != nil {
return err return err
} }
@ -35,24 +56,56 @@ func getUserMethodPermissions(ctx context.Context, t *TokenVerifier, requiredPer
return nil, nil, nil return nil, nil, nil
} }
} }
requestedPermissions, allPermissions = mapMembershipsToPermissions(requiredPerm, memberships, authConfig) requestedPermissions, allPermissions = mapMembershipsToPermissions(requiredPerm, memberships, roleMappings)
return requestedPermissions, allPermissions, nil return requestedPermissions, allPermissions, nil
} }
func mapMembershipsToPermissions(requiredPerm string, memberships []*Membership, authConfig Config) (requestPermissions, allPermissions []string) { // checkUserResourcePermissions checks that if a user i granted either the requested permission globally (project.write)
// or the specific resource (project.write:123)
func checkUserResourcePermissions(userPerms []string, resourceID string) error {
if len(userPerms) == 0 {
return errors.ThrowPermissionDenied(nil, "AUTH-AWfge", "No matching permissions found")
}
if resourceID == "" {
return nil
}
if HasGlobalPermission(userPerms) {
return nil
}
if hasContextResourcePermission(userPerms, resourceID) {
return nil
}
return errors.ThrowPermissionDenied(nil, "AUTH-Swrgg2", "No matching permissions found")
}
func hasContextResourcePermission(permissions []string, resourceID string) bool {
for _, perm := range permissions {
_, ctxID := SplitPermission(perm)
if resourceID == ctxID {
return true
}
}
return false
}
func mapMembershipsToPermissions(requiredPerm string, memberships []*Membership, roleMappings []RoleMapping) (requestPermissions, allPermissions []string) {
requestPermissions = make([]string, 0) requestPermissions = make([]string, 0)
allPermissions = make([]string, 0) allPermissions = make([]string, 0)
for _, membership := range memberships { for _, membership := range memberships {
requestPermissions, allPermissions = mapMembershipToPerm(requiredPerm, membership, authConfig, requestPermissions, allPermissions) requestPermissions, allPermissions = mapMembershipToPerm(requiredPerm, membership, roleMappings, requestPermissions, allPermissions)
} }
return requestPermissions, allPermissions return requestPermissions, allPermissions
} }
func mapMembershipToPerm(requiredPerm string, membership *Membership, authConfig Config, requestPermissions, allPermissions []string) ([]string, []string) { func mapMembershipToPerm(requiredPerm string, membership *Membership, roleMappings []RoleMapping, requestPermissions, allPermissions []string) ([]string, []string) {
roleNames, roleContextID := roleWithContext(membership) roleNames, roleContextID := roleWithContext(membership)
for _, roleName := range roleNames { for _, roleName := range roleNames {
perms := authConfig.getPermissionsFromRole(roleName) perms := getPermissionsFromRole(roleMappings, roleName)
for _, p := range perms { for _, p := range perms {
permWithCtx := addRoleContextIDToPerm(p, roleContextID) permWithCtx := addRoleContextIDToPerm(p, roleContextID)

View File

@ -18,7 +18,7 @@ type testVerifier struct {
func (v *testVerifier) VerifyAccessToken(ctx context.Context, token, clientID, projectID string) (string, string, string, string, string, error) { func (v *testVerifier) VerifyAccessToken(ctx context.Context, token, clientID, projectID string) (string, string, string, string, string, error) {
return "userID", "agentID", "clientID", "de", "orgID", nil return "userID", "agentID", "clientID", "de", "orgID", nil
} }
func (v *testVerifier) SearchMyMemberships(ctx context.Context) ([]*Membership, error) { func (v *testVerifier) SearchMyMemberships(ctx context.Context, orgID string) ([]*Membership, error) {
return v.memberships, nil return v.memberships, nil
} }
@ -46,7 +46,7 @@ func equalStringArray(a, b []string) bool {
return true return true
} }
func Test_GetUserMethodPermissions(t *testing.T) { func Test_GetUserPermissions(t *testing.T) {
type args struct { type args struct {
ctxData CtxData ctxData CtxData
verifier *TokenVerifier verifier *TokenVerifier
@ -139,7 +139,7 @@ func Test_GetUserMethodPermissions(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
_, perms, err := getUserMethodPermissions(context.Background(), tt.args.verifier, tt.args.requiredPerm, tt.args.authConfig, tt.args.ctxData) _, perms, err := getUserPermissions(context.Background(), tt.args.verifier, tt.args.requiredPerm, tt.args.authConfig.RolePermissionMappings, tt.args.ctxData, tt.args.ctxData.OrgID)
if tt.wantErr && err == nil { if tt.wantErr && err == nil {
t.Errorf("got wrong result, should get err: actual: %v ", err) t.Errorf("got wrong result, should get err: actual: %v ", err)
@ -295,7 +295,7 @@ func Test_MapMembershipToPermissions(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
requestPerms, allPerms := mapMembershipsToPermissions(tt.args.requiredPerm, tt.args.membership, tt.args.authConfig) requestPerms, allPerms := mapMembershipsToPermissions(tt.args.requiredPerm, tt.args.membership, tt.args.authConfig.RolePermissionMappings)
if !equalStringArray(requestPerms, tt.requestPerms) { if !equalStringArray(requestPerms, tt.requestPerms) {
t.Errorf("got wrong requestPerms, expecting: %v, actual: %v ", tt.requestPerms, requestPerms) t.Errorf("got wrong requestPerms, expecting: %v, actual: %v ", tt.requestPerms, requestPerms)
} }
@ -435,7 +435,7 @@ func Test_MapMembershipToPerm(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
requestPerms, allPerms := mapMembershipToPerm(tt.args.requiredPerm, tt.args.membership, tt.args.authConfig, tt.args.requestPerms, tt.args.allPerms) requestPerms, allPerms := mapMembershipToPerm(tt.args.requiredPerm, tt.args.membership, tt.args.authConfig.RolePermissionMappings, tt.args.requestPerms, tt.args.allPerms)
if !equalStringArray(requestPerms, tt.requestPerms) { if !equalStringArray(requestPerms, tt.requestPerms) {
t.Errorf("got wrong requestPerms, expecting: %v, actual: %v ", tt.requestPerms, requestPerms) t.Errorf("got wrong requestPerms, expecting: %v, actual: %v ", tt.requestPerms, requestPerms)
} }
@ -519,3 +519,109 @@ func Test_ExistisPerm(t *testing.T) {
}) })
} }
} }
func Test_CheckUserResourcePermissions(t *testing.T) {
type args struct {
perms []string
resourceID string
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "no permissions",
args: args{
perms: []string{},
resourceID: "",
},
wantErr: true,
},
{
name: "has permission and no context requested",
args: args{
perms: []string{"project.read"},
resourceID: "",
},
wantErr: false,
},
{
name: "context requested and has global permission",
args: args{
perms: []string{"project.read", "project.read:1"},
resourceID: "Test",
},
wantErr: false,
},
{
name: "context requested and has specific permission",
args: args{
perms: []string{"project.read:Test"},
resourceID: "Test",
},
wantErr: false,
},
{
name: "context requested and has no permission",
args: args{
perms: []string{"project.read:Test"},
resourceID: "Hodor",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := checkUserResourcePermissions(tt.args.perms, tt.args.resourceID)
if tt.wantErr && err == nil {
t.Errorf("got wrong result, should get err: actual: %v ", err)
}
if !tt.wantErr && err != nil {
t.Errorf("shouldn't get err: %v ", err)
}
if tt.wantErr && !caos_errs.IsPermissionDenied(err) {
t.Errorf("got wrong err: %v ", err)
}
})
}
}
func Test_HasContextResourcePermission(t *testing.T) {
type args struct {
perms []string
resourceID string
}
tests := []struct {
name string
args args
result bool
}{
{
name: "existing context permission",
args: args{
perms: []string{"test:wrong", "test:right"},
resourceID: "right",
},
result: true,
},
{
name: "not existing context permission",
args: args{
perms: []string{"test:wrong", "test:wrong2"},
resourceID: "test",
},
result: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := hasContextResourcePermission(tt.args.perms, tt.args.resourceID)
if result != tt.result {
t.Errorf("got wrong result, expecting: %v, actual: %v ", tt.result, result)
}
})
}
}

View File

@ -27,10 +27,14 @@ type TokenVerifier struct {
systemJWTProfile op.JWTProfileVerifier systemJWTProfile op.JWTProfileVerifier
} }
type MembershipsResolver interface {
SearchMyMemberships(ctx context.Context, orgID string) ([]*Membership, error)
}
type authZRepo interface { type authZRepo interface {
VerifyAccessToken(ctx context.Context, token, verifierClientID, projectID string) (userID, agentID, clientID, prefLang, resourceOwner string, err error) VerifyAccessToken(ctx context.Context, token, verifierClientID, projectID string) (userID, agentID, clientID, prefLang, resourceOwner string, err error)
VerifierClientID(ctx context.Context, name string) (clientID, projectID string, err error) VerifierClientID(ctx context.Context, name string) (clientID, projectID string, err error)
SearchMyMemberships(ctx context.Context) ([]*Membership, error) SearchMyMemberships(ctx context.Context, orgID string) ([]*Membership, error)
ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error)
ExistsOrg(ctx context.Context, orgID string) error ExistsOrg(ctx context.Context, orgID string) error
} }
@ -127,10 +131,10 @@ func (v *TokenVerifier) RegisterServer(appName, methodPrefix string, mappings Me
} }
} }
func (v *TokenVerifier) SearchMyMemberships(ctx context.Context) (_ []*Membership, err error) { func (v *TokenVerifier) SearchMyMemberships(ctx context.Context, orgID string) (_ []*Membership, err error) {
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
return v.authZRepo.SearchMyMemberships(ctx) return v.authZRepo.SearchMyMemberships(ctx, orgID)
} }
func (v *TokenVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (_ string, _ []string, err error) { func (v *TokenVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (_ string, _ []string, err error) {

View File

@ -71,7 +71,7 @@ func (s *Server) AppName() string {
} }
func (s *Server) MethodPrefix() string { func (s *Server) MethodPrefix() string {
return admin.AdminService_MethodPrefix return admin.AdminService_ServiceDesc.ServiceName
} }
func (s *Server) AuthMethods() authz.MethodMapping { func (s *Server) AuthMethods() authz.MethodMapping {

View File

@ -69,7 +69,7 @@ func (s *Server) AppName() string {
} }
func (s *Server) MethodPrefix() string { func (s *Server) MethodPrefix() string {
return auth.AuthService_MethodPrefix return auth.AuthService_ServiceDesc.ServiceName
} }
func (s *Server) AuthMethods() authz.MethodMapping { func (s *Server) AuthMethods() authz.MethodMapping {

View File

@ -63,7 +63,7 @@ func (s *Server) AppName() string {
} }
func (s *Server) MethodPrefix() string { func (s *Server) MethodPrefix() string {
return management.ManagementService_MethodPrefix return management.ManagementService_ServiceDesc.ServiceName
} }
func (s *Server) AuthMethods() authz.MethodMapping { func (s *Server) AuthMethods() authz.MethodMapping {

View File

@ -0,0 +1,19 @@
package object
import (
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/domain"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
)
func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details {
details := &object.Details{
Sequence: objectDetail.Sequence,
ResourceOwner: objectDetail.ResourceOwner,
}
if !objectDetail.EventDate.IsZero() {
details.ChangeDate = timestamppb.New(objectDetail.EventDate)
}
return details
}

View File

@ -24,7 +24,7 @@ type verifierMock struct{}
func (v *verifierMock) VerifyAccessToken(ctx context.Context, token, clientID, projectID string) (string, string, string, string, string, error) { func (v *verifierMock) VerifyAccessToken(ctx context.Context, token, clientID, projectID string) (string, string, string, string, string, error) {
return "", "", "", "", "", nil return "", "", "", "", "", nil
} }
func (v *verifierMock) SearchMyMemberships(ctx context.Context) ([]*authz.Membership, error) { func (v *verifierMock) SearchMyMemberships(ctx context.Context, orgID string) ([]*authz.Membership, error) {
return nil, nil return nil, nil
} }

View File

@ -50,13 +50,13 @@ func CreateServer(
middleware.MetricsHandler(metricTypes, grpc_api.Probes...), middleware.MetricsHandler(metricTypes, grpc_api.Probes...),
middleware.NoCacheInterceptor(), middleware.NoCacheInterceptor(),
middleware.ErrorHandler(), middleware.ErrorHandler(),
middleware.InstanceInterceptor(queries, hostHeaderName, system_pb.SystemService_MethodPrefix, healthpb.Health_ServiceDesc.ServiceName), middleware.InstanceInterceptor(queries, hostHeaderName, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName),
middleware.AccessStorageInterceptor(accessSvc), middleware.AccessStorageInterceptor(accessSvc),
middleware.AuthorizationInterceptor(verifier, authConfig), middleware.AuthorizationInterceptor(verifier, authConfig),
middleware.TranslationHandler(), middleware.TranslationHandler(),
middleware.ValidationHandler(), middleware.ValidationHandler(),
middleware.ServiceHandler(), middleware.ServiceHandler(),
middleware.QuotaExhaustedInterceptor(accessSvc, system_pb.SystemService_MethodPrefix), middleware.QuotaExhaustedInterceptor(accessSvc, system_pb.SystemService_ServiceDesc.ServiceName),
), ),
), ),
} }

View File

@ -4,7 +4,6 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"github.com/zitadel/zitadel/internal/admin/repository" "github.com/zitadel/zitadel/internal/admin/repository"
"github.com/zitadel/zitadel/internal/admin/repository/eventsourcing" "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/api/grpc/server"
@ -60,7 +59,7 @@ func (s *Server) AppName() string {
} }
func (s *Server) MethodPrefix() string { func (s *Server) MethodPrefix() string {
return system.SystemService_MethodPrefix return system.SystemService_ServiceDesc.ServiceName
} }
func (s *Server) AuthMethods() authz.MethodMapping { func (s *Server) AuthMethods() authz.MethodMapping {

View File

@ -0,0 +1,65 @@
package user
import (
"context"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) {
resourceOwner := "" // TODO: check if still needed
var email *domain.Email
switch v := req.GetVerification().(type) {
case *user.SetEmailRequest_SendCode:
email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate())
case *user.SetEmailRequest_ReturnCode:
email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg)
case *user.SetEmailRequest_IsVerified:
if v.IsVerified {
email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), resourceOwner, req.GetEmail())
} else {
email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg)
}
case nil:
email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg)
default:
err = caos_errs.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetEmail not implemented", v)
}
if err != nil {
return nil, err
}
return &user.SetEmailResponse{
Details: &object.Details{
Sequence: email.Sequence,
ChangeDate: timestamppb.New(email.ChangeDate),
ResourceOwner: email.ResourceOwner,
},
VerificationCode: email.PlainCode,
}, nil
}
func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) {
details, err := s.command.VerifyUserEmail(ctx,
req.GetUserId(),
"", // TODO: check if still needed
req.GetVerificationCode(),
s.userCodeAlg,
)
if err != nil {
return nil, err
}
return &user.VerifyEmailResponse{
Details: &object.Details{
Sequence: details.Sequence,
ChangeDate: timestamppb.New(details.EventDate),
ResourceOwner: details.ResourceOwner,
},
}, nil
}

View File

@ -6,8 +6,9 @@ import (
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
) )
var _ user.UserServiceServer = (*Server)(nil) var _ user.UserServiceServer = (*Server)(nil)
@ -16,17 +17,16 @@ type Server struct {
user.UnimplementedUserServiceServer user.UnimplementedUserServiceServer
command *command.Commands command *command.Commands
query *query.Queries query *query.Queries
userCodeAlg crypto.EncryptionAlgorithm
} }
type Config struct{} type Config struct{}
func CreateServer( func CreateServer(command *command.Commands, query *query.Queries, userCodeAlg crypto.EncryptionAlgorithm) *Server {
command *command.Commands,
query *query.Queries,
) *Server {
return &Server{ return &Server{
command: command, command: command,
query: query, query: query,
userCodeAlg: userCodeAlg,
} }
} }

View File

@ -1,55 +0,0 @@
package user
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func (s *Server) TestGet(ctx context.Context, req *user.TestGetRequest) (*user.TestGetResponse, error) {
return &user.TestGetResponse{
Ctx: req.Ctx.String(),
}, nil
}
func (s *Server) TestPost(ctx context.Context, req *user.TestPostRequest) (*user.TestPostResponse, error) {
return &user.TestPostResponse{
Ctx: req.Ctx.String(),
}, nil
}
func (s *Server) TestAuth(ctx context.Context, req *user.TestAuthRequest) (*user.TestAuthResponse, error) {
reqCtx, err := authDemo(ctx, req.Ctx)
if err != nil {
return nil, err
}
return &user.TestAuthResponse{
User: &user.User{Id: authz.GetCtxData(ctx).UserID},
Ctx: reqCtx,
}, nil
}
func authDemo(ctx context.Context, reqCtx *user.Context) (*user.Context, error) {
ro := authz.GetCtxData(ctx).ResourceOwner
if reqCtx == nil {
return &user.Context{Ctx: &user.Context_OrgId{OrgId: ro}}, nil
}
switch c := reqCtx.Ctx.(type) {
case *user.Context_OrgId:
if c.OrgId == ro {
return reqCtx, nil
}
return nil, errors.ThrowPermissionDenied(nil, "USER-dg4g", "Errors.User.NotAllowedOrg")
case *user.Context_OrgDomain:
if c.OrgDomain == "forbidden.com" {
return nil, errors.ThrowPermissionDenied(nil, "USER-SDg4g", "Errors.User.NotAllowedOrg")
}
return reqCtx, nil
case *user.Context_Instance:
return reqCtx, nil
default:
return reqCtx, nil
}
}

View File

@ -12,17 +12,17 @@ type UserMembershipRepo struct {
Queries *query.Queries Queries *query.Queries
} }
func (repo *UserMembershipRepo) SearchMyMemberships(ctx context.Context) (_ []*authz.Membership, err error) { func (repo *UserMembershipRepo) SearchMyMemberships(ctx context.Context, orgID string) (_ []*authz.Membership, err error) {
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
memberships, err := repo.searchUserMemberships(ctx) memberships, err := repo.searchUserMemberships(ctx, orgID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return userMembershipsToMemberships(memberships), nil return userMembershipsToMemberships(memberships), nil
} }
func (repo *UserMembershipRepo) searchUserMemberships(ctx context.Context) (_ []*query.Membership, err error) { func (repo *UserMembershipRepo) searchUserMemberships(ctx context.Context, orgID string) (_ []*query.Membership, err error) {
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
ctxData := authz.GetCtxData(ctx) ctxData := authz.GetCtxData(ctx)
@ -30,11 +30,11 @@ func (repo *UserMembershipRepo) searchUserMemberships(ctx context.Context) (_ []
if err != nil { if err != nil {
return nil, err return nil, err
} }
orgIDsQuery, err := query.NewMembershipResourceOwnersSearchQuery(ctxData.OrgID, authz.GetInstance(ctx).InstanceID()) orgIDsQuery, err := query.NewMembershipResourceOwnersSearchQuery(orgID, authz.GetInstance(ctx).InstanceID())
if err != nil { if err != nil {
return nil, err return nil, err
} }
grantedIDQuery, err := query.NewMembershipGrantedOrgIDSearchQuery(ctxData.OrgID) grantedIDQuery, err := query.NewMembershipGrantedOrgIDSearchQuery(orgID)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -7,5 +7,5 @@ import (
) )
type UserMembershipRepository interface { type UserMembershipRepository interface {
SearchMyMemberships(ctx context.Context) ([]*authz.Membership, error) SearchMyMemberships(ctx context.Context, orgID string) ([]*authz.Membership, error)
} }

View File

@ -29,6 +29,8 @@ import (
type Commands struct { type Commands struct {
httpClient *http.Client httpClient *http.Client
checkPermission permissionCheck
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
static static.Storage static static.Storage
idGenerator id.Generator idGenerator id.Generator
@ -59,7 +61,8 @@ type Commands struct {
certificateLifetime time.Duration certificateLifetime time.Duration
} }
func StartCommands(es *eventstore.Eventstore, func StartCommands(
es *eventstore.Eventstore,
defaults sd.SystemDefaults, defaults sd.SystemDefaults,
zitadelRoles []authz.RoleMapping, zitadelRoles []authz.RoleMapping,
staticStore static.Storage, staticStore static.Storage,
@ -76,6 +79,7 @@ func StartCommands(es *eventstore.Eventstore,
oidcEncryption, oidcEncryption,
samlEncryption crypto.EncryptionAlgorithm, samlEncryption crypto.EncryptionAlgorithm,
httpClient *http.Client, httpClient *http.Client,
membershipsResolver authz.MembershipsResolver,
) (repo *Commands, err error) { ) (repo *Commands, err error) {
if externalDomain == "" { if externalDomain == "" {
return nil, errors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified") return nil, errors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified")
@ -102,6 +106,9 @@ func StartCommands(es *eventstore.Eventstore,
certificateAlgorithm: samlEncryption, certificateAlgorithm: samlEncryption,
webauthnConfig: webAuthN, webauthnConfig: webAuthN,
httpClient: httpClient, httpClient: httpClient,
checkPermission: func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) {
return authz.CheckPermission(ctx, membershipsResolver, zitadelRoles, permission, orgID, resourceID, allowSelf)
},
} }
instance_repo.RegisterEventMappers(repo.eventstore) instance_repo.RegisterEventMappers(repo.eventstore)

View File

@ -10,6 +10,7 @@ import (
"golang.org/x/text/language" "golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository" "github.com/zitadel/zitadel/internal/eventstore/repository"
"github.com/zitadel/zitadel/internal/eventstore/repository/mock" "github.com/zitadel/zitadel/internal/eventstore/repository/mock"
@ -248,3 +249,15 @@ func (m *mockInstance) RequestedHost() string {
func (m *mockInstance) SecurityPolicyAllowedOrigins() []string { func (m *mockInstance) SecurityPolicyAllowedOrigins() []string {
return nil return nil
} }
func newMockPermissionCheckAllowed() permissionCheck {
return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) {
return nil
}
}
func newMockPermissionCheckNotAllowed() permissionCheck {
return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) {
return errors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")
}
}

View File

@ -0,0 +1,11 @@
package command
import (
"context"
)
type permissionCheck func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error)
const (
permissionUserWrite = "user.write"
)

View File

@ -507,7 +507,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.
if human.Email != nil && human.EmailAddress != "" && human.IsEmailVerified { if human.Email != nil && human.EmailAddress != "" && human.IsEmailVerified {
events = append(events, user.NewHumanEmailVerifiedEvent(ctx, userAgg)) events = append(events, user.NewHumanEmailVerifiedEvent(ctx, userAgg))
} else { } else {
emailCode, err := domain.NewEmailCode(emailCodeGenerator) emailCode, _, err := domain.NewEmailCode(emailCodeGenerator)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"github.com/zitadel/logging" "github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors" caos_errs "github.com/zitadel/zitadel/internal/errors"
@ -41,7 +42,7 @@ func (c *Commands) ChangeHumanEmail(ctx context.Context, email *domain.Email, em
if email.IsEmailVerified { if email.IsEmailVerified {
events = append(events, user.NewHumanEmailVerifiedEvent(ctx, userAgg)) events = append(events, user.NewHumanEmailVerifiedEvent(ctx, userAgg))
} else { } else {
emailCode, err := domain.NewEmailCode(emailCodeGenerator) emailCode, _, err := domain.NewEmailCode(emailCodeGenerator)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -113,7 +114,7 @@ func (c *Commands) CreateHumanEmailVerificationCode(ctx context.Context, userID,
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3M9ds", "Errors.User.Email.AlreadyVerified") return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3M9ds", "Errors.User.Email.AlreadyVerified")
} }
userAgg := UserAggregateFromWriteModel(&existingEmail.WriteModel) userAgg := UserAggregateFromWriteModel(&existingEmail.WriteModel)
emailCode, err := domain.NewEmailCode(emailCodeGenerator) emailCode, _, err := domain.NewEmailCode(emailCodeGenerator)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -4,10 +4,9 @@ import (
"context" "context"
"time" "time"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/repository/user"
) )

View File

@ -0,0 +1,208 @@
package command
import (
"context"
"io"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
)
// ChangeUserEmail sets a user's email address, generates a code
// and triggers a notification e-mail with the default confirmation URL format.
func (c *Commands) ChangeUserEmail(ctx context.Context, userID, resourceOwner, email string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.changeUserEmailWithCode(ctx, userID, resourceOwner, email, alg, false, "")
}
// ChangeUserEmailURLTemplate sets a user's email address, generates a code
// and triggers a notification e-mail with the confirmation URL rendered from the passed urlTmpl.
// urlTmpl must be a valid [tmpl.Template].
func (c *Commands) ChangeUserEmailURLTemplate(ctx context.Context, userID, resourceOwner, email string, alg crypto.EncryptionAlgorithm, urlTmpl string) (*domain.Email, error) {
if err := domain.RenderConfirmURLTemplate(io.Discard, urlTmpl, userID, "code", "orgID"); err != nil {
return nil, err
}
return c.changeUserEmailWithCode(ctx, userID, resourceOwner, email, alg, false, urlTmpl)
}
// ChangeUserEmailReturnCode sets a user's email address, generates a code and does not send a notification email.
// The generated plain text code will be set in the returned Email object.
func (c *Commands) ChangeUserEmailReturnCode(ctx context.Context, userID, resourceOwner, email string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.changeUserEmailWithCode(ctx, userID, resourceOwner, email, alg, true, "")
}
// ChangeUserEmailVerified sets a user's email address and marks it is verified.
// No code is generated and no confirmation e-mail is send.
func (c *Commands) ChangeUserEmailVerified(ctx context.Context, userID, resourceOwner, email string) (*domain.Email, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, false); err != nil {
return nil, err
}
if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil {
return nil, err
}
cmd.SetVerified(ctx)
return cmd.Push(ctx)
}
func (c *Commands) changeUserEmailWithCode(ctx context.Context, userID, resourceOwner, email string, alg crypto.EncryptionAlgorithm, returnCode bool, urlTmpl string) (*domain.Email, error) {
config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode)
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.changeUserEmailWithGenerator(ctx, userID, resourceOwner, email, gen, returnCode, urlTmpl)
}
// changeUserEmailWithGenerator set a user's email address.
// returnCode controls if the plain text version of the code will be set in the return object.
// When the plain text code is returned, no notification e-mail will be send to the user.
// urlTmpl allows changing the target URL that is used by the e-mail and should be a validated Go template, if used.
func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, resourceOwner, email string, gen crypto.Generator, returnCode bool, urlTmpl string) (*domain.Email, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, true); err != nil {
return nil, err
}
if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil {
return nil, err
}
if err = cmd.AddGeneratedCode(ctx, gen, urlTmpl, returnCode); err != nil {
return nil, err
}
return cmd.Push(ctx)
}
func (c *Commands) VerifyUserEmail(ctx context.Context, userID, resourceOwner, code string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) {
config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode)
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.verifyUserEmailWithGenerator(ctx, userID, resourceOwner, code, gen)
}
func (c *Commands) verifyUserEmailWithGenerator(ctx context.Context, userID, resourceOwner, code string, gen crypto.Generator) (*domain.ObjectDetails, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
err = cmd.VerifyCode(ctx, code, gen)
if err != nil {
return nil, err
}
if _, err = cmd.Push(ctx); err != nil {
return nil, err
}
return writeModelToObjectDetails(&cmd.model.WriteModel), nil
}
// UserEmailEvents allows step-by-step additions of events,
// operating on the Human Email Model.
type UserEmailEvents struct {
eventstore *eventstore.Eventstore
aggregate *eventstore.Aggregate
events []eventstore.Command
model *HumanEmailWriteModel
plainCode *string
}
// NewUserEmailEvents constructs a UserEmailEvents with a Human Email Write Model,
// filtered by userID and resourceOwner.
// If a model cannot be found, or it's state is invalid and error is returned.
func (c *Commands) NewUserEmailEvents(ctx context.Context, userID, resourceOwner string) (*UserEmailEvents, error) {
if userID == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-0Gzs3", "Errors.User.Email.IDMissing")
}
model, err := c.emailWriteModel(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if model.UserState == domain.UserStateUnspecified || model.UserState == domain.UserStateDeleted {
return nil, caos_errs.ThrowNotFound(nil, "COMMAND-ieJ2e", "Errors.User.Email.NotFound")
}
if model.UserState == domain.UserStateInitial {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-uz0Uu", "Errors.User.NotInitialised")
}
return &UserEmailEvents{
eventstore: c.eventstore,
aggregate: UserAggregateFromWriteModel(&model.WriteModel),
model: model,
}, nil
}
// Change sets a new email address.
// The generated event unsets any previously generated code and verified flag.
func (c *UserEmailEvents) Change(ctx context.Context, email domain.EmailAddress) error {
if err := email.Validate(); err != nil {
return err
}
event, hasChanged := c.model.NewChangedEvent(ctx, c.aggregate, email)
if !hasChanged {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Uch5e", "Errors.User.Email.NotChanged")
}
c.events = append(c.events, event)
return nil
}
// SetVerified sets the email address to verified.
func (c *UserEmailEvents) SetVerified(ctx context.Context) {
c.events = append(c.events, user.NewHumanEmailVerifiedEvent(ctx, c.aggregate))
}
// AddGeneratedCode generates a new encrypted code and sets it to the email address.
// When returnCode a plain text of the code will be returned from Push.
func (c *UserEmailEvents) AddGeneratedCode(ctx context.Context, gen crypto.Generator, urlTmpl string, returnCode bool) error {
value, plain, err := crypto.NewCode(gen)
if err != nil {
return err
}
c.events = append(c.events, user.NewHumanEmailCodeAddedEventV2(ctx, c.aggregate, value, gen.Expiry(), urlTmpl, returnCode))
if returnCode {
c.plainCode = &plain
}
return nil
}
func (c *UserEmailEvents) VerifyCode(ctx context.Context, code string, gen crypto.Generator) error {
if code == "" {
return caos_errs.ThrowInvalidArgument(nil, "COMMAND-Fia4a", "Errors.User.Code.Empty")
}
err := crypto.VerifyCode(c.model.CodeCreationDate, c.model.CodeExpiry, c.model.Code, code, gen)
if err == nil {
c.events = append(c.events, user.NewHumanEmailVerifiedEvent(ctx, c.aggregate))
return nil
}
_, err = c.eventstore.Push(ctx, user.NewHumanEmailVerificationFailedEvent(ctx, c.aggregate))
logging.WithFields("id", "COMMAND-Zoo6b", "userID", c.aggregate.ID).OnError(err).Error("NewHumanEmailVerificationFailedEvent push failed")
return caos_errs.ThrowInvalidArgument(err, "COMMAND-eis9R", "Errors.User.Code.Invalid")
}
// Push all events to the eventstore and Reduce them into the Model.
func (c *UserEmailEvents) Push(ctx context.Context) (*domain.Email, error) {
pushedEvents, err := c.eventstore.Push(ctx, c.events...)
if err != nil {
return nil, err
}
err = AppendAndReduce(c.model, pushedEvents...)
if err != nil {
return nil, err
}
email := writeModelToEmail(c.model)
email.PlainCode = c.plainCode
return email, nil
}

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,15 @@
package domain package domain
import ( import (
"io"
"regexp" "regexp"
"strings" "strings"
"text/template"
"time" "time"
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/errors"
caos_errs "github.com/zitadel/zitadel/internal/errors"
es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models" es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models"
) )
@ -35,6 +38,8 @@ type Email struct {
EmailAddress EmailAddress EmailAddress EmailAddress
IsEmailVerified bool IsEmailVerified bool
// PlainCode is set by the command and can be used to return it to the caller (API)
PlainCode *string
} }
type EmailCode struct { type EmailCode struct {
@ -51,13 +56,36 @@ func (e *Email) Validate() error {
return e.EmailAddress.Validate() return e.EmailAddress.Validate()
} }
func NewEmailCode(emailGenerator crypto.Generator) (*EmailCode, error) { func NewEmailCode(emailGenerator crypto.Generator) (*EmailCode, string, error) {
emailCodeCrypto, _, err := crypto.NewCode(emailGenerator) emailCodeCrypto, code, err := crypto.NewCode(emailGenerator)
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
return &EmailCode{ return &EmailCode{
Code: emailCodeCrypto, Code: emailCodeCrypto,
Expiry: emailGenerator.Expiry(), Expiry: emailGenerator.Expiry(),
}, nil }, code, nil
}
type ConfirmURLData struct {
UserID string
Code string
OrgID string
}
// RenderConfirmURLTemplate parses and renders tmplStr.
// userID, code and orgID are passed into the [ConfirmURLData].
// "%s%s?userID=%s&code=%s&orgID=%s"
func RenderConfirmURLTemplate(w io.Writer, tmplStr, userID, code, orgID string) error {
tmpl, err := template.New("").Parse(tmplStr)
if err != nil {
return caos_errs.ThrowInvalidArgument(err, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate")
}
data := &ConfirmURLData{userID, code, orgID}
if err = tmpl.Execute(w, data); err != nil {
return caos_errs.ThrowInvalidArgument(err, "USERv2-ohSi5", "Errors.User.Email.InvalidURLTemplate")
}
return nil
} }

View File

@ -1,7 +1,13 @@
package domain package domain
import ( import (
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
caos_errs "github.com/zitadel/zitadel/internal/errors"
) )
func TestEmailValid(t *testing.T) { func TestEmailValid(t *testing.T) {
@ -72,3 +78,57 @@ func TestEmailValid(t *testing.T) {
}) })
} }
} }
func TestRenderConfirmURLTemplate(t *testing.T) {
type args struct {
tmplStr string
userID string
code string
orgID string
}
tests := []struct {
name string
args args
want string
wantErr error
}{
{
name: "invalid template",
args: args{
tmplStr: "{{",
userID: "user1",
code: "123",
orgID: "org1",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate"),
},
{
name: "execution error",
args: args{
tmplStr: "{{.Foo}}",
userID: "user1",
code: "123",
orgID: "org1",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ohSi5", "Errors.User.Email.InvalidURLTemplate"),
},
{
name: "success",
args: args{
tmplStr: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
userID: "user1",
code: "123",
orgID: "org1",
},
want: "https://example.com/email/verify?userID=user1&code=123&orgID=org1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var w strings.Builder
err := RenderConfirmURLTemplate(&w, tt.args.tmplStr, tt.args.userID, tt.args.code, tt.args.orgID)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, w.String())
})
}
}

View File

@ -182,6 +182,10 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St
if !ok { if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SWf3g", "reduce.wrong.event.type %s", user.HumanEmailCodeAddedType) return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SWf3g", "reduce.wrong.event.type %s", user.HumanEmailCodeAddedType)
} }
if e.CodeReturned {
return crdb.NewNoOpStatement(e), nil
}
ctx := HandlerContext(event.Aggregate()) ctx := HandlerContext(event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.UserV1EmailCodeAddedType, user.UserV1EmailCodeSentType, user.UserV1EmailCodeAddedType, user.UserV1EmailCodeSentType,
@ -232,7 +236,7 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St
e, e,
u.metricSuccessfulDeliveriesEmail, u.metricSuccessfulDeliveriesEmail,
u.metricFailedDeliveriesEmail, u.metricFailedDeliveriesEmail,
).SendEmailVerificationCode(notifyUser, origin, code) ).SendEmailVerificationCode(notifyUser, origin, code, e.URLTemplate)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -1,13 +1,25 @@
package types package types
import ( import (
"strings"
"github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
) )
func (notify Notify) SendEmailVerificationCode(user *query.NotifyUser, origin, code string) error { func (notify Notify) SendEmailVerificationCode(user *query.NotifyUser, origin, code string, urlTmpl string) error {
url := login.MailVerificationLink(origin, user.ID, code, user.ResourceOwner) var url string
if urlTmpl == "" {
url = login.MailVerificationLink(origin, user.ID, code, user.ResourceOwner)
} else {
var buf strings.Builder
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
return err
}
url = buf.String()
}
args := make(map[string]interface{}) args := make(map[string]interface{})
args["Code"] = code args["Code"] = code
return notify(url, args, domain.VerifyEmailMessageType, true) return notify(url, args, domain.VerifyEmailMessageType, true)

View File

@ -0,0 +1,107 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
)
func TestNotify_SendEmailVerificationCode(t *testing.T) {
type res struct {
url string
args map[string]interface{}
messageType string
allowUnverifiedNotificationChannel bool
}
notify := func(dst *res) Notify {
return func(
url string,
args map[string]interface{},
messageType string,
allowUnverifiedNotificationChannel bool,
) error {
dst.url = url
dst.args = args
dst.messageType = messageType
dst.allowUnverifiedNotificationChannel = allowUnverifiedNotificationChannel
return nil
}
}
type args struct {
user *query.NotifyUser
origin string
code string
urlTmpl string
}
tests := []struct {
name string
args args
want *res
wantErr error
}{
{
name: "default URL",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: "https://example.com",
code: "123",
urlTmpl: "",
},
want: &res{
url: "https://example.com/ui/login/mail/verification?userID=user1&code=123&orgID=org1",
args: map[string]interface{}{"Code": "123"},
messageType: domain.VerifyEmailMessageType,
allowUnverifiedNotificationChannel: true,
},
},
{
name: "template error",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: "https://example.com",
code: "123",
urlTmpl: "{{",
},
want: &res{},
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate"),
},
{
name: "template success",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: "https://example.com",
code: "123",
urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
},
want: &res{
url: "https://example.com/email/verify?userID=user1&code=123&orgID=org1",
args: map[string]interface{}{"Code": "123"},
messageType: domain.VerifyEmailMessageType,
allowUnverifiedNotificationChannel: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := new(res)
err := notify(got).SendEmailVerificationCode(tt.args.user, tt.args.origin, tt.args.code, tt.args.urlTmpl)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}

View File

@ -0,0 +1,16 @@
// Code generated by protoc-gen-auth. DO NOT EDIT.
package {{.GoPackageName}}
import (
"github.com/zitadel/zitadel/internal/api/authz"
)
var {{.ServiceName}}_AuthMethods = authz.MethodMapping {
{{ range $m := .AuthOptions}}
{{$.ServiceName}}_{{$m.Name}}_FullMethodName: authz.Option{
Permission: "{{$m.Permission}}",
CheckParam: "{{$m.CheckFieldName}}",
},
{{ end}}
}

View File

@ -0,0 +1,97 @@
package main
import (
"bytes"
_ "embed"
"fmt"
"io"
"os"
"text/template"
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/descriptorpb"
"google.golang.org/protobuf/types/pluginpb"
"github.com/zitadel/zitadel/internal/protoc/protoc-gen-authoption/authoption"
)
var (
//go:embed auth_method_mapping.go.tmpl
authTemplate []byte
)
type authMethods struct {
GoPackageName string
ProtoPackageName string
ServiceName string
AuthOptions []authOption
}
type authOption struct {
Name string
Permission string
CheckFieldName string
}
func main() {
input, _ := io.ReadAll(os.Stdin)
var req pluginpb.CodeGeneratorRequest
err := proto.Unmarshal(input, &req)
if err != nil {
panic(err)
}
opts := protogen.Options{}
plugin, err := opts.New(&req)
if err != nil {
panic(err)
}
plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)
authTemp := loadTemplate(authTemplate)
for _, file := range plugin.Files {
var buf bytes.Buffer
var methods authMethods
for _, service := range file.Services {
methods.ServiceName = service.GoName
methods.GoPackageName = string(file.GoPackageName)
methods.ProtoPackageName = *file.Proto.Package
for _, method := range service.Methods {
if options := method.Desc.Options().(*descriptorpb.MethodOptions); options != nil {
ext := proto.GetExtension(options, authoption.E_AuthOption).(*authoption.AuthOption)
if ext != nil {
methods.AuthOptions = append(methods.AuthOptions, authOption{Name: string(method.Desc.Name()), Permission: ext.Permission, CheckFieldName: ext.CheckFieldName})
}
}
}
}
if len(methods.AuthOptions) > 0 {
authTemp.Execute(&buf, &methods)
filename := file.GeneratedFilenamePrefix + ".pb.authoptions.go"
file := plugin.NewGeneratedFile(filename, ".")
file.Write(buf.Bytes())
}
}
// Generate a response from our plugin and marshall as protobuf
stdout := plugin.Response()
out, err := proto.Marshal(stdout)
if err != nil {
panic(err)
}
// Write the response to stdout, to be picked up by protoc
fmt.Fprintf(os.Stdout, string(out))
}
func loadTemplate(templateData []byte) *template.Template {
return template.Must(template.New("").
Parse(string(templateData)))
}

View File

@ -1,37 +0,0 @@
# protoc-gen-authoption
Proto options to annotate auth methods in protos
## Generate protos/templates
protos: `go generate authoption/generate.go`
templates/install: `go generate generate.go`
## Usage
```
// proto file
import "authoption/options.proto";
service MyService {
rpc Hello(Hello) returns (google.protobuf.Empty) {
option (google.api.http) = {
get: "/hello"
};
option (caos.zitadel.utils.v1.auth_option) = {
zitadel_permission: "hello.read"
zitadel_check_param: "id"
};
}
message Hello {
string id = 1;
}
}
```
Caos Auth Option is used for granting groups
On each zitadel role is specified which auth methods are allowed to call
Get protoc-get-authoption: ``go get github.com/zitadel/zitadel/internal/protoc/protoc-gen-authoption``
Protc-Flag: ``--authoption_out=.``

View File

@ -1,4 +0,0 @@
package main
//go:generate go-bindata -pkg main -o templates.gen.go templates
//go:generate go install

View File

@ -1,15 +0,0 @@
package main
import (
base "github.com/zitadel/zitadel/internal/protoc/protoc-base"
"github.com/zitadel/zitadel/internal/protoc/protoc-gen-authoption/authoption"
)
const (
fileName = "%v.pb.authoptions.go"
)
func main() {
base.RegisterExtension(authoption.E_AuthOption)
base.RunWithBaseTemplate(fileName, base.LoadTemplate(templatesAuth_method_mappingGoTmplBytes()))
}

View File

@ -1,33 +0,0 @@
// Code generated by protoc-gen-authmethod. DO NOT EDIT.
package {{.File.GoPkg.Name}}
import (
"google.golang.org/grpc"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server/middleware"
)
{{ range $s := .File.Services }}
/**
* {{$s.Name}}
*/
const {{$s.Name}}_MethodPrefix = "{{$.File.Package}}.{{$s.Name}}"
var {{$s.Name}}_AuthMethods = authz.MethodMapping {
{{ range $m := $s.Method}}
{{ $mAuthOpt := option $m.Options "zitadel.v1.auth_option" }}
{{ if and $mAuthOpt $mAuthOpt.Permission }}
"/{{$.File.Package}}.{{$s.Name}}/{{.Name}}": authz.Option{
Permission: "{{$mAuthOpt.Permission}}",
CheckParam: "{{$mAuthOpt.CheckFieldName}}",
},
{{end}}
{{ end}}
}
{{ end }}

View File

@ -19,6 +19,7 @@ const (
HumanEmailVerificationFailedType = emailEventPrefix + "verification.failed" HumanEmailVerificationFailedType = emailEventPrefix + "verification.failed"
HumanEmailCodeAddedType = emailEventPrefix + "code.added" HumanEmailCodeAddedType = emailEventPrefix + "code.added"
HumanEmailCodeSentType = emailEventPrefix + "code.sent" HumanEmailCodeSentType = emailEventPrefix + "code.sent"
HumanEmailConfirmURLAddedType = emailEventPrefix + "confirm_url.added"
) )
type HumanEmailChangedEvent struct { type HumanEmailChangedEvent struct {
@ -123,6 +124,8 @@ type HumanEmailCodeAddedEvent struct {
Code *crypto.CryptoValue `json:"code,omitempty"` Code *crypto.CryptoValue `json:"code,omitempty"`
Expiry time.Duration `json:"expiry,omitempty"` Expiry time.Duration `json:"expiry,omitempty"`
URLTemplate string `json:"url_template,omitempty"`
CodeReturned bool `json:"code_returned,omitempty"`
} }
func (e *HumanEmailCodeAddedEvent) Data() interface{} { func (e *HumanEmailCodeAddedEvent) Data() interface{} {
@ -137,7 +140,19 @@ func NewHumanEmailCodeAddedEvent(
ctx context.Context, ctx context.Context,
aggregate *eventstore.Aggregate, aggregate *eventstore.Aggregate,
code *crypto.CryptoValue, code *crypto.CryptoValue,
expiry time.Duration) *HumanEmailCodeAddedEvent { expiry time.Duration,
) *HumanEmailCodeAddedEvent {
return NewHumanEmailCodeAddedEventV2(ctx, aggregate, code, expiry, "", false)
}
func NewHumanEmailCodeAddedEventV2(
ctx context.Context,
aggregate *eventstore.Aggregate,
code *crypto.CryptoValue,
expiry time.Duration,
urlTemplate string,
codeReturned bool,
) *HumanEmailCodeAddedEvent {
return &HumanEmailCodeAddedEvent{ return &HumanEmailCodeAddedEvent{
BaseEvent: *eventstore.NewBaseEventForPush( BaseEvent: *eventstore.NewBaseEventForPush(
ctx, ctx,
@ -146,6 +161,8 @@ func NewHumanEmailCodeAddedEvent(
), ),
Code: code, Code: code,
Expiry: expiry, Expiry: expiry,
URLTemplate: urlTemplate,
CodeReturned: codeReturned,
} }
} }

View File

@ -81,6 +81,7 @@ Errors:
NotChanged: Email wurde nicht geändert NotChanged: Email wurde nicht geändert
Empty: Email ist leer Empty: Email ist leer
IDMissing: Email ID fehlt IDMissing: Email ID fehlt
InvalidURLTemplate: URL Template ist ungültig
Phone: Phone:
NotFound: Telefonnummer nicht gefunden NotFound: Telefonnummer nicht gefunden
Invalid: Telefonnummer ist ungültig Invalid: Telefonnummer ist ungültig

View File

@ -81,6 +81,7 @@ Errors:
NotChanged: Email not changed NotChanged: Email not changed
Empty: Email is empty Empty: Email is empty
IDMissing: Email ID is missing IDMissing: Email ID is missing
InvalidURLTemplate: URL Template is invalid
Phone: Phone:
NotFound: Phone not found NotFound: Phone not found
Invalid: Phone is invalid Invalid: Phone is invalid

View File

@ -81,6 +81,7 @@ Errors:
NotChanged: El email no ha cambiado NotChanged: El email no ha cambiado
Empty: El email no está vacío Empty: El email no está vacío
IDMissing: Falta el ID del email IDMissing: Falta el ID del email
InvalidURLTemplate: La plantilla URL no es válida
Phone: Phone:
NotFound: Teléfono no encontrado NotFound: Teléfono no encontrado
Invalid: El teléfono no es válido Invalid: El teléfono no es válido

View File

@ -81,6 +81,7 @@ Errors:
NotChanged: L'adresse électronique n'a pas changé NotChanged: L'adresse électronique n'a pas changé
Empty: Email est vide Empty: Email est vide
IDMissing: Email ID manquant IDMissing: Email ID manquant
InvalidURLTemplate: Le modèle d'URL n'est pas valide
Phone: Phone:
Notfound: Téléphone non trouvé Notfound: Téléphone non trouvé
Invalid: Le téléphone n'est pas valide Invalid: Le téléphone n'est pas valide

View File

@ -81,6 +81,7 @@ Errors:
NotChanged: Email non cambiata NotChanged: Email non cambiata
Empty: Email è vuota Empty: Email è vuota
IDMissing: Email ID mancante IDMissing: Email ID mancante
InvalidURLTemplate: Il modello di URL non è valido
Phone: Phone:
NotFound: Telefono non trovato NotFound: Telefono non trovato
Invalid: Il telefono non è valido Invalid: Il telefono non è valido

View File

@ -76,6 +76,7 @@ Errors:
Invalid: 無効なメールアドレスです Invalid: 無効なメールアドレスです
AlreadyVerified: メールアドレスはすでに検証済みです AlreadyVerified: メールアドレスはすでに検証済みです
NotChanged: メールアドレスが変更されていません NotChanged: メールアドレスが変更されていません
InvalidURLTemplate: URLテンプレートが無効です
Phone: Phone:
NotFound: 電話番号が見つかりません NotFound: 電話番号が見つかりません
Invalid: 無効な電話番号です Invalid: 無効な電話番号です

View File

@ -81,6 +81,7 @@ Errors:
NotChanged: Adres e-mail nie zmieniony NotChanged: Adres e-mail nie zmieniony
Empty: Adres e-mail jest pusty Empty: Adres e-mail jest pusty
IDMissing: Adres e-mail ID brakuje IDMissing: Adres e-mail ID brakuje
InvalidURLTemplate: Szablon URL jest nieprawidłowy
Phone: Phone:
NotFound: Numer telefonu nie znaleziony NotFound: Numer telefonu nie znaleziony
Invalid: Numer telefonu jest nieprawidłowy Invalid: Numer telefonu jest nieprawidłowy

View File

@ -81,6 +81,7 @@ Errors:
NotChanged: 电子邮件未更改 NotChanged: 电子邮件未更改
Empty: 电子邮件是空的 Empty: 电子邮件是空的
IDMissing: 电子邮件ID丢失 IDMissing: 电子邮件ID丢失
InvalidURLTemplate: URL模板无效
Phone: Phone:
NotFound: 手机号码未找到 NotFound: 手机号码未找到
Invalid: 手机号码无效 Invalid: 手机号码无效

View File

@ -2801,7 +2801,7 @@ service AdminService {
rpc ResetCustomPasswordResetMessageTextToDefault(ResetCustomPasswordResetMessageTextToDefaultRequest) returns (ResetCustomPasswordResetMessageTextToDefaultResponse) { rpc ResetCustomPasswordResetMessageTextToDefault(ResetCustomPasswordResetMessageTextToDefaultRequest) returns (ResetCustomPasswordResetMessageTextToDefaultResponse) {
option (google.api.http) = { option (google.api.http) = {
delete: "/text/message/verifyemail/{language}" delete: "/text/message/passwordreset/{language}"
}; };
option (zitadel.v1.auth_option) = { option (zitadel.v1.auth_option) = {

View File

@ -5588,7 +5588,7 @@ service ManagementService {
rpc ResetCustomPasswordResetMessageTextToDefault(ResetCustomPasswordResetMessageTextToDefaultRequest) returns (ResetCustomPasswordResetMessageTextToDefaultResponse) { rpc ResetCustomPasswordResetMessageTextToDefault(ResetCustomPasswordResetMessageTextToDefaultRequest) returns (ResetCustomPasswordResetMessageTextToDefaultResponse) {
option (google.api.http) = { option (google.api.http) = {
delete: "/text/message/verifyemail/{language}" delete: "/text/message/passwordreset/{language}"
}; };
option (zitadel.v1.auth_option) = { option (zitadel.v1.auth_option) = {

View File

@ -0,0 +1,40 @@
syntax = "proto3";
package zitadel.object.v2alpha;
option go_package = "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha;object";
import "google/protobuf/timestamp.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
message OrgContext {
oneof ctx {
string org_id = 1;
string org_domain = 2;
}
}
message Details {
//sequence represents the order of events. It's always counting
//
// on read: the sequence of the last event reduced by the projection
//
// on manipulation: the timestamp of the event(s) added by the manipulation
uint64 sequence = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2\"";
}
];
//change_date is the timestamp when the object was changed
//
// on read: the timestamp of the last event reduced by the projection
//
// on manipulation: the timestamp of the event(s) added by the manipulation
google.protobuf.Timestamp change_date = 2;
//resource_owner is the organization or instance_id an object belongs to
string resource_owner = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629023906488334\"";
}
];
}

View File

@ -0,0 +1,27 @@
syntax = "proto3";
package zitadel.user.v2alpha;
option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha;user";
import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
message SendEmailVerificationCode {
optional string url_template = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}\"";
description: "\"Optionally set a url_template, which will be used in the verification mail sent by ZITADEL to guide the user to your verification page. If no template is set, the default ZITADEL url will be used.\""
}
];
}
message ReturnEmailVerificationCode {}

View File

@ -3,39 +3,32 @@ syntax = "proto3";
package zitadel.user.v2alpha; package zitadel.user.v2alpha;
import "zitadel/options.proto"; import "zitadel/options.proto";
import "zitadel/user/v2alpha/user.proto"; import "zitadel/object/v2alpha/object.proto";
import "zitadel/user/v2alpha/email.proto";
import "google/api/annotations.proto"; import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto"; import "validate/validate.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha;user"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha;user";
service UserService { service UserService {
// TestGet simply demonstrates how the context (org, instance) could be handled in a GET request // rpc SetEmail (SetEmailRequest) returns (SetEmailResponse) {
//
// this request is subject to change and currently used for demonstration only
rpc TestGet (TestGetRequest) returns (TestGetResponse) {
option (google.api.http) = { option (google.api.http) = {
get: "/v2alpha/users/test" post: "/v2alpha/users/{user_id}/email"
};
}
// TestPOST simply demonstrates how the context (org, instance) could be handled in a POST request
//
// this request is subject to change and currently used for demonstration only
rpc TestPost (TestPostRequest) returns (TestPostResponse) {
option (google.api.http) = {
post: "/v2alpha/users/test"
body: "*" body: "*"
}; };
option (zitadel.v1.auth_option) = {
permission: "authenticated"
};
} }
// TestAuth demonstrates how the context (org, instance) could be handled in combination of the authorized context rpc VerifyEmail (VerifyEmailRequest) returns (VerifyEmailResponse) {
//
// this request is subject to change and currently used for demonstration only
rpc TestAuth (TestAuthRequest) returns (TestAuthResponse) {
option (google.api.http) = { option (google.api.http) = {
get: "/v2alpha/users/test_auth" post: "/v2alpha/users/{user_id}/email/_verify"
body: "*"
}; };
option (zitadel.v1.auth_option) = { option (zitadel.v1.auth_option) = {
@ -44,35 +37,61 @@ service UserService {
} }
} }
message TestGetRequest{ message SetEmailRequest{
Context ctx = 1; string user_id = 1 [
} (validate.rules).string = {min_len: 1, max_len: 200},
(google.api.field_behavior) = REQUIRED,
message TestGetResponse{ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
string ctx = 1; min_length: 1;
} max_length: 200;
example: "\"69629026806489455\"";
message TestPostRequest{ }
Context ctx = 1; ];
} string email = 2 [
(validate.rules).string = {min_len: 1, max_len: 200, email: true},
message TestPostResponse{ (google.api.field_behavior) = REQUIRED,
string ctx = 1; (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
} min_length: 1;
max_length: 200;
message TestAuthRequest{ example: "\"mini@mouse.com\"";
Context ctx = 1; }
} ];
// if no verification is specified, an email is sent with the default url
message TestAuthResponse{ oneof verification {
User user = 1; SendEmailVerificationCode send_code = 3;
Context ctx = 2; ReturnEmailVerificationCode return_code = 4;
} bool is_verified = 5 [(validate.rules).bool.const = true];
message Context {
oneof ctx {
bool instance = 1;
string org_id = 2;
string org_domain = 3;
} }
} }
message SetEmailResponse{
zitadel.object.v2alpha.Details details = 1;
// in case the verification was set to return_code, the code will be returned
optional string verification_code = 2;
}
message VerifyEmailRequest{
string user_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"69629026806489455\"";
}
];
string verification_code = 2 [
(validate.rules).string = {min_len: 1, max_len: 20},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 20;
example: "\"SKJd342k\"";
description: "\"the verification code generated during the set email request\"";
}
];
}
message VerifyEmailResponse{
zitadel.object.v2alpha.Details details = 1;
}

View File

@ -3,17 +3,28 @@ module github.com/zitadel/zitadel/tools
go 1.15 go 1.15
require ( require (
github.com/envoyproxy/protoc-gen-validate v0.6.1 github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/Masterminds/sprig v2.22.0+incompatible // indirect
github.com/envoyproxy/protoc-gen-validate v0.10.1
github.com/go-bindata/go-bindata/v3 v3.1.3 github.com/go-bindata/go-bindata/v3 v3.1.3
github.com/golang/mock v1.4.4 github.com/gogo/protobuf v1.3.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.2.0 github.com/golang/glog v1.1.1 // indirect
github.com/iancoleman/strcase v0.1.3 // indirect github.com/golang/mock v1.6.0
github.com/kisielk/errcheck v1.5.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.15.2
github.com/lyft/protoc-gen-star v0.5.2 // indirect github.com/huandu/xstrings v1.4.0 // indirect
github.com/pseudomuto/protoc-gen-doc v1.4.1 github.com/imdario/mergo v0.3.15 // indirect
github.com/kisielk/errcheck v1.6.3 // indirect
github.com/lyft/protoc-gen-star/v2 v2.0.3 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mwitkow/go-proto-validators v0.3.2 // indirect
github.com/pseudomuto/protoc-gen-doc v1.5.1
github.com/pseudomuto/protokit v0.2.1 // indirect
github.com/rakyll/statik v0.1.7 github.com/rakyll/statik v0.1.7
github.com/spf13/afero v1.5.1 // indirect github.com/spf13/afero v1.9.5 // indirect
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 golang.org/x/crypto v0.8.0 // indirect
google.golang.org/protobuf v1.26.0 golang.org/x/tools v0.8.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0
google.golang.org/protobuf v1.30.0
) )

File diff suppressed because it is too large Load Diff