Skip to content

Commit

Permalink
feat(v2): implement user register OTP (#6030)
Browse files Browse the repository at this point in the history
* feat(v2): implement user register OTP

* feat(v2): implement user verify OTP

* session: retry on permission denied
  • Loading branch information
muhlemmer committed Jun 20, 2023
1 parent 4eaf3fb commit 09aafb3
Show file tree
Hide file tree
Showing 10 changed files with 1,113 additions and 12 deletions.
38 changes: 38 additions & 0 deletions internal/api/grpc/user/v2/otp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package user

import (
"context"

"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/domain"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)

func (s *Server) RegisterOTP(ctx context.Context, req *user.RegisterOTPRequest) (*user.RegisterOTPResponse, error) {
return otpDetailsToPb(
s.command.AddUserOTP(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner),
)

}

func otpDetailsToPb(otp *domain.OTPv2, err error) (*user.RegisterOTPResponse, error) {
if err != nil {
return nil, err
}
return &user.RegisterOTPResponse{
Details: object.DomainToDetailsPb(otp.ObjectDetails),
Uri: otp.URI,
Secret: otp.Secret,
}, nil
}

func (s *Server) VerifyOTPRegistration(ctx context.Context, req *user.VerifyOTPRegistrationRequest) (*user.VerifyOTPRegistrationResponse, error) {
objectDetails, err := s.command.CheckUserOTP(ctx, req.GetUserId(), req.GetCode(), authz.GetCtxData(ctx).ResourceOwner)
if err != nil {
return nil, err
}
return &user.VerifyOTPRegistrationResponse{
Details: object.DomainToDetailsPb(objectDetails),
}, nil
}
155 changes: 155 additions & 0 deletions internal/api/grpc/user/v2/otp_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//go:build integration

package user_test

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/zitadel/zitadel/internal/integration"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)

func TestServer_RegisterOTP(t *testing.T) {
// userID := Tester.CreateHumanUser(CTX).GetUserId()

type args struct {
ctx context.Context
req *user.RegisterOTPRequest
}
tests := []struct {
name string
args args
want *user.RegisterOTPResponse
wantErr bool
}{
{
name: "missing user id",
args: args{
ctx: CTX,
req: &user.RegisterOTPRequest{},
},
wantErr: true,
},
{
name: "user mismatch",
args: args{
ctx: CTX,
req: &user.RegisterOTPRequest{
UserId: "wrong",
},
},
wantErr: true,
},
/* TODO: after we are able to obtain a Bearer token for a human user
https://github.com/zitadel/zitadel/issues/6022
{
name: "human user",
args: args{
ctx: CTX,
req: &user.RegisterOTPRequest{
UserId: userID,
},
},
want: &user.RegisterOTPResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
*/
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.RegisterOTP(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)
assert.NotEmpty(t, got.GetUri())
assert.NotEmpty(t, got.GetSecret())
})
}
}

func TestServer_VerifyOTPRegistration(t *testing.T) {
userID := Tester.CreateHumanUser(CTX).GetUserId()

/* TODO: after we are able to obtain a Bearer token for a human user
reg, err := Client.RegisterOTP(CTX, &user.RegisterOTPRequest{
UserId: userID,
})
require.NoError(t, err)
code, err := totp.GenerateCode(reg.Secret, time.Now())
require.NoError(t, err)
*/

type args struct {
ctx context.Context
req *user.VerifyOTPRegistrationRequest
}
tests := []struct {
name string
args args
want *user.VerifyOTPRegistrationResponse
wantErr bool
}{
{
name: "user mismatch",
args: args{
ctx: CTX,
req: &user.VerifyOTPRegistrationRequest{
UserId: "wrong",
},
},
wantErr: true,
},
{
name: "wrong code",
args: args{
ctx: CTX,
req: &user.VerifyOTPRegistrationRequest{
UserId: userID,
Code: "123",
},
},
wantErr: true,
},
/* TODO: after we are able to obtain a Bearer token for a human user
https://github.com/zitadel/zitadel/issues/6022
{
name: "success",
args: args{
ctx: CTX,
req: &user.VerifyOTPRegistrationRequest{
UserId: userID,
Code: code,
},
},
want: &user.VerifyOTPRegistrationResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ResourceOwner,
},
},
},
*/
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.VerifyOTPRegistration(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)
})
}
}
71 changes: 71 additions & 0 deletions internal/api/grpc/user/v2/otp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package user

