feat(api): add and remove OTP (SMS and email) (#6295)

* refactor: rename otp to totp

* feat: add otp sms and email

* implement tests
This commit is contained in:
Livio Spring 2023-08-02 18:57:53 +02:00 committed by GitHub
parent ca13e70c92
commit a1942ecdaa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 2253 additions and 215 deletions

View File

@ -10,7 +10,7 @@ import (
// equals the authenticated user in the context.
func UserIDInCTX(ctx context.Context, userID string) error {
if GetCtxData(ctx).UserID != userID {
return errors.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong")
return errors.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong")
}
return nil
}

View File

@ -552,7 +552,7 @@ func (s *Server) importData(ctx context.Context, orgs []*admin_pb.DataOrg) (*adm
if user.User.OtpCode != "" {
logging.Debugf("import user otp: %s", user.GetUserId())
if err := s.command.ImportHumanOTP(ctx, user.UserId, "", org.GetOrgId(), user.User.OtpCode); err != nil {
if err := s.command.ImportHumanTOTP(ctx, user.UserId, "", org.GetOrgId(), user.User.OtpCode); err != nil {
errors = append(errors, &admin_pb.ImportDataError{Type: "human_user_otp", Id: user.GetUserId(), Message: err.Error()})
if isCtxTimeout(ctx) {
return &admin_pb.ImportDataResponse{Errors: errors, Success: success}, count, err

View File

@ -18,7 +18,7 @@ func (s *Server) ListMyAuthFactors(ctx context.Context, _ *auth_pb.ListMyAuthFac
if err != nil {
return nil, err
}
err = query.AppendAuthMethodsQuery(domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeOTP)
err = query.AppendAuthMethodsQuery(domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, domain.UserAuthMethodTypeOTPEmail)
if err != nil {
return nil, err
}
@ -37,16 +37,16 @@ func (s *Server) ListMyAuthFactors(ctx context.Context, _ *auth_pb.ListMyAuthFac
func (s *Server) AddMyAuthFactorOTP(ctx context.Context, _ *auth_pb.AddMyAuthFactorOTPRequest) (*auth_pb.AddMyAuthFactorOTPResponse, error) {
ctxData := authz.GetCtxData(ctx)
otp, err := s.command.AddHumanOTP(ctx, ctxData.UserID, ctxData.ResourceOwner)
otp, err := s.command.AddHumanTOTP(ctx, ctxData.UserID, ctxData.ResourceOwner)
if err != nil {
return nil, err
}
return &auth_pb.AddMyAuthFactorOTPResponse{
Url: otp.Url,
Secret: otp.SecretString,
Url: otp.URI,
Secret: otp.Secret,
Details: object.AddToDetailsPb(
otp.Sequence,
otp.ChangeDate,
otp.EventDate,
otp.ResourceOwner,
),
}, nil
@ -54,7 +54,7 @@ func (s *Server) AddMyAuthFactorOTP(ctx context.Context, _ *auth_pb.AddMyAuthFac
func (s *Server) VerifyMyAuthFactorOTP(ctx context.Context, req *auth_pb.VerifyMyAuthFactorOTPRequest) (*auth_pb.VerifyMyAuthFactorOTPResponse, error) {
ctxData := authz.GetCtxData(ctx)
objectDetails, err := s.command.HumanCheckMFAOTPSetup(ctx, ctxData.UserID, req.Code, "", ctxData.ResourceOwner)
objectDetails, err := s.command.HumanCheckMFATOTPSetup(ctx, ctxData.UserID, req.Code, "", ctxData.ResourceOwner)
if err != nil {
return nil, err
}
@ -65,7 +65,7 @@ func (s *Server) VerifyMyAuthFactorOTP(ctx context.Context, req *auth_pb.VerifyM
func (s *Server) RemoveMyAuthFactorOTP(ctx context.Context, _ *auth_pb.RemoveMyAuthFactorOTPRequest) (*auth_pb.RemoveMyAuthFactorOTPResponse, error) {
ctxData := authz.GetCtxData(ctx)
objectDetails, err := s.command.HumanRemoveOTP(ctx, ctxData.UserID, ctxData.ResourceOwner)
objectDetails, err := s.command.HumanRemoveTOTP(ctx, ctxData.UserID, ctxData.ResourceOwner)
if err != nil {
return nil, err
}
@ -74,6 +74,50 @@ func (s *Server) RemoveMyAuthFactorOTP(ctx context.Context, _ *auth_pb.RemoveMyA
}, nil
}
func (s *Server) AddMyAuthFactorOTPSMS(ctx context.Context, _ *auth_pb.AddMyAuthFactorOTPSMSRequest) (*auth_pb.AddMyAuthFactorOTPSMSResponse, error) {
ctxData := authz.GetCtxData(ctx)
details, err := s.command.AddHumanOTPSMS(ctx, ctxData.UserID, ctxData.ResourceOwner)
if err != nil {
return nil, err
}
return &auth_pb.AddMyAuthFactorOTPSMSResponse{
Details: object.DomainToAddDetailsPb(details),
}, nil
}
func (s *Server) RemoveMyAuthFactorOTPSMS(ctx context.Context, _ *auth_pb.RemoveMyAuthFactorOTPSMSRequest) (*auth_pb.RemoveMyAuthFactorOTPSMSResponse, error) {
ctxData := authz.GetCtxData(ctx)
details, err := s.command.RemoveHumanOTPSMS(ctx, ctxData.UserID, ctxData.ResourceOwner)
if err != nil {
return nil, err
}
return &auth_pb.RemoveMyAuthFactorOTPSMSResponse{
Details: object.DomainToChangeDetailsPb(details),
}, nil
}
func (s *Server) AddMyAuthFactorOTPEmail(ctx context.Context, _ *auth_pb.AddMyAuthFactorOTPEmailRequest) (*auth_pb.AddMyAuthFactorOTPEmailResponse, error) {
ctxData := authz.GetCtxData(ctx)
details, err := s.command.AddHumanOTPEmail(ctx, ctxData.UserID, ctxData.ResourceOwner)
if err != nil {
return nil, err
}
return &auth_pb.AddMyAuthFactorOTPEmailResponse{
Details: object.DomainToAddDetailsPb(details),
}, nil
}
func (s *Server) RemoveMyAuthFactorOTPEmail(ctx context.Context, _ *auth_pb.RemoveMyAuthFactorOTPEmailRequest) (*auth_pb.RemoveMyAuthFactorOTPEmailResponse, error) {
ctxData := authz.GetCtxData(ctx)
details, err := s.command.RemoveHumanOTPEmail(ctx, ctxData.UserID, ctxData.ResourceOwner)
if err != nil {
return nil, err
}
return &auth_pb.RemoveMyAuthFactorOTPEmailResponse{
Details: object.DomainToChangeDetailsPb(details),
}, nil
}
func (s *Server) AddMyAuthFactorU2F(ctx context.Context, _ *auth_pb.AddMyAuthFactorU2FRequest) (*auth_pb.AddMyAuthFactorU2FResponse, error) {
ctxData := authz.GetCtxData(ctx)
u2f, err := s.command.HumanAddU2FSetup(ctx, ctxData.UserID, ctxData.ResourceOwner, false)

View File

@ -613,7 +613,7 @@ func (s *Server) ListHumanAuthFactors(ctx context.Context, req *mgmt_pb.ListHuma
if err != nil {
return nil, err
}
err = query.AppendAuthMethodsQuery(domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeOTP)
err = query.AppendAuthMethodsQuery(domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, domain.UserAuthMethodTypeOTPEmail)
if err != nil {
return nil, err
}
@ -631,7 +631,7 @@ func (s *Server) ListHumanAuthFactors(ctx context.Context, req *mgmt_pb.ListHuma
}
func (s *Server) RemoveHumanAuthFactorOTP(ctx context.Context, req *mgmt_pb.RemoveHumanAuthFactorOTPRequest) (*mgmt_pb.RemoveHumanAuthFactorOTPResponse, error) {
objectDetails, err := s.command.HumanRemoveOTP(ctx, req.UserId, authz.GetCtxData(ctx).OrgID)
objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.UserId, authz.GetCtxData(ctx).OrgID)
if err != nil {
return nil, err
}

View File

@ -396,7 +396,7 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) {
func Test_ZITADEL_API_missing_mfa(t *testing.T) {
id, token, _, _ := Tester.CreatePasswordSession(t, CTX, User.GetUserId(), integration.UserPassword)
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token))
ctx := Tester.WithAuthorizationToken(context.Background(), token)
sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.Error(t, err)
require.Nil(t, sessionResp)
@ -405,7 +405,7 @@ func Test_ZITADEL_API_missing_mfa(t *testing.T) {
func Test_ZITADEL_API_success(t *testing.T) {
id, token, _, _ := Tester.CreatePasskeySession(t, CTX, User.GetUserId())
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token))
ctx := Tester.WithAuthorizationToken(context.Background(), token)
sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.NoError(t, err)
require.NotNil(t, id, sessionResp.GetSession().GetFactors().GetPasskey().GetVerifiedAt().AsTime())
@ -415,7 +415,7 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) {
id, token, _, _ := Tester.CreatePasskeySession(t, CTX, User.GetUserId())
// test session token works
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token))
ctx := Tester.WithAuthorizationToken(context.Background(), token)
_, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.NoError(t, err)
@ -425,7 +425,7 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) {
SessionToken: gu.Ptr(token),
})
require.NoError(t, err)
ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token))
ctx = Tester.WithAuthorizationToken(context.Background(), token)
_, err = Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.Error(t, err)
}

View File

@ -197,7 +197,7 @@ func AuthMethodToPb(mfa *query.AuthMethod) *user_pb.AuthFactor {
State: MFAStateToPb(mfa.State),
}
switch mfa.Type {
case domain.UserAuthMethodTypeOTP:
case domain.UserAuthMethodTypeTOTP:
factor.Type = &user_pb.AuthFactor_Otp{
Otp: &user_pb.AuthFactorOTP{},
}
@ -208,6 +208,14 @@ func AuthMethodToPb(mfa *query.AuthMethod) *user_pb.AuthFactor {
Name: mfa.Name,
},
}
case domain.UserAuthMethodTypeOTPSMS:
factor.Type = &user_pb.AuthFactor_OtpSms{
OtpSms: &user_pb.AuthFactorOTPSMS{},
}
case domain.UserAuthMethodTypeOTPEmail:
factor.Type = &user_pb.AuthFactor_OtpEmail{
OtpEmail: &user_pb.AuthFactorOTPEmail{},
}
}
return factor
}

View File

@ -0,0 +1,43 @@
package user
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*user.AddOTPSMSResponse, error) {
details, err := s.command.AddHumanOTPSMS(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner)
if err != nil {
return nil, err
}
return &user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}, nil
}
func (s *Server) RemoveOTPSMS(ctx context.Context, req *user.RemoveOTPSMSRequest) (*user.RemoveOTPSMSResponse, error) {
objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner)
if err != nil {
return nil, err
}
return &user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil
}
func (s *Server) AddOTPEmail(ctx context.Context, req *user.AddOTPEmailRequest) (*user.AddOTPEmailResponse, error) {
details, err := s.command.AddHumanOTPEmail(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner)
if err != nil {
return nil, err
}
return &user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}, nil
}
func (s *Server) RemoveOTPEmail(ctx context.Context, req *user.RemoveOTPEmailRequest) (*user.RemoveOTPEmailResponse, error) {
objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner)
if err != nil {
return nil, err
}
return &user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil
}

View File

@ -0,0 +1,302 @@
//go:build integration
package user_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/integration"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func TestServer_AddOTPSMS(t *testing.T) {
userID := Tester.CreateHumanUser(CTX).GetUserId()
Tester.RegisterUserPasskey(CTX, userID)
_, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID)
// TODO: add when phone can be added to user
/*
userIDPhone := Tester.CreateHumanUser(CTX).GetUserId()
Tester.RegisterUserPasskey(CTX, userIDPhone)
_, sessionTokenPhone, _, _ := Tester.CreatePasskeySession(t, CTX, userIDPhone)
*/
type args struct {
ctx context.Context
req *user.AddOTPSMSRequest
}
tests := []struct {
name string
args args
want *user.AddOTPSMSResponse
wantErr bool
}{
{
name: "missing user id",
args: args{
ctx: CTX,
req: &user.AddOTPSMSRequest{},
},
wantErr: true,
},
{
name: "user mismatch",
args: args{
ctx: CTX,
req: &user.AddOTPSMSRequest{
UserId: "wrong",
},
},
wantErr: true,
},
{
name: "phone not verified",
args: args{
ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken),
req: &user.AddOTPSMSRequest{
UserId: userID,
},
},
wantErr: true,
},
// TODO: add when phone can be added to user
/*
{
name: "add success",
args: args{
ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenPhone),
req: &user.AddOTPSMSRequest{
UserId: userID,
},
},
want: &user.AddOTPSMSResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
*/
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.AddOTPSMS(tt.args.ctx, tt.args.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, got)
integration.AssertDetails(t, tt.want, got)
})
}
}
func TestServer_RemoveOTPSMS(t *testing.T) {
// TODO: add when phone can be added to user
/*
userID := Tester.CreateHumanUser(CTX).GetUserId()
Tester.RegisterUserPasskey(CTX, userID)
_, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID)
*/
type args struct {
ctx context.Context
req *user.RemoveOTPSMSRequest
}
tests := []struct {
name string
args args
want *user.RemoveOTPSMSResponse
wantErr bool
}{
{
name: "not added",
args: args{
ctx: CTX,
req: &user.RemoveOTPSMSRequest{
UserId: "wrong",
},
},
wantErr: true,
},
// TODO: add when phone can be added to user
/*
{
name: "success",
args: args{
ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken),
req: &user.RemoveOTPSMSRequest{
UserId: userID,
},
},
want: &user.RemoveOTPSMSResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ResourceOwner,
},
},
},
*/
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.RemoveOTPSMS(tt.args.ctx, tt.args.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, got)
integration.AssertDetails(t, tt.want, got)
})
}
}
func TestServer_AddOTPEmail(t *testing.T) {
userID := Tester.CreateHumanUser(CTX).GetUserId()
Tester.RegisterUserPasskey(CTX, userID)
_, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID)
userVerified := Tester.CreateHumanUser(CTX)
_, err := Tester.Client.UserV2.VerifyEmail(CTX, &user.VerifyEmailRequest{
UserId: userVerified.GetUserId(),
VerificationCode: userVerified.GetEmailCode(),
})
require.NoError(t, err)
Tester.RegisterUserPasskey(CTX, userVerified.GetUserId())
_, sessionTokenVerified, _, _ := Tester.CreatePasskeySession(t, CTX, userVerified.GetUserId())
type args struct {
ctx context.Context
req *user.AddOTPEmailRequest
}
tests := []struct {
name string
args args
want *user.AddOTPEmailResponse
wantErr bool
}{
{
name: "missing user id",
args: args{
ctx: CTX,
req: &user.AddOTPEmailRequest{},
},
wantErr: true,
},
{
name: "user mismatch",
args: args{
ctx: CTX,
req: &user.AddOTPEmailRequest{
UserId: "wrong",
},
},
wantErr: true,
},
{
name: "email not verified",
args: args{
ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken),
req: &user.AddOTPEmailRequest{
UserId: userID,
},
},
wantErr: true,
},
{
name: "add success",
args: args{
ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified),
req: &user.AddOTPEmailRequest{
UserId: userVerified.GetUserId(),
},
},
want: &user.AddOTPEmailResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.AddOTPEmail(tt.args.ctx, tt.args.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, got)
integration.AssertDetails(t, tt.want, got)
})
}
}
func TestServer_RemoveOTPEmail(t *testing.T) {
userID := Tester.CreateHumanUser(CTX).GetUserId()
Tester.RegisterUserPasskey(CTX, userID)
_, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID)
userVerified := Tester.CreateHumanUser(CTX)
Tester.RegisterUserPasskey(CTX, userVerified.GetUserId())
_, sessionTokenVerified, _, _ := Tester.CreatePasskeySession(t, CTX, userVerified.GetUserId())
userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified)
_, err := Tester.Client.UserV2.VerifyEmail(userVerifiedCtx, &user.VerifyEmailRequest{
UserId: userVerified.GetUserId(),
VerificationCode: userVerified.GetEmailCode(),
})
require.NoError(t, err)
_, err = Tester.Client.UserV2.AddOTPEmail(userVerifiedCtx, &user.AddOTPEmailRequest{UserId: userVerified.GetUserId()})
require.NoError(t, err)
type args struct {
ctx context.Context
req *user.RemoveOTPEmailRequest
}
tests := []struct {
name string
args args
want *user.RemoveOTPEmailResponse
wantErr bool
}{
{
name: "not added",
args: args{
ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken),
req: &user.RemoveOTPEmailRequest{
UserId: userID,
},
},
wantErr: true,
},
{
name: "success",
args: args{
ctx: userVerifiedCtx,
req: &user.RemoveOTPEmailRequest{
UserId: userVerified.GetUserId(),
},
},
want: &user.RemoveOTPEmailResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ResourceOwner,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.RemoveOTPEmail(tt.args.ctx, tt.args.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, got)
integration.AssertDetails(t, tt.want, got)
})
}
}

View File