import (
"io"
"testing"
"time"

"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"

"github.com/zitadel/zitadel/internal/domain"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)

func Test_otpDetailsToPb(t *testing.T) {
type args struct {
otp *domain.OTPv2
err error
}
tests := []struct {
name string
args args
want *user.RegisterOTPResponse
wantErr error
}{
{
name: "error",
args: args{
err: io.ErrClosedPipe,
},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
args: args{
otp: &domain.OTPv2{
ObjectDetails: &domain.ObjectDetails{
Sequence: 123,
EventDate: time.Unix(456, 789),
ResourceOwner: "me",
},
Secret: "secret",
URI: "URI",
},
},
want: &user.RegisterOTPResponse{
Details: &object.Details{
Sequence: 123,
ChangeDate: &timestamppb.Timestamp{
Seconds: 456,
Nanos: 789,
},
ResourceOwner: "me",
},
Secret: "secret",
Uri: "URI",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := otpDetailsToPb(tt.args.otp, tt.args.err)
require.ErrorIs(t, err, tt.wantErr)
if !proto.Equal(tt.want, got) {
t.Errorf("RegisterOTPResponse =\n%v\nwant\n%v", got, tt.want)
}
})
}
}
12 changes: 12 additions & 0 deletions internal/command/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ func expectPushFailed(err error, events []*repository.Event, uniqueConstraints .
}
}

func expectRandomPush(events []*repository.Event, uniqueConstraints ...*repository.UniqueConstraint) expect {
return func(m *mock.MockRepository) {
m.ExpectRandomPush(events, uniqueConstraints...)
}
}

func expectRandomPushFailed(err error, events []*repository.Event, uniqueConstraints ...*repository.UniqueConstraint) expect {
return func(m *mock.MockRepository) {
m.ExpectRandomPushFailed(err, events, uniqueConstraints...)
}
}

func expectFilter(events ...*repository.Event) expect {
return func(m *mock.MockRepository) {
m.ExpectFilterEvents(events...)
Expand Down
46 changes: 35 additions & 11 deletions internal/command/user_human_otp.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package command
import (
"context"

"github.com/pquerna/otp"
"github.com/zitadel/logging"

"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
Expand Down Expand Up @@ -43,7 +45,32 @@ func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string
if userID == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-5M0sd", "Errors.User.UserIDMissing")
}
human, err := c.getHuman(ctx, userID, resourceowner)
prep, err := c.createHumanOTP(ctx, userID, resourceowner)
if err != nil {
return nil, err
}
_, err = c.eventstore.Push(ctx, 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(),
}, nil
}

type preparedOTP struct {
wm *HumanOTPWriteModel
userAgg *eventstore.Aggregate
key *otp.Key
cmds []eventstore.Command
}

func (c *Commands) createHumanOTP(ctx context.Context, userID, resourceOwner string) (*preparedOTP, 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")
return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-MM9fs", "Errors.User.NotFound")
Expand All @@ -59,7 +86,7 @@ func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string
return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-8ugTs", "Errors.Org.DomainPolicy.NotFound")
}

otpWriteModel, err := c.otpWriteModelByID(ctx, userID, resourceowner)
otpWriteModel, err := c.otpWriteModelByID(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
Expand All @@ -80,16 +107,13 @@ func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string
if err != nil {
return nil, err
}
_, err = c.eventstore.Push(ctx, user.NewHumanOTPAddedEvent(ctx, userAgg, secret))
if err != nil {
return nil, err
}
return &domain.OTP{
ObjectRoot: models.ObjectRoot{
AggregateID: human.AggregateID,
return &preparedOTP{
wm: otpWriteModel,
userAgg: userAgg,
key: key,
cmds: []eventstore.Command{
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
},
SecretString: key.Secret(),
Url: key.URL(),
}, nil
}

Expand Down

1 comment on commit 09aafb3

@vercel
Copy link

@vercel vercel bot commented on 09aafb3 Jun 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

docs – ./

zitadel-docs.vercel.app
docs-git-main-zitadel.vercel.app
docs-zitadel.vercel.app

Please sign in to comment.