@ -213,7 +213,7 @@ func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.Authent
func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.AuthenticationMethodType {
switch methodType {
case domain.UserAuthMethodTypeOTP:
case domain.UserAuthMethodTypeTOTP:
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP
case domain.UserAuthMethodTypeU2F:
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_U2F
@ -223,6 +223,10 @@ func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.Authenticatio
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSWORD
case domain.UserAuthMethodTypeIDP:
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP
case domain.UserAuthMethodTypeOTPSMS:
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_SMS
case domain.UserAuthMethodTypeOTPEmail:
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_EMAIL
case domain.UserAuthMethodTypeUnspecified:
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED
default:

View File

@ -194,8 +194,8 @@ func Test_authMethodTypeToPb(t *testing.T) {
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED,
},
{
"(t)otp",
domain.UserAuthMethodTypeOTP,
"totp",
domain.UserAuthMethodTypeTOTP,
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP,
},
{
@ -218,6 +218,16 @@ func Test_authMethodTypeToPb(t *testing.T) {
domain.UserAuthMethodTypeIDP,
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP,
},
{
"otp sms",
domain.UserAuthMethodTypeOTPSMS,
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_SMS,
},
{
"otp email",
domain.UserAuthMethodTypeOTPEmail,
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_EMAIL,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@ -22,25 +22,35 @@ const (
// [RFC 8176, section 2]: https://datatracker.ietf.org/doc/html/rfc8176#section-2
func AuthMethodTypesToAMR(methodTypes []domain.UserAuthMethodType) []string {
amr := make([]string, 0, 4)
var mfa bool
var factors, otp int
for _, methodType := range methodTypes {
switch methodType {
case domain.UserAuthMethodTypePassword:
amr = append(amr, PWD)
factors++
case domain.UserAuthMethodTypePasswordless:
mfa = true
amr = append(amr, UserPresence)
factors += 2
case domain.UserAuthMethodTypeU2F:
amr = append(amr, UserPresence)
case domain.UserAuthMethodTypeOTP:
amr = append(amr, OTP)
factors++
case domain.UserAuthMethodTypeTOTP,
domain.UserAuthMethodTypeOTPSMS,
domain.UserAuthMethodTypeOTPEmail:
// a user could use multiple (t)otp, which is a factor, but still will be returned as a single `otp` entry
otp++
factors++
case domain.UserAuthMethodTypeIDP:
// no AMR value according to specification
factors++
case domain.UserAuthMethodTypeUnspecified:
// ignore
}
}
if mfa || len(amr) >= 2 {
if otp > 0 {
amr = append(amr, OTP)
}
if factors >= 2 {
amr = append(amr, MFA)
}
return amr

View File

@ -46,12 +46,33 @@ func TestAMR(t *testing.T) {
[]string{UserPresence},
},
{
"otp checked",
"totp checked",
args{
[]domain.UserAuthMethodType{domain.UserAuthMethodTypeOTP},
[]domain.UserAuthMethodType{domain.UserAuthMethodTypeTOTP},
},
[]string{OTP},
},
{
"otp sms checked",
args{
[]domain.UserAuthMethodType{domain.UserAuthMethodTypeOTPSMS},
},
[]string{OTP},
},
{
"otp email checked",
args{
[]domain.UserAuthMethodType{domain.UserAuthMethodTypeOTPEmail},
},
[]string{OTP},
},
{
"multiple (t)otp checked",
args{
[]domain.UserAuthMethodType{domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPEmail},
},
[]string{OTP, MFA},
},
{
"multiple checked",
args{

View File

@ -261,7 +261,7 @@ func CodeChallengeToOIDC(challenge *domain.OIDCCodeChallenge) *oidc.CodeChalleng
func AMRFromMFAType(mfaType domain.MFAType) string {
switch mfaType {
case domain.MFATypeOTP:
case domain.MFATypeTOTP:
return OTP
case domain.MFATypeU2F,
domain.MFATypeU2FUserVerification:

View File

@ -33,7 +33,7 @@ func (l *Login) handleMFAInitVerify(w http.ResponseWriter, r *http.Request) {
}
var verifyData *mfaVerifyData
switch data.MFAType {
case domain.MFATypeOTP:
case domain.MFATypeTOTP:
verifyData = l.handleOTPVerify(w, r, authReq, data)
}
@ -50,13 +50,13 @@ func (l *Login) handleMFAInitVerify(w http.ResponseWriter, r *http.Request) {
func (l *Login) handleOTPVerify(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaInitVerifyData) *mfaVerifyData {
userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
_, err := l.command.HumanCheckMFAOTPSetup(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, data.Code, userAgentID, authReq.UserOrgID)
_, err := l.command.HumanCheckMFATOTPSetup(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, data.Code, userAgentID, authReq.UserOrgID)
if err == nil {
return nil
}
mfadata := &mfaVerifyData{
MFAType: data.MFAType,
otpData: otpData{
totpData: totpData{
Secret: data.Secret,
Url: data.URL,
},
@ -73,10 +73,10 @@ func (l *Login) renderMFAInitVerify(w http.ResponseWriter, r *http.Request, auth
translator := l.getTranslator(r.Context(), authReq)
data.baseData = l.getBaseData(r, authReq, "InitMFAOTP.Title", "InitMFAOTP.Description", errID, errMessage)
data.profileData = l.getProfileData(authReq)
if data.MFAType == domain.MFATypeOTP {
code, err := generateQrCode(data.otpData.Url)
if data.MFAType == domain.MFATypeTOTP {
code, err := generateQrCode(data.totpData.Url)
if err == nil {
data.otpData.QrCode = code
data.totpData.QrCode = code
}
}

View File

@ -80,8 +80,8 @@ func (l *Login) renderMFAPrompt(w http.ResponseWriter, r *http.Request, authReq
func (l *Login) handleMFACreation(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaVerifyData) {
switch data.MFAType {
case domain.MFATypeOTP:
l.handleOTPCreation(w, r, authReq, data)
case domain.MFATypeTOTP:
l.handleTOTPCreation(w, r, authReq, data)
return
case domain.MFATypeU2F:
l.renderRegisterU2F(w, r, authReq, nil)
@ -90,16 +90,16 @@ func (l *Login) handleMFACreation(w http.ResponseWriter, r *http.Request, authRe
l.renderError(w, r, authReq, caos_errs.ThrowPreconditionFailed(nil, "APP-Or3HO", "Errors.User.MFA.NoProviders"))
}
func (l *Login) handleOTPCreation(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaVerifyData) {
otp, err := l.command.AddHumanOTP(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID)
func (l *Login) handleTOTPCreation(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaVerifyData) {
otp, err := l.command.AddHumanTOTP(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID)
if err != nil {
l.renderError(w, r, authReq, err)
return
}
data.otpData = otpData{
Secret: otp.SecretString,
Url: otp.Url,
data.totpData = totpData{
Secret: otp.Secret,
Url: otp.URI,
}
l.renderMFAInitVerify(w, r, authReq, data, nil)
}

View File

@ -33,7 +33,7 @@ func (l *Login) handleMFAVerify(w http.ResponseWriter, r *http.Request) {
l.renderMFAVerifySelected(w, r, authReq, step, data.SelectedProvider, nil)
return
}
if data.MFAType == domain.MFATypeOTP {
if data.MFAType == domain.MFATypeTOTP {
userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
err = l.authRepo.VerifyMFAOTP(setContext(r.Context(), authReq.UserOrgID), authReq.ID, authReq.UserID, authReq.UserOrgID, data.Code, userAgentID, domain.BrowserInfoFromRequest(r))
@ -45,7 +45,7 @@ func (l *Login) handleMFAVerify(w http.ResponseWriter, r *http.Request) {
}
if err != nil {
l.renderMFAVerifySelected(w, r, authReq, step, domain.MFATypeOTP, err)
l.renderMFAVerifySelected(w, r, authReq, step, domain.MFATypeTOTP, err)
return
}
}
@ -79,9 +79,9 @@ func (l *Login) renderMFAVerifySelected(w http.ResponseWriter, r *http.Request,
data.Description = translator.LocalizeWithoutArgs("VerifyMFAU2F.Description")
l.renderU2FVerification(w, r, authReq, removeSelectedProviderFromList(verificationStep.MFAProviders, domain.MFATypeU2F), nil)
return
case domain.MFATypeOTP:
data.MFAProviders = removeSelectedProviderFromList(verificationStep.MFAProviders, domain.MFATypeOTP)
data.SelectedMFAProvider = domain.MFATypeOTP
case domain.MFATypeTOTP:
data.MFAProviders = removeSelectedProviderFromList(verificationStep.MFAProviders, domain.MFATypeTOTP)
data.SelectedMFAProvider = domain.MFATypeTOTP
data.Title = translator.LocalizeWithoutArgs("VerifyMFAOTP.Title")
data.Description = translator.LocalizeWithoutArgs("VerifyMFAOTP.Description")
default:

View File

@ -673,7 +673,7 @@ type mfaVerifyData struct {
baseData
profileData
MFAType domain.MFAType
otpData
totpData
}
type mfaDoneData struct {
@ -682,7 +682,7 @@ type mfaDoneData struct {
MFAType domain.MFAType
}
type otpData struct {
type totpData struct {
Url string
Secret string
QrCode string

View File

@ -368,7 +368,7 @@ func (repo *AuthRequestRepo) VerifyMFAOTP(ctx context.Context, authRequestID, us
if err != nil {
return err
}
return repo.Command.HumanCheckMFAOTP(ctx, userID, code, resourceOwner, request.WithCurrentInfo(info))
return repo.Command.HumanCheckMFATOTP(ctx, userID, code, resourceOwner, request.WithCurrentInfo(info))
}
func (repo *AuthRequestRepo) BeginMFAU2FLogin(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) (login *domain.WebAuthNLogin, err error) {

View File

@ -1016,7 +1016,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
},
}, false},
[]domain.NextStep{&domain.MFAVerificationStep{
MFAProviders: []domain.MFAType{domain.MFATypeOTP},
MFAProviders: []domain.MFAType{domain.MFATypeTOTP},
}},
nil,
},
@ -1050,7 +1050,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
},
}, false},
[]domain.NextStep{&domain.MFAVerificationStep{
MFAProviders: []domain.MFAType{domain.MFATypeOTP},
MFAProviders: []domain.MFAType{domain.MFATypeTOTP},
}},
nil,
},
@ -1087,7 +1087,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
},
}, false},
[]domain.NextStep{&domain.MFAVerificationStep{
MFAProviders: []domain.MFAType{domain.MFATypeOTP},
MFAProviders: []domain.MFAType{domain.MFATypeTOTP},
}},
nil,
},
@ -1637,7 +1637,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
},
&domain.MFAPromptStep{
MFAProviders: []domain.MFAType{
domain.MFATypeOTP,
domain.MFATypeTOTP,
},
},
false,
@ -1663,7 +1663,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
&domain.MFAPromptStep{
Required: true,
MFAProviders: []domain.MFAType{
domain.MFATypeOTP,
domain.MFATypeTOTP,
},
},
false,
@ -1731,7 +1731,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
},
&domain.MFAVerificationStep{
MFAProviders: []domain.MFAType{domain.MFATypeOTP},
MFAProviders: []domain.MFAType{domain.MFATypeTOTP},
},
false,
nil,
@ -1755,7 +1755,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
isInternal: false,
},
&domain.MFAVerificationStep{
MFAProviders: []domain.MFAType{domain.MFATypeOTP},
MFAProviders: []domain.MFAType{domain.MFATypeTOTP},
},
false,
nil,
@ -1803,7 +1803,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
&domain.MFAPromptStep{
Required: true,
MFAProviders: []domain.MFAType{
domain.MFATypeOTP,
domain.MFATypeTOTP,
},
},
false,

View File

@ -199,12 +199,21 @@ func authMethodsFromSession(session *query.Session) []domain.UserAuthMethodType
// TODO: add checks with https://github.com/zitadel/zitadel/issues/5477
/*
if !session.TOTPFactor.TOTPCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeOTP)
types = append(types, domain.UserAuthMethodTypeTOTP)
}
if !session.U2FFactor.U2FCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeU2F)
}
*/
// TODO: add checks with https://github.com/zitadel/zitadel/issues/6224
/*
if !session.TOTPFactor.OTPSMSCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeOTPSMS)
}
if !session.TOTPFactor.OTPEmailCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeOTPEmail)
}
*/
return types
}

View File

@ -155,6 +155,7 @@ func (wm *SessionWriteModel) AuthenticationTime() time.Time {
wm.PasskeyCheckedAt,
wm.IntentCheckedAt,
// TODO: add U2F and OTP check https://github.com/zitadel/zitadel/issues/5477
// TODO: add OTP (sms and email) check https://github.com/zitadel/zitadel/issues/6224
} {
if check.After(authTime) {
authTime = check
@ -178,11 +179,20 @@ func (wm *SessionWriteModel) AuthMethodTypes() []domain.UserAuthMethodType {
// TODO: add checks with https://github.com/zitadel/zitadel/issues/5477
/*
if !wm.TOTPCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeOTP)
types = append(types, domain.UserAuthMethodTypeTOTP)
}
if !wm.U2FCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeU2F)
}
*/
// TODO: add checks with https://github.com/zitadel/zitadel/issues/6224
/*
if !wm.TOTPFactor.OTPSMSCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeOTPSMS)
}
if !wm.TOTPFactor.OTPEmailCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeOTPEmail)
}
*/
return types
}

View File

@ -11,12 +11,11 @@ import (
"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/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
func (c *Commands) ImportHumanOTP(ctx context.Context, userID, userAgentID, resourceowner string, key string) error {
func (c *Commands) ImportHumanTOTP(ctx context.Context, userID, userAgentID, resourceowner string, key string) error {
encryptedSecret, err := crypto.Encrypt([]byte(key), c.multifactors.OTP.CryptoMFA)
if err != nil {
return err
@ -25,7 +24,7 @@ func (c *Commands) ImportHumanOTP(ctx context.Context, userID, userAgentID, reso
return err
}
otpWriteModel, err := c.otpWriteModelByID(ctx, userID, resourceowner)
otpWriteModel, err := c.totpWriteModelByID(ctx, userID, resourceowner)
if err != nil {
return err
}
@ -41,7 +40,7 @@ func (c *Commands) ImportHumanOTP(ctx context.Context, userID, userAgentID, reso
return err
}
func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string) (*domain.OTP, error) {
func (c *Commands) AddHumanTOTP(ctx context.Context, userID, resourceowner string) (*domain.TOTP, error) {
if userID == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-5M0sd", "Errors.User.UserIDMissing")
}
@ -49,21 +48,19 @@ func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string
if err != nil {
return nil, err
}
_, err = c.eventstore.Push(ctx, prep.cmds...)
err = c.pushAppendAndReduce(ctx, prep.wm, prep.cmds...)
if err != nil {
return nil, err
}
return &domain.OTP{
ObjectRoot: models.ObjectRoot{
AggregateID: prep.userAgg.ID,
},
SecretString: prep.key.Secret(),
Url: prep.key.URL(),
return &domain.TOTP{
ObjectDetails: writeModelToObjectDetails(&prep.wm.WriteModel),
Secret: prep.key.Secret(),
URI: prep.key.URL(),
}, nil
}
type preparedTOTP struct {
wm *HumanOTPWriteModel
wm *HumanTOTPWriteModel
userAgg *eventstore.Aggregate
key *otp.Key
cmds []eventstore.Command
@ -72,21 +69,21 @@ type preparedTOTP struct {
func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner string) (*preparedTOTP, error) {
human, err := c.getHuman(ctx, userID, resourceOwner)
if err != nil {
logging.Log("COMMAND-DAqe1").WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get human for loginname")
logging.WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get human for loginname")
return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-MM9fs", "Errors.User.NotFound")
}
org, err := c.getOrg(ctx, human.ResourceOwner)
if err != nil {
logging.Log("COMMAND-Cm0ds").WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org for loginname")
logging.WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org for loginname")
return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-55M9f", "Errors.Org.NotFound")
}
orgPolicy, err := c.getOrgDomainPolicy(ctx, org.AggregateID)
if err != nil {
logging.Log("COMMAND-y5zv9").WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org policy for loginname")
logging.WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org policy for loginname")
return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-8ugTs", "Errors.Org.DomainPolicy.NotFound")
}
otpWriteModel, err := c.otpWriteModelByID(ctx, userID, resourceOwner)
otpWriteModel, err := c.totpWriteModelByID(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
@ -103,7 +100,7 @@ func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner st
if issuer == "" {
issuer = authz.GetInstance(ctx).RequestedDomain()
}
key, secret, err := domain.NewOTPKey(issuer, accountName, c.multifactors.OTP.CryptoMFA)
key, secret, err := domain.NewTOTPKey(issuer, accountName, c.multifactors.OTP.CryptoMFA)
if err != nil {
return nil, err
}
@ -117,12 +114,12 @@ func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner st
}, nil
}
func (c *Commands) HumanCheckMFAOTPSetup(ctx context.Context, userID, code, userAgentID, resourceowner string) (*domain.ObjectDetails, error) {
func (c *Commands) HumanCheckMFATOTPSetup(ctx context.Context, userID, code, userAgentID, resourceowner string) (*domain.ObjectDetails, error) {
if userID == "" {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-8N9ds", "Errors.User.UserIDMissing")
}
existingOTP, err := c.otpWriteModelByID(ctx, userID, resourceowner)
existingOTP, err := c.totpWriteModelByID(ctx, userID, resourceowner)
if err != nil {
return nil, err
}
@ -132,7 +129,7 @@ func (c *Commands) HumanCheckMFAOTPSetup(ctx context.Context, userID, code, user
if existingOTP.State == domain.MFAStateReady {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-qx4ls", "Errors.Users.MFA.OTP.AlreadyReady")
}
if err := domain.VerifyMFAOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA); err != nil {
if err := domain.VerifyTOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA); err != nil {
return nil, err
}
userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel)
@ -148,11 +145,11 @@ func (c *Commands) HumanCheckMFAOTPSetup(ctx context.Context, userID, code, user
return writeModelToObjectDetails(&existingOTP.WriteModel), nil
}
func (c *Commands) HumanCheckMFAOTP(ctx context.Context, userID, code, resourceowner string, authRequest *domain.AuthRequest) error {
func (c *Commands) HumanCheckMFATOTP(ctx context.Context, userID, code, resourceowner string, authRequest *domain.AuthRequest) error {
if userID == "" {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-8N9ds", "Errors.User.UserIDMissing")
}
existingOTP, err := c.otpWriteModelByID(ctx, userID, resourceowner)
existingOTP, err := c.totpWriteModelByID(ctx, userID, resourceowner)
if err != nil {
return err
}
@ -160,22 +157,22 @@ func (c *Commands) HumanCheckMFAOTP(ctx context.Context, userID, code, resourceo
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3Mif9s", "Errors.User.MFA.OTP.NotReady")
}
userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel)
err = domain.VerifyMFAOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA)
err = domain.VerifyTOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA)
if err == nil {
_, err = c.eventstore.Push(ctx, user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest)))
return err
}
_, pushErr := c.eventstore.Push(ctx, user.NewHumanOTPCheckFailedEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest)))
logging.Log("COMMAND-9fj7s").OnError(pushErr).Error("error create password check failed event")
logging.OnError(pushErr).Error("error create password check failed event")
return err
}
func (c *Commands) HumanRemoveOTP(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) {
func (c *Commands) HumanRemoveTOTP(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) {
if userID == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-5M0sd", "Errors.User.UserIDMissing")
}
existingOTP, err := c.otpWriteModelByID(ctx, userID, resourceOwner)
existingOTP, err := c.totpWriteModelByID(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
@ -194,11 +191,128 @@ func (c *Commands) HumanRemoveOTP(ctx context.Context, userID, resourceOwner str
return writeModelToObjectDetails(&existingOTP.WriteModel), nil
}
func (c *Commands) otpWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanOTPWriteModel, err error) {
func (c *Commands) AddHumanOTPSMS(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) {
if userID == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-QSF2s", "Errors.User.UserIDMissing")
}
if err := authz.UserIDInCTX(ctx, userID); err != nil {
return nil, err
}
otpWriteModel, err := c.otpSMSWriteModelByID(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if otpWriteModel.otpAdded {
return nil, caos_errs.ThrowAlreadyExists(nil, "COMMAND-Ad3g2", "Errors.User.MFA.OTP.AlreadyReady")
}
if !otpWriteModel.phoneVerified {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Q54j2", "Errors.User.MFA.OTP.NotReady")
}
userAgg := UserAggregateFromWriteModel(&otpWriteModel.WriteModel)
if err = c.pushAppendAndReduce(ctx, otpWriteModel, user.NewHumanOTPSMSAddedEvent(ctx, userAgg)); err != nil {
return nil, err
}
return writeModelToObjectDetails(&otpWriteModel.WriteModel), nil
}
func (c *Commands) RemoveHumanOTPSMS(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) {
if userID == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-S3br2", "Errors.User.UserIDMissing")
}
existingOTP, err := c.otpSMSWriteModelByID(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if userID != authz.GetCtxData(ctx).UserID {
if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.ResourceOwner, userID); err != nil {
return nil, err
}
}
if !existingOTP.otpAdded {
return nil, caos_errs.ThrowNotFound(nil, "COMMAND-Sr3h3", "Errors.User.MFA.OTP.NotExisting")
}
userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel)
if err = c.pushAppendAndReduce(ctx, existingOTP, user.NewHumanOTPSMSRemovedEvent(ctx, userAgg)); err != nil {
return nil, err
}
return writeModelToObjectDetails(&existingOTP.WriteModel), nil
}
func (c *Commands) AddHumanOTPEmail(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) {
if userID == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Sg1hz", "Errors.User.UserIDMissing")
}
otpWriteModel, err := c.otpEmailWriteModelByID(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if otpWriteModel.otpAdded {
return nil, caos_errs.ThrowAlreadyExists(nil, "COMMAND-MKL2s", "Errors.User.MFA.OTP.AlreadyReady")
}
if !otpWriteModel.emailVerified {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-KLJ2d", "Errors.User.MFA.OTP.NotReady")
}
userAgg := UserAggregateFromWriteModel(&otpWriteModel.WriteModel)
if err = c.pushAppendAndReduce(ctx, otpWriteModel, user.NewHumanOTPEmailAddedEvent(ctx, userAgg)); err != nil {
return nil, err
}
return writeModelToObjectDetails(&otpWriteModel.WriteModel), nil
}
func (c *Commands) RemoveHumanOTPEmail(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) {
if userID == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-S2h11", "Errors.User.UserIDMissing")
}
existingOTP, err := c.otpEmailWriteModelByID(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if userID != authz.GetCtxData(ctx).UserID {
if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.ResourceOwner, userID); err != nil {
return nil, err
}
}
if !existingOTP.otpAdded {
return nil, caos_errs.ThrowNotFound(nil, "COMMAND-b312D", "Errors.User.MFA.OTP.NotExisting")
}
userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel)
if err = c.pushAppendAndReduce(ctx, existingOTP, user.NewHumanOTPEmailRemovedEvent(ctx, userAgg)); err != nil {
return nil, err
}
return writeModelToObjectDetails(&existingOTP.WriteModel), nil
}
func (c *Commands) totpWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanTOTPWriteModel, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
writeModel = NewHumanOTPWriteModel(userID, resourceOwner)
writeModel = NewHumanTOTPWriteModel(userID, resourceOwner)
err = c.eventstore.FilterToQueryReducer(ctx, writeModel)
if err != nil {
return nil, err
}
return writeModel, nil
}
func (c *Commands) otpSMSWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanOTPSMSWriteModel, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
writeModel = NewHumanOTPSMSWriteModel(userID, resourceOwner)
err = c.eventstore.FilterToQueryReducer(ctx, writeModel)
if err != nil {
return nil, err
}
return writeModel, nil
}
func (c *Commands) otpEmailWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanOTPEmailWriteModel, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
writeModel = NewHumanOTPEmailWriteModel(userID, resourceOwner)
err = c.eventstore.FilterToQueryReducer(ctx, writeModel)
if err != nil {
return nil, err

View File

@ -7,15 +7,15 @@ import (
"github.com/zitadel/zitadel/internal/repository/user"
)
type HumanOTPWriteModel struct {
type HumanTOTPWriteModel struct {
eventstore.WriteModel
State domain.MFAState
Secret *crypto.CryptoValue
}
func NewHumanOTPWriteModel(userID, resourceOwner string) *HumanOTPWriteModel {
return &HumanOTPWriteModel{
func NewHumanTOTPWriteModel(userID, resourceOwner string) *HumanTOTPWriteModel {
return &HumanTOTPWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: userID,
ResourceOwner: resourceOwner,
@ -23,7 +23,7 @@ func NewHumanOTPWriteModel(userID, resourceOwner string) *HumanOTPWriteModel {
}
}
func (wm *HumanOTPWriteModel) Reduce() error {
func (wm *HumanTOTPWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *user.HumanOTPAddedEvent:
@ -40,7 +40,7 @@ func (wm *HumanOTPWriteModel) Reduce() error {
return wm.WriteModel.Reduce()
}
func (wm *HumanOTPWriteModel) Query() *eventstore.SearchQueryBuilder {
func (wm *HumanTOTPWriteModel) Query() *eventstore.SearchQueryBuilder {
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(user.AggregateType).
@ -59,3 +59,107 @@ func (wm *HumanOTPWriteModel) Query() *eventstore.SearchQueryBuilder {
}
return query
}
type HumanOTPSMSWriteModel struct {
eventstore.WriteModel
phoneVerified bool
otpAdded bool
}
func NewHumanOTPSMSWriteModel(userID, resourceOwner string) *HumanOTPSMSWriteModel {
return &HumanOTPSMSWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: userID,
ResourceOwner: resourceOwner,
},
}
}
func (wm *HumanOTPSMSWriteModel) Reduce() error {
for _, event := range wm.Events {
switch event.(type) {
case *user.HumanPhoneVerifiedEvent:
wm.phoneVerified = true
case *user.HumanOTPSMSAddedEvent:
wm.otpAdded = true
case *user.HumanOTPSMSRemovedEvent:
wm.otpAdded = false
case *user.HumanPhoneRemovedEvent,
*user.UserRemovedEvent:
wm.phoneVerified = false
wm.otpAdded = false
}
}
return wm.WriteModel.Reduce()
}
func (wm *HumanOTPSMSWriteModel) Query() *eventstore.SearchQueryBuilder {
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(user.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(user.HumanPhoneVerifiedType,
user.HumanOTPSMSAddedType,
user.HumanOTPSMSRemovedType,
user.HumanPhoneRemovedType,
user.UserRemovedType,
).
Builder()
if wm.ResourceOwner != "" {
query.ResourceOwner(wm.ResourceOwner)
}
return query
}
type HumanOTPEmailWriteModel struct {
eventstore.WriteModel
emailVerified bool
otpAdded bool
}
func NewHumanOTPEmailWriteModel(userID, resourceOwner string) *HumanOTPEmailWriteModel {
return &HumanOTPEmailWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: userID,
ResourceOwner: resourceOwner,
},
}
}
func (wm *HumanOTPEmailWriteModel) Reduce() error {
for _, event := range wm.Events {
switch event.(type) {
case *user.HumanEmailVerifiedEvent:
wm.emailVerified = true
case *user.HumanOTPEmailAddedEvent:
wm.otpAdded = true
case *user.HumanOTPEmailRemovedEvent:
wm.otpAdded = false
case *user.UserRemovedEvent:
wm.emailVerified = false
wm.otpAdded = false
}
}
return wm.WriteModel.Reduce()
}
func (wm *HumanOTPEmailWriteModel) Query() *eventstore.SearchQueryBuilder {
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(user.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(user.HumanEmailVerifiedType,
user.HumanOTPEmailAddedType,
user.HumanOTPEmailRemovedType,
user.UserRemovedType,
).
Builder()
if wm.ResourceOwner != "" {
query.ResourceOwner(wm.ResourceOwner)
}
return query
}

View File

@ -22,7 +22,7 @@ import (
"github.com/zitadel/zitadel/internal/repository/user"
)
func TestCommandSide_AddHumanOTP(t *testing.T) {
func TestCommandSide_AddHumanTOTP(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
}
@ -223,7 +223,7 @@ func TestCommandSide_AddHumanOTP(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore,
}
got, err := r.AddHumanOTP(tt.args.ctx, tt.args.userID, tt.args.orgID)
got, err := r.AddHumanTOTP(tt.args.ctx, tt.args.userID, tt.args.orgID)
if tt.res.err == nil {
assert.NoError(t, err)
}
@ -237,7 +237,7 @@ func TestCommandSide_AddHumanOTP(t *testing.T) {
}
}
func TestCommands_createHumanOTP(t *testing.T) {
func TestCommands_createHumanTOTP(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
}
@ -527,11 +527,11 @@ func TestCommands_createHumanOTP(t *testing.T) {
}
}
func TestCommands_HumanCheckMFAOTPSetup(t *testing.T) {
func TestCommands_HumanCheckMFATOTPSetup(t *testing.T) {
ctx := authz.NewMockContext("", "org1", "user1")
cryptoAlg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
key, secret, err := domain.NewOTPKey("example.com", "user1", cryptoAlg)
key, secret, err := domain.NewTOTPKey("example.com", "user1", cryptoAlg)
require.NoError(t, err)
userAgg := &user.NewAggregate("user1", "org1").Aggregate
@ -697,7 +697,7 @@ func TestCommands_HumanCheckMFAOTPSetup(t *testing.T) {
},
},
}
got, err := c.HumanCheckMFAOTPSetup(ctx, tt.args.userID, tt.args.code, "agent1", tt.args.resourceOwner)
got, err := c.HumanCheckMFATOTPSetup(ctx, tt.args.userID, tt.args.code, "agent1", tt.args.resourceOwner)
require.ErrorIs(t, err, tt.wantErr)
if tt.want {
require.NotNil(t, got)
@ -707,7 +707,7 @@ func TestCommands_HumanCheckMFAOTPSetup(t *testing.T) {
}
}
func TestCommandSide_RemoveHumanOTP(t *testing.T) {
func TestCommandSide_RemoveHumanTOTP(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
}
@ -802,7 +802,7 @@ func TestCommandSide_RemoveHumanOTP(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore,
}
got, err := r.HumanRemoveOTP(tt.args.ctx, tt.args.userID, tt.args.orgID)
got, err := r.HumanRemoveTOTP(tt.args.ctx, tt.args.userID, tt.args.orgID)
if tt.res.err == nil {
assert.NoError(t, err)
}
@ -815,3 +815,540 @@ func TestCommandSide_RemoveHumanOTP(t *testing.T) {
})
}
}
func TestCommandSide_AddHumanOTPSMS(t *testing.T) {
ctx := authz.NewMockContext("inst1", "org1", "user1")
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
}
type (
args struct {
ctx context.Context
userID string
resourceOwner string
}
)
type res struct {
want *domain.ObjectDetails
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "userid missing, invalid argument error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: ctx,
userID: "",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-QSF2s", "Errors.User.UserIDMissing"),
},
},
{
name: "wrong user, permission denied error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: ctx,
userID: "other",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
},
},
{
name: "otp sms already exists, already exists error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanOTPSMSAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowAlreadyExists(nil, "COMMAND-Ad3g2", "Errors.User.MFA.OTP.AlreadyReady"),
},
},
{
name: "phone not verified, precondition failed error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Q54j2", "Errors.User.MFA.OTP.NotReady"),
},
},
{
name: "phone removed, precondition failed error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanPhoneChangedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
"+4179654321",
),
),
eventFromEventPusher(
user.NewHumanPhoneVerifiedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
eventFromEventPusher(
user.NewHumanPhoneRemovedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Q54j2", "Errors.User.MFA.OTP.NotReady"),
},
},
{
name: "successful add",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanPhoneChangedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
"+4179654321",
),
),
eventFromEventPusher(
user.NewHumanPhoneVerifiedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusherWithInstanceID("inst1",
user.NewHumanOTPSMSAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
},
),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
}
got, err := r.AddHumanOTPSMS(tt.args.ctx, tt.args.userID, tt.args.resourceOwner)
assert.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
})
}
}
func TestCommandSide_RemoveHumanOTPSMS(t *testing.T) {
ctx := authz.NewMockContext("inst1", "org1", "user1")
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
checkPermission domain.PermissionCheck
}
type (
args struct {
ctx context.Context
userID string
resourceOwner string
}
)
type res struct {
want *domain.ObjectDetails
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "userid missing, invalid argument error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: ctx,
userID: "",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-S3br2", "Errors.User.UserIDMissing"),
},
},
{
name: "other user not permission, permission denied error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: ctx,
userID: "other",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
},
{
name: "otp sms not added, not found error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowNotFound(nil, "COMMAND-Sr3h3", "Errors.User.MFA.OTP.NotExisting"),
},
},
{
name: "successful remove",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanOTPSMSAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusherWithInstanceID("inst1",
user.NewHumanOTPSMSRemovedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
},
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
checkPermission: tt.fields.checkPermission,
}
got, err := r.RemoveHumanOTPSMS(tt.args.ctx, tt.args.userID, tt.args.resourceOwner)
assert.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
})
}
}
func TestCommandSide_AddHumanOTPEmail(t *testing.T) {
ctx := authz.NewMockContext("inst1", "org1", "user1")
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
}
type (
args struct {
ctx context.Context
userID string
resourceOwner string
}
)
type res struct {
want *domain.ObjectDetails
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "userid missing, invalid argument error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: ctx,
userID: "",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-Sg1hz", "Errors.User.UserIDMissing"),
},
},
{
name: "otp email already exists, already exists error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanOTPEmailAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowAlreadyExists(nil, "COMMAND-MKL2s", "Errors.User.MFA.OTP.AlreadyReady"),
},
},
{
name: "email not verified, precondition failed error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-KLJ2d", "Errors.User.MFA.OTP.NotReady"),
},
},
{
name: "successful add",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanEmailChangedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
"email@test.ch",
),
),
eventFromEventPusher(
user.NewHumanEmailVerifiedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusherWithInstanceID("inst1",
user.NewHumanOTPEmailAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
},
),
),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
}
got, err := r.AddHumanOTPEmail(tt.args.ctx, tt.args.userID, tt.args.resourceOwner)
assert.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
})
}
}
func TestCommandSide_RemoveHumanOTPEmail(t *testing.T) {
ctx := authz.NewMockContext("inst1", "org1", "user1")
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
checkPermission domain.PermissionCheck
}
type (
args struct {
ctx context.Context
userID string
resourceOwner string
}
)
type res struct {
want *domain.ObjectDetails
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "userid missing, invalid argument error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: ctx,
userID: "",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-S2h11", "Errors.User.UserIDMissing"),
},
},
{
name: "other user not permission, permission denied error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: ctx,
userID: "other",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
},
{
name: "otp email not added, not found error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
res: res{
err: caos_errs.ThrowNotFound(nil, "COMMAND-b312D", "Errors.User.MFA.OTP.NotExisting"),
},
},
{
name: "successful remove",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanOTPEmailAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusherWithInstanceID("inst1",
user.NewHumanOTPEmailRemovedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
},
),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: ctx,
userID: "user1",
resourceOwner: "org1",
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
checkPermission: tt.fields.checkPermission,
}
got, err := r.RemoveHumanOTPEmail(tt.args.ctx, tt.args.userID, tt.args.resourceOwner)
assert.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
})
}
}

View File

@ -885,70 +885,70 @@ func TestCommandSide_RemoveHumanPhone(t *testing.T) {
args args
res res
}{
//{
// name: "userid missing, invalid argument error",
// fields: fields{
// eventstore: eventstoreExpect(
// t,
// ),
// },
// args: args{
// ctx: context.Background(),
// resourceOwner: "org1",
// },
// res: res{
// err: caos_errs.IsErrorInvalidArgument,
// },
//},
//{
// name: "user not existing, precondition error",
// fields: fields{
// eventstore: eventstoreExpect(
// t,
// expectFilter(),
// ),
// },
// args: args{
// ctx: context.Background(),
// userID: "user1",
// resourceOwner: "org1",
// },
// res: res{
// err: caos_errs.IsPreconditionFailed,
// },
//},
//{
// name: "phone not existing, precondition error",
// fields: fields{
// eventstore: eventstoreExpect(
// t,
// expectFilter(
// eventFromEventPusher(
// user.NewHumanAddedEvent(context.Background(),
// &user.NewAggregate("user1", "org1").Aggregate,
// "username",
// "firstname",
// "lastname",
// "nickname",
// "displayname",
// language.German,
// domain.GenderUnspecified,
// "email@test.ch",
// true,
// ),
// ),
// ),
// ),
// },
// args: args{
// ctx: context.Background(),
// userID: "user1",
// resourceOwner: "org1",
// },
// res: res{
// err: caos_errs.IsNotFound,
// },
//},
{
name: "userid missing, invalid argument error",
fields: fields{
eventstore: eventstoreExpect(
t,
),
},
args: args{
ctx: context.Background(),
resourceOwner: "org1",
},
res: res{
err: caos_errs.IsErrorInvalidArgument,
},
},
{
name: "user not existing, precondition error",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
},
res: res{
err: caos_errs.IsPreconditionFailed,
},
},
{
name: "phone not existing, precondition error",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
},
res: res{
err: caos_errs.IsNotFound,
},
},
{
name: "remove phone, ok",
fields: fields{

View File

@ -57,7 +57,7 @@ func TestCommands_RegisterUserPasskey(t *testing.T) {
resourceOwner: "org1",
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
},
wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
},
{
name: "get human passwordless error",

View File

@ -29,5 +29,5 @@ func (c *Commands) CheckUserTOTP(ctx context.Context, userID, code, resourceOwne
if err := authz.UserIDInCTX(ctx, userID); err != nil {
return nil, err
}
return c.HumanCheckMFAOTPSetup(ctx, userID, code, "", resourceOwner)
return c.HumanCheckMFATOTPSetup(ctx, userID, code, "", resourceOwner)
}

View File

@ -45,7 +45,7 @@ func TestCommands_AddUserTOTP(t *testing.T) {
userID: "foo",
resourceowner: "org1",
},
wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
},
{
name: "create otp error",
@ -191,7 +191,7 @@ func TestCommands_CheckUserTOTP(t *testing.T) {
ctx := authz.NewMockContext("", "org1", "user1")
cryptoAlg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
key, secret, err := domain.NewOTPKey("example.com", "user1", cryptoAlg)
key, secret, err := domain.NewTOTPKey("example.com", "user1", cryptoAlg)
require.NoError(t, err)
userAgg := &user.NewAggregate("user1", "org1").Aggregate
@ -218,7 +218,7 @@ func TestCommands_CheckUserTOTP(t *testing.T) {
args: args{
userID: "foo",
},
wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
},
{
name: "success",

View File

@ -52,7 +52,7 @@ func TestCommands_RegisterUserU2F(t *testing.T) {
userID: "foo",
resourceOwner: "org1",
},
wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
},
{
name: "get human passwordless error",

View File

@ -102,7 +102,7 @@ const (
type MFAType int
const (
MFATypeOTP MFAType = iota
MFATypeTOTP MFAType = iota
MFATypeU2F
MFATypeU2FUserVerification
)

View File

@ -3,20 +3,11 @@ package domain
import (
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/zitadel/zitadel/internal/crypto"
caos_errs "github.com/zitadel/zitadel/internal/errors"
es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models"
)
type OTP struct {
es_models.ObjectRoot
Secret *crypto.CryptoValue
SecretString string
Url string
State MFAState
}
type TOTP struct {
*ObjectDetails
@ -24,7 +15,7 @@ type TOTP struct {
URI string
}
func NewOTPKey(issuer, accountName string, cryptoAlg crypto.EncryptionAlgorithm) (*otp.Key, *crypto.CryptoValue, error) {
func NewTOTPKey(issuer, accountName string, cryptoAlg crypto.EncryptionAlgorithm) (*otp.Key, *crypto.CryptoValue, error) {
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: accountName})
if err != nil {
return nil, nil, caos_errs.ThrowInternal(err, "TOTP-ieY3o", "Errors.Internal")
@ -36,7 +27,7 @@ func NewOTPKey(issuer, accountName string, cryptoAlg crypto.EncryptionAlgorithm)
return key, encryptedSecret, nil
}
func VerifyMFAOTP(code string, secret *crypto.CryptoValue, cryptoAlg crypto.EncryptionAlgorithm) error {
func VerifyTOTP(code string, secret *crypto.CryptoValue, cryptoAlg crypto.EncryptionAlgorithm) error {
decrypt, err := crypto.DecryptString(secret, cryptoAlg)
if err != nil {
return err

View File

@ -48,11 +48,13 @@ type UserAuthMethodType int32
const (
UserAuthMethodTypeUnspecified UserAuthMethodType = iota
UserAuthMethodTypeOTP
UserAuthMethodTypeTOTP
UserAuthMethodTypeU2F
UserAuthMethodTypePasswordless
UserAuthMethodTypePassword
UserAuthMethodTypeIDP
UserAuthMethodTypeOTPSMS
UserAuthMethodTypeOTPEmail
userAuthMethodTypeCount
)
@ -67,15 +69,14 @@ func HasMFA(methods []UserAuthMethodType) bool {
var factors int
for _, method := range methods {
switch method {
case UserAuthMethodTypePassword:
factors++
case UserAuthMethodTypePasswordless:
return true
case UserAuthMethodTypeU2F:
factors++
case UserAuthMethodTypeOTP:
factors++
case UserAuthMethodTypeIDP:
case UserAuthMethodTypePassword,
UserAuthMethodTypeU2F,
UserAuthMethodTypeTOTP,
UserAuthMethodTypeOTPSMS,
UserAuthMethodTypeOTPEmail,
UserAuthMethodTypeIDP:
factors++
case UserAuthMethodTypeUnspecified,
userAuthMethodTypeCount:

View File

@ -315,6 +315,9 @@ func GenericEventMapper[T any, PT BaseEventSetter[T]](event *repository.Event) (
e := PT(new(T))
e.SetBaseEvent(BaseEventFromRepo(event))
if len(event.Data) == 0 {
return e, nil
}
err := json.Unmarshal(event.Data, e)
if err != nil {
return nil, errors.ThrowInternal(err, "V2-Thai6", "unable to unmarshal event")

View File

@ -248,7 +248,11 @@ func (s *Tester) WithInstanceAuthorization(ctx context.Context, u UserType, inst
if u == SystemUser {
s.ensureSystemUser()
}
return metadata.AppendToOutgoingContext(ctx, "Authorization", fmt.Sprintf("Bearer %s", s.Users.Get(instanceID, u).Token))
return s.WithAuthorizationToken(ctx, s.Users.Get(instanceID, u).Token)
}
func (s *Tester) WithAuthorizationToken(ctx context.Context, token string) context.Context {
return metadata.AppendToOutgoingContext(ctx, "Authorization", fmt.Sprintf("Bearer %s", token))
}
func (s *Tester) ensureSystemUser() {

View File

@ -89,6 +89,14 @@ func (p *userAuthMethodProjection) reducers() []handler.AggregateReducer {
Event: user.HumanMFAOTPVerifiedType,
Reduce: p.reduceActivateEvent,
},
{
Event: user.HumanOTPSMSAddedType,
Reduce: p.reduceAddAuthMethod,
},
{
Event: user.HumanOTPEmailAddedType,
Reduce: p.reduceAddAuthMethod,
},
{
Event: user.HumanPasswordlessTokenRemovedType,
Reduce: p.reduceRemoveAuthMethod,
@ -101,6 +109,14 @@ func (p *userAuthMethodProjection) reducers() []handler.AggregateReducer {
Event: user.HumanMFAOTPRemovedType,
Reduce: p.reduceRemoveAuthMethod,
},
{
Event: user.HumanOTPSMSRemovedType,
Reduce: p.reduceRemoveAuthMethod,
},
{
Event: user.HumanOTPEmailRemovedType,
Reduce: p.reduceRemoveAuthMethod,
},
},
},
{
@ -135,7 +151,7 @@ func (p *userAuthMethodProjection) reduceInitAuthMethod(event eventstore.Event)
methodType = domain.UserAuthMethodTypeU2F
tokenID = e.WebAuthNTokenID
case *user.HumanOTPAddedEvent:
methodType = domain.UserAuthMethodTypeOTP
methodType = domain.UserAuthMethodTypeTOTP
default:
return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType})
}
@ -178,7 +194,7 @@ func (p *userAuthMethodProjection) reduceActivateEvent(event eventstore.Event) (
tokenID = e.WebAuthNTokenID
name = e.WebAuthNTokenName
case *user.HumanOTPVerifiedEvent:
methodType = domain.UserAuthMethodTypeOTP
methodType = domain.UserAuthMethodTypeTOTP
default:
return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType})
@ -202,6 +218,34 @@ func (p *userAuthMethodProjection) reduceActivateEvent(event eventstore.Event) (
), nil
}
func (p *userAuthMethodProjection) reduceAddAuthMethod(event eventstore.Event) (*handler.Statement, error) {
var methodType domain.UserAuthMethodType
switch event.(type) {
case *user.HumanOTPSMSAddedEvent:
methodType = domain.UserAuthMethodTypeOTPSMS
case *user.HumanOTPEmailAddedEvent:
methodType = domain.UserAuthMethodTypeOTPEmail
default:
return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-DS4g3", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanOTPSMSAddedType, user.HumanOTPEmailAddedType})
}
return crdb.NewCreateStatement(
event,
[]handler.Column{
handler.NewCol(UserAuthMethodTokenIDCol, ""),
handler.NewCol(UserAuthMethodCreationDateCol, event.CreationDate()),
handler.NewCol(UserAuthMethodChangeDateCol, event.CreationDate()),
handler.NewCol(UserAuthMethodResourceOwnerCol, event.Aggregate().ResourceOwner),
handler.NewCol(UserAuthMethodInstanceIDCol, event.Aggregate().InstanceID),
handler.NewCol(UserAuthMethodUserIDCol, event.Aggregate().ID),
handler.NewCol(UserAuthMethodSequenceCol, event.Sequence()),
handler.NewCol(UserAuthMethodStateCol, domain.MFAStateReady),
handler.NewCol(UserAuthMethodTypeCol, methodType),
handler.NewCol(UserAuthMethodNameCol, ""),
},
), nil
}
func (p *userAuthMethodProjection) reduceRemoveAuthMethod(event eventstore.Event) (*handler.Statement, error) {
var tokenID string
var methodType domain.UserAuthMethodType
@ -213,10 +257,17 @@ func (p *userAuthMethodProjection) reduceRemoveAuthMethod(event eventstore.Event
methodType = domain.UserAuthMethodTypeU2F
tokenID = e.WebAuthNTokenID
case *user.HumanOTPRemovedEvent:
methodType = domain.UserAuthMethodTypeOTP
methodType = domain.UserAuthMethodTypeTOTP
case *user.HumanOTPSMSRemovedEvent,
*user.HumanPhoneRemovedEvent:
methodType = domain.UserAuthMethodTypeOTPSMS
case *user.HumanOTPEmailRemovedEvent:
methodType = domain.UserAuthMethodTypeOTPEmail
default:
return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType})
return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v",
[]eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType, user.HumanMFAOTPRemovedType,
user.HumanOTPSMSRemovedType, user.HumanPhoneRemovedType, user.HumanOTPEmailRemovedType})
}
conditions := []handler.Condition{
handler.NewCond(UserAuthMethodUserIDCol, event.Aggregate().ID),

View File

@ -98,7 +98,7 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) {
},
},
{
name: "reduceAddedOTP",
name: "reduceAddedTOTP",
args: args{
event: getEvent(testEvent(
repository.EventType(user.HumanMFAOTPAddedType),
@ -125,7 +125,7 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) {
"agg-id",
uint64(15),
domain.MFAStateNotReady,
domain.UserAuthMethodTypeOTP,
domain.UserAuthMethodTypeTOTP,
"",
},
},
@ -208,7 +208,7 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) {
},
},
{
name: "reduceVerifiedOTP",
name: "reduceVerifiedTOTP",
args: args{
event: getEvent(testEvent(
repository.EventType(user.HumanMFAOTPVerifiedType),
@ -232,7 +232,7 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) {
"",
domain.MFAStateReady,
"agg-id",
domain.UserAuthMethodTypeOTP,
domain.UserAuthMethodTypeTOTP,
"ro-id",
"",
"instance-id",
@ -242,6 +242,256 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) {
},
},
},
{
name: "reduceAddedOTPSMS",
args: args{
event: getEvent(testEvent(
repository.EventType(user.HumanOTPSMSAddedType),
user.AggregateType,
nil,
), eventstore.GenericEventMapper[user.HumanOTPSMSAddedEvent]),
},
reduce: (&userAuthMethodProjection{}).reduceAddAuthMethod,
want: wantReduce{
aggregateType: user.AggregateType,
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "INSERT INTO projections.user_auth_methods4 (token_id, creation_date, change_date, resource_owner, instance_id, user_id, sequence, state, method_type, name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
expectedArgs: []interface{}{
"",
anyArg{},
anyArg{},
"ro-id",
"instance-id",
"agg-id",
uint64(15),
domain.MFAStateReady,
domain.UserAuthMethodTypeOTPSMS,
"",
},
},
},
},
},
},
{
name: "reduceAddedOTPEmail",
args: args{
event: getEvent(testEvent(
repository.EventType(user.HumanOTPEmailAddedType),
user.AggregateType,
nil,
), eventstore.GenericEventMapper[user.HumanOTPEmailAddedEvent]),
},
reduce: (&userAuthMethodProjection{}).reduceAddAuthMethod,
want: wantReduce{
aggregateType: user.AggregateType,
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "INSERT INTO projections.user_auth_methods4 (token_id, creation_date, change_date, resource_owner, instance_id, user_id, sequence, state, method_type, name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
expectedArgs: []interface{}{
"",
anyArg{},
anyArg{},
"ro-id",
"instance-id",
"agg-id",
uint64(15),
domain.MFAStateReady,
domain.UserAuthMethodTypeOTPEmail,
"",
},
},
},
},
},
},
{
name: "reduceRemoveOTPPasswordless",
args: args{
event: getEvent(testEvent(
repository.EventType(user.HumanPasswordlessTokenRemovedType),
user.AggregateType,
[]byte(`{
"webAuthNTokenId": "token-id"
}`),
), user.HumanPasswordlessRemovedEventMapper),
},
reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod,
want: wantReduce{
aggregateType: user.AggregateType,
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4) AND (token_id = $5)",
expectedArgs: []interface{}{
"agg-id",
domain.UserAuthMethodTypePasswordless,
"ro-id",
"instance-id",
"token-id",
},
},
},
},
},
},
{
name: "reduceRemoveOTPU2F",
args: args{
event: getEvent(testEvent(
repository.EventType(user.HumanU2FTokenRemovedType),
user.AggregateType,
[]byte(`{
"webAuthNTokenId": "token-id"
}`),
), user.HumanU2FRemovedEventMapper),
},
reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod,
want: wantReduce{
aggregateType: user.AggregateType,
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4) AND (token_id = $5)",
expectedArgs: []interface{}{
"agg-id",
domain.UserAuthMethodTypeU2F,
"ro-id",
"instance-id",
"token-id",
},
},
},
},
},
},
{
name: "reduceRemoveTOTP",
args: args{
event: getEvent(testEvent(
repository.EventType(user.HumanMFAOTPRemovedType),
user.AggregateType,
nil,
), user.HumanOTPRemovedEventMapper),
},
reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod,
want: wantReduce{
aggregateType: user.AggregateType,
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)",
expectedArgs: []interface{}{
"agg-id",
domain.UserAuthMethodTypeTOTP,
"ro-id",
"instance-id",
},
},
},
},
},
},
{
name: "reduceRemoveOTPSMS",
args: args{
event: getEvent(testEvent(
repository.EventType(user.HumanOTPSMSRemovedType),
user.AggregateType,
nil,
), eventstore.GenericEventMapper[user.HumanOTPSMSRemovedEvent]),
},
reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod,
want: wantReduce{
aggregateType: user.AggregateType,
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)",
expectedArgs: []interface{}{
"agg-id",
domain.UserAuthMethodTypeOTPSMS,
"ro-id",
"instance-id",
},
},
},
},
},
},
{
name: "reduceRemovePhone",
args: args{
event: getEvent(testEvent(
repository.EventType(user.HumanPhoneRemovedType),
user.AggregateType,
nil,
), user.HumanPhoneRemovedEventMapper),
},
reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod,
want: wantReduce{
aggregateType: user.AggregateType,
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)",
expectedArgs: []interface{}{
"agg-id",
domain.UserAuthMethodTypeOTPSMS,
"ro-id",
"instance-id",
},
},
},
},
},
},
{
name: "reduceRemoveOTPEmail",
args: args{
event: getEvent(testEvent(
repository.EventType(user.HumanOTPEmailRemovedType),
user.AggregateType,
nil,
), eventstore.GenericEventMapper[user.HumanOTPEmailRemovedEvent]),
},
reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod,
want: wantReduce{
aggregateType: user.AggregateType,
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)",
expectedArgs: []interface{}{
"agg-id",
domain.UserAuthMethodTypeOTPEmail,
"ro-id",
"instance-id",
},
},
},
},
},
},
{
name: "org reduceOwnerRemoved",
reduce: (&userAuthMethodProjection{}).reduceOwnerRemoved,

View File

@ -280,7 +280,7 @@ func Test_UserAuthMethodPrepares(t *testing.T) {
},
{
true,
domain.UserAuthMethodTypeOTP,
domain.UserAuthMethodTypeTOTP,
1,
},
},
@ -292,7 +292,7 @@ func Test_UserAuthMethodPrepares(t *testing.T) {
},
AuthMethodTypes: []domain.UserAuthMethodType{
domain.UserAuthMethodTypePasswordless,
domain.UserAuthMethodTypeOTP,
domain.UserAuthMethodTypeTOTP,
domain.UserAuthMethodTypePassword,
domain.UserAuthMethodTypeIDP,
},
@ -399,7 +399,7 @@ func Test_UserAuthMethodPrepares(t *testing.T) {
},
{
true,
domain.UserAuthMethodTypeOTP,
domain.UserAuthMethodTypeTOTP,
1,
true,
true,
@ -411,7 +411,7 @@ func Test_UserAuthMethodPrepares(t *testing.T) {
object: &testUserAuthMethodTypesRequired{
authMethods: []domain.UserAuthMethodType{
domain.UserAuthMethodTypePasswordless,
domain.UserAuthMethodTypeOTP,
domain.UserAuthMethodTypeTOTP,
domain.UserAuthMethodTypePassword,
domain.UserAuthMethodTypeIDP,
},

View File

@ -88,6 +88,14 @@ func RegisterEventMappers(es *eventstore.Eventstore) {
RegisterFilterEventMapper(AggregateType, HumanMFAOTPRemovedType, HumanOTPRemovedEventMapper).
RegisterFilterEventMapper(AggregateType, HumanMFAOTPCheckSucceededType, HumanOTPCheckSucceededEventMapper).
RegisterFilterEventMapper(AggregateType, HumanMFAOTPCheckFailedType, HumanOTPCheckFailedEventMapper).
RegisterFilterEventMapper(AggregateType, HumanOTPSMSAddedType, eventstore.GenericEventMapper[HumanOTPSMSAddedEvent]).
RegisterFilterEventMapper(AggregateType, HumanOTPSMSRemovedType, eventstore.GenericEventMapper[HumanOTPSMSRemovedEvent]).
RegisterFilterEventMapper(AggregateType, HumanOTPSMSCheckSucceededType, eventstore.GenericEventMapper[HumanOTPSMSCheckSucceededEvent]).
RegisterFilterEventMapper(AggregateType, HumanOTPSMSCheckFailedType, eventstore.GenericEventMapper[HumanOTPSMSCheckFailedEvent]).
RegisterFilterEventMapper(AggregateType, HumanOTPEmailAddedType, eventstore.GenericEventMapper[HumanOTPEmailAddedEvent]).
RegisterFilterEventMapper(AggregateType, HumanOTPEmailRemovedType, eventstore.GenericEventMapper[HumanOTPEmailRemovedEvent]).
RegisterFilterEventMapper(AggregateType, HumanOTPEmailCheckSucceededType, eventstore.GenericEventMapper[HumanOTPEmailCheckSucceededEvent]).
RegisterFilterEventMapper(AggregateType, HumanOTPEmailCheckFailedType, eventstore.GenericEventMapper[HumanOTPEmailCheckFailedEvent]).
RegisterFilterEventMapper(AggregateType, HumanU2FTokenAddedType, HumanU2FAddedEventMapper).
RegisterFilterEventMapper(AggregateType, HumanU2FTokenVerifiedType, HumanU2FVerifiedEventMapper).
RegisterFilterEventMapper(AggregateType, HumanU2FTokenSignCountChangedType, HumanU2FSignCountChangedEventMapper).

View File

@ -12,12 +12,22 @@ import (
)
const (
otpEventPrefix = mfaEventPrefix + "otp."
HumanMFAOTPAddedType = otpEventPrefix + "added"
HumanMFAOTPVerifiedType = otpEventPrefix + "verified"
HumanMFAOTPRemovedType = otpEventPrefix + "removed"
HumanMFAOTPCheckSucceededType = otpEventPrefix + "check.succeeded"
HumanMFAOTPCheckFailedType = otpEventPrefix + "check.failed"
otpEventPrefix = mfaEventPrefix + "otp."
HumanMFAOTPAddedType = otpEventPrefix + "added"
HumanMFAOTPVerifiedType = otpEventPrefix + "verified"
HumanMFAOTPRemovedType = otpEventPrefix + "removed"
HumanMFAOTPCheckSucceededType = otpEventPrefix + "check.succeeded"
HumanMFAOTPCheckFailedType = otpEventPrefix + "check.failed"
otpSMSEventPrefix = otpEventPrefix + "sms."
HumanOTPSMSAddedType = otpSMSEventPrefix + "added"
HumanOTPSMSRemovedType = otpSMSEventPrefix + "removed"
HumanOTPSMSCheckSucceededType = otpSMSEventPrefix + "check.succeeded"
HumanOTPSMSCheckFailedType = otpSMSEventPrefix + "check.failed"
otpEmailEventPrefix = otpEventPrefix + "email."
HumanOTPEmailAddedType = otpEmailEventPrefix + "added"
HumanOTPEmailRemovedType = otpEmailEventPrefix + "removed"
HumanOTPEmailCheckSucceededType = otpEmailEventPrefix + "check.succeeded"
HumanOTPEmailCheckFailedType = otpEmailEventPrefix + "check.failed"
)
type HumanOTPAddedEvent struct {
@ -202,3 +212,247 @@ func HumanOTPCheckFailedEventMapper(event *repository.Event) (eventstore.Event,
}
return otpAdded, nil
}
type HumanOTPSMSAddedEvent struct {
eventstore.BaseEvent `json:"-"`
}
func (e *HumanOTPSMSAddedEvent) Data() interface{} {
return nil
}
func (e *HumanOTPSMSAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func (e *HumanOTPSMSAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewHumanOTPSMSAddedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
) *HumanOTPSMSAddedEvent {
return &HumanOTPSMSAddedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
HumanOTPSMSAddedType,
),
}
}
type HumanOTPSMSRemovedEvent struct {
eventstore.BaseEvent `json:"-"`
}
func (e *HumanOTPSMSRemovedEvent) Data() interface{} {
return nil
}
func (e *HumanOTPSMSRemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func (e *HumanOTPSMSRemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewHumanOTPSMSRemovedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
) *HumanOTPSMSRemovedEvent {
return &HumanOTPSMSRemovedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
HumanOTPSMSRemovedType,
),
}
}
type HumanOTPSMSCheckSucceededEvent struct {
eventstore.BaseEvent `json:"-"`
*AuthRequestInfo
}
func (e *HumanOTPSMSCheckSucceededEvent) Data() interface{} {
return e
}
func (e *HumanOTPSMSCheckSucceededEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func (e *HumanOTPSMSCheckSucceededEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewHumanOTPSMSCheckSucceededEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
info *AuthRequestInfo,
) *HumanOTPSMSCheckSucceededEvent {
return &HumanOTPSMSCheckSucceededEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
HumanOTPSMSCheckSucceededType,
),
AuthRequestInfo: info,
}
}
type HumanOTPSMSCheckFailedEvent struct {
eventstore.BaseEvent `json:"-"`
*AuthRequestInfo
}
func (e *HumanOTPSMSCheckFailedEvent) Data() interface{} {
return e
}
func (e *HumanOTPSMSCheckFailedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func (e *HumanOTPSMSCheckFailedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewHumanOTPSMSCheckFailedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
info *AuthRequestInfo,
) *HumanOTPSMSCheckFailedEvent {
return &HumanOTPSMSCheckFailedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
HumanOTPSMSCheckFailedType,
),
AuthRequestInfo: info,
}
}
type HumanOTPEmailAddedEvent struct {
eventstore.BaseEvent `json:"-"`
}
func (e *HumanOTPEmailAddedEvent) Data() interface{} {
return nil
}
func (e *HumanOTPEmailAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func (e *HumanOTPEmailAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewHumanOTPEmailAddedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
) *HumanOTPEmailAddedEvent {
return &HumanOTPEmailAddedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
HumanOTPEmailAddedType,
),
}
}
type HumanOTPEmailRemovedEvent struct {
eventstore.BaseEvent `json:"-"`
}
func (e *HumanOTPEmailRemovedEvent) Data() interface{} {
return nil
}
func (e *HumanOTPEmailRemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func (e *HumanOTPEmailRemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewHumanOTPEmailRemovedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
) *HumanOTPEmailRemovedEvent {
return &HumanOTPEmailRemovedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
HumanOTPEmailRemovedType,
),
}
}
type HumanOTPEmailCheckSucceededEvent struct {
eventstore.BaseEvent `json:"-"`
*AuthRequestInfo
}
func (e *HumanOTPEmailCheckSucceededEvent) Data() interface{} {
return e
}
func (e *HumanOTPEmailCheckSucceededEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func (e *HumanOTPEmailCheckSucceededEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewHumanOTPEmailCheckSucceededEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
info *AuthRequestInfo,
) *HumanOTPEmailCheckSucceededEvent {
return &HumanOTPEmailCheckSucceededEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
HumanOTPEmailCheckSucceededType,
),
AuthRequestInfo: info,
}
}
type HumanOTPEmailCheckFailedEvent struct {
eventstore.BaseEvent `json:"-"`
*AuthRequestInfo
}
func (e *HumanOTPEmailCheckFailedEvent) Data() interface{} {
return e
}
func (e *HumanOTPEmailCheckFailedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func (e *HumanOTPEmailCheckFailedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewHumanOTPEmailCheckFailedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
info *AuthRequestInfo,
) *HumanOTPEmailCheckFailedEvent {
return &HumanOTPEmailCheckFailedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
HumanOTPEmailCheckFailedType,
),
AuthRequestInfo: info,
}
}

View File

@ -158,7 +158,7 @@ func (u *UserView) MFATypesSetupPossible(level domain.MFALevel, policy *domain.L
switch mfaType {
case domain.SecondFactorTypeTOTP:
if u.OTPState != MFAStateReady {
types = append(types, domain.MFATypeOTP)
types = append(types, domain.MFATypeTOTP)
}
case domain.SecondFactorTypeU2F:
types = append(types, domain.MFATypeU2F)
@ -183,7 +183,7 @@ func (u *UserView) MFATypesAllowed(level domain.MFALevel, policy *domain.LoginPo
switch mfaType {
case domain.SecondFactorTypeTOTP:
if u.OTPState == MFAStateReady {
types = append(types, domain.MFATypeOTP)
types = append(types, domain.MFATypeTOTP)
}
case domain.SecondFactorTypeU2F:
if u.IsU2FReady() {

View File

@ -134,11 +134,11 @@ func (v *UserSessionView) AppendEvent(event *models.Event) error {
return err
}
if v.UserAgentID == data.UserAgentID {
v.setSecondFactorVerification(event.CreationDate, domain.MFATypeOTP)
v.setSecondFactorVerification(event.CreationDate, domain.MFATypeTOTP)
}
case user.UserV1MFAOTPCheckSucceededType,
user.HumanMFAOTPCheckSucceededType:
v.setSecondFactorVerification(event.CreationDate, domain.MFATypeOTP)
v.setSecondFactorVerification(event.CreationDate, domain.MFATypeTOTP)
case user.UserV1MFAOTPCheckFailedType,
user.UserV1MFAOTPRemovedType,
user.HumanMFAOTPCheckFailedType,

View File

@ -647,6 +647,70 @@ service AuthService {
};
}
rpc AddMyAuthFactorOTPSMS(AddMyAuthFactorOTPSMSRequest) returns (AddMyAuthFactorOTPSMSResponse) {
option (google.api.http) = {
post: "/users/me/auth_factors/otp_sms"
body: "*"
};
option (zitadel.v1.auth_option) = {
permission: "authenticated"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "User Authentication Factor"
summary: "Add One-Time-Password (OTP) SMS";
description: "Add a new One-Time-Password (OTP) SMS factor to the authenticated user. OTP SMS will enable the user to verify a OTP with the latest verified phone number. The phone number has to be verified to add the second factor."
};
}
rpc RemoveMyAuthFactorOTPSMS(RemoveMyAuthFactorOTPSMSRequest) returns (RemoveMyAuthFactorOTPSMSResponse) {
option (google.api.http) = {
delete: "/users/me/auth_factors/otp_sms"
};
option (zitadel.v1.auth_option) = {
permission: "authenticated"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "User Authentication Factor"
summary: "Remove One-Time-Password (OTP) SMS";
description: "Remove the configured One-Time-Password (OTP) SMS factor of the authenticated user. As only one OTP SMS per user is allowed, the user will not have OTP SMS as a second-factor afterward."
};
}
rpc AddMyAuthFactorOTPEmail(AddMyAuthFactorOTPEmailRequest) returns (AddMyAuthFactorOTPEmailResponse) {
option (google.api.http) = {
post: "/users/me/auth_factors/otp_email"
body: "*"
};
option (zitadel.v1.auth_option) = {
permission: "authenticated"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "User Authentication Factor"
summary: "Add One-Time-Password (OTP) Email";
description: "Add a new One-Time-Password (OTP) Email factor to the authenticated user. OTP Email will enable the user to verify a OTP with the latest verified email. The email has to be verified to add the second factor."
};
}
rpc RemoveMyAuthFactorOTPEmail(RemoveMyAuthFactorOTPEmailRequest) returns (RemoveMyAuthFactorOTPEmailResponse) {
option (google.api.http) = {
delete: "/users/me/auth_factors/otp_email"
};
option (zitadel.v1.auth_option) = {
permission: "authenticated"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "User Authentication Factor"
summary: "Remove One-Time-Password (OTP) Email";
description: "Remove the configured One-Time-Password (OTP) Email factor of the authenticated user. As only one OTP Email per user is allowed, the user will not have OTP Email as a second-factor afterward."
};
}
rpc AddMyAuthFactorU2F(AddMyAuthFactorU2FRequest) returns (AddMyAuthFactorU2FResponse) {
option (google.api.http) = {
post: "/users/me/auth_factors/u2f"
@ -1340,6 +1404,34 @@ message RemoveMyAuthFactorOTPResponse {
zitadel.v1.ObjectDetails details = 1;
}
//This is an empty request
message AddMyAuthFactorOTPSMSRequest {}
message AddMyAuthFactorOTPSMSResponse {
zitadel.v1.ObjectDetails details = 1;
}
//This is an empty request
message RemoveMyAuthFactorOTPSMSRequest {}
message RemoveMyAuthFactorOTPSMSResponse {
zitadel.v1.ObjectDetails details = 1;
}
//This is an empty request
message AddMyAuthFactorOTPEmailRequest {}
message AddMyAuthFactorOTPEmailResponse {
zitadel.v1.ObjectDetails details = 1;
}
//This is an empty request
message RemoveMyAuthFactorOTPEmailRequest {}
message RemoveMyAuthFactorOTPEmailResponse {
zitadel.v1.ObjectDetails details = 1;
}
message RemoveMyAuthFactorU2FRequest {
string token_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
}

View File

@ -342,12 +342,22 @@ message AuthFactor {
oneof type {
AuthFactorOTP otp = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "one type use OTP or U2F"
description: "one type use OTP, OTPSMS, OTPEmail or U2F"
}
];
AuthFactorU2F u2f = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "one type use OTP or U2F"
description: "one type use OTP, OTPSMS, OTPEmail or U2F"
}
];
AuthFactorOTPSMS otp_sms = 4 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "one type use OTP, OTPSMS, OTPEmail or U2F"
}
];
AuthFactorOTPEmail otp_email = 5 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "one type use OTP, OTPSMS, OTPEmail or U2F"
}
];
}
@ -361,6 +371,8 @@ enum AuthFactorState {
}
message AuthFactorOTP {}
message AuthFactorOTPSMS {}
message AuthFactorOTPEmail {}
message AuthFactorU2F {
string id = 1 [

View File

@ -317,6 +317,96 @@ service UserService {
};
}
rpc AddOTPSMS (AddOTPSMSRequest) returns (AddOTPSMSResponse) {
option (google.api.http) = {
post: "/v2alpha/users/{user_id}/otp_sms"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Add OTP SMS for a user";
description: "Add a new One-Time-Password (OTP) SMS factor to the authenticated user. OTP SMS will enable the user to verify a OTP with the latest verified phone number. The phone number has to be verified to add the second factor."
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
rpc RemoveOTPSMS (RemoveOTPSMSRequest) returns (RemoveOTPSMSResponse) {
option (google.api.http) = {
delete: "/v2alpha/users/{user_id}/otp_sms"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Remove One-Time-Password (OTP) SMS from a user";
description: "Remove the configured One-Time-Password (OTP) SMS factor of the authenticated user. As only one OTP SMS per user is allowed, the user will not have OTP SMS as a second-factor afterward."
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
rpc AddOTPEmail (AddOTPEmailRequest) returns (AddOTPEmailResponse) {
option (google.api.http) = {
post: "/v2alpha/users/{user_id}/otp_email"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Add OTP Email for a user";
description: "Add a new One-Time-Password (OTP) Email factor to the authenticated user. OTP Email will enable the user to verify a OTP with the latest verified email. The email has to be verified to add the second factor."
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
rpc RemoveOTPEmail (RemoveOTPEmailRequest) returns (RemoveOTPEmailResponse) {
option (google.api.http) = {
delete: "/v2alpha/users/{user_id}/otp_email"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Remove One-Time-Password (OTP) Email from a user";
description: "Remove the configured One-Time-Password (OTP) Email factor of the authenticated user. As only one OTP Email per user is allowed, the user will not have OTP Email as a second-factor afterward."
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
// Start an IDP authentication (for external login, registration or linking)
rpc StartIdentityProviderFlow (StartIdentityProviderFlowRequest) returns (StartIdentityProviderFlowResponse) {
option (google.api.http) = {
@ -779,6 +869,70 @@ message VerifyTOTPRegistrationResponse {
zitadel.object.v2alpha.Details details = 1;
}
message AddOTPSMSRequest {
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: "\"163840776835432705\"";
}
];
}
message AddOTPSMSResponse {
zitadel.object.v2alpha.Details details = 1;
}
message RemoveOTPSMSRequest {
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: "\"163840776835432705\"";
}
];
}
message RemoveOTPSMSResponse {
zitadel.object.v2alpha.Details details = 1;
}
message AddOTPEmailRequest {
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: "\"163840776835432705\"";
}
];
}
message AddOTPEmailResponse {
zitadel.object.v2alpha.Details details = 1;
}
message RemoveOTPEmailRequest {
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: "\"163840776835432705\"";
}
];
}
message RemoveOTPEmailResponse {
zitadel.object.v2alpha.Details details = 1;
}
message CreatePasskeyRegistrationLinkRequest{
string user_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
@ -979,4 +1133,6 @@ enum AuthenticationMethodType {
AUTHENTICATION_METHOD_TYPE_IDP = 3;
AUTHENTICATION_METHOD_TYPE_TOTP = 4;
AUTHENTICATION_METHOD_TYPE_U2F = 5;
AUTHENTICATION_METHOD_TYPE_OTP_SMS = 6;
AUTHENTICATION_METHOD_TYPE_OTP_EMAIL = 7;
}