From 45738a8ab87e57ee9ded79d070552f00f8ffb50b Mon Sep 17 00:00:00 2001 From: natalia Date: Wed, 21 Aug 2024 13:21:17 +0200 Subject: [PATCH 01/12] feat: support lookup_secret method, offer user to regenerate the set if 3 or less codes are left --- docker/kratos/kratos.yml | 8 +- pkg/kratos/handlers.go | 77 ++++++++++++++ pkg/kratos/handlers_test.go | 61 +++++++++++ pkg/kratos/interfaces.go | 1 + pkg/kratos/service.go | 105 +++++++++++++++++-- pkg/kratos/service_test.go | 203 ++++++++++++++++++++++++++++++++++++ ui/util/handleFlowError.ts | 4 + 7 files changed, 449 insertions(+), 10 deletions(-) diff --git a/docker/kratos/kratos.yml b/docker/kratos/kratos.yml index b72a5c834..0e7ab4a88 100644 --- a/docker/kratos/kratos.yml +++ b/docker/kratos/kratos.yml @@ -55,9 +55,11 @@ selfservice: - hook: session methods: totp: - enabled: true - config: - issuer: Identity Platform + enabled: true + config: + issuer: Identity Platform + lookup_secret: + enabled: true password: enabled: True config: diff --git a/pkg/kratos/handlers.go b/pkg/kratos/handlers.go index 6357001db..cdbda0b5b 100644 --- a/pkg/kratos/handlers.go +++ b/pkg/kratos/handlers.go @@ -6,14 +6,18 @@ import ( "fmt" "net/http" "net/url" + "path" "strconv" "github.com/go-chi/chi/v5" client "github.com/ory/kratos-client-go" "github.com/canonical/identity-platform-login-ui/internal/logging" + "github.com/canonical/identity-platform-login-ui/pkg/ui" ) +const RegenerateBackupCodesError = "regenerate_backup_codes" + type API struct { mfaEnabled bool service ServiceInterface @@ -199,6 +203,15 @@ func (a *API) handleUpdateFlow(w http.ResponseWriter, r *http.Request) { return } + shouldRegenerateBackupCodes, err := a.shouldRegenerateBackupCodes(r.Context(), cookies) + if err != nil { + err = fmt.Errorf("error when checking backup codes: %v", err) + + a.logger.Error(err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + setCookies(w, cookies) if shouldEnforceMfa { @@ -206,10 +219,53 @@ func (a *API) handleUpdateFlow(w http.ResponseWriter, r *http.Request) { return } + if shouldRegenerateBackupCodes { + a.lookupSecretsSettingsRedirect(w, flowId) + return + } + w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(flow) } +func (a *API) shouldRegenerateBackupCodes(ctx context.Context, cookies []*http.Cookie) (bool, error) { + // skip the check if mfa is not enabled + if !a.mfaEnabled { + return false, nil + } + + session, _, err := a.service.CheckSession(ctx, cookies) + + if err != nil { + if a.is40xError(err) { + a.logger.Debugf("check session failed: %v", err) + return false, nil + } + + return false, err + } + + authnMethods := session.AuthenticationMethods + if len(authnMethods) < 2 { + a.logger.Debugf("User has not yet completed 2fa") + return false, nil + } + + aal2AuthenticationMethod := authnMethods[1].Method + + if aal2AuthenticationMethod == nil || *aal2AuthenticationMethod != "lookup_secret" { + return false, nil + } + + // check the backup codes only if aal2 method was lookup_secret + shouldRegenerateBackupCodes, err := a.service.HasNotEnoughLookupSecretsLeft(ctx, session.Identity.GetId()) + if err != nil { + return false, err + } + + return shouldRegenerateBackupCodes, nil +} + func (a *API) shouldEnforceMFA(ctx context.Context, cookies []*http.Cookie) (bool, error) { if !a.mfaEnabled { return false, nil @@ -279,6 +335,27 @@ func (a *API) mfaSettingsRedirect(w http.ResponseWriter) { ) } +func (a *API) lookupSecretsSettingsRedirect(w http.ResponseWriter, flowId string) { + redirectUrl, err := url.Parse(path.Join("/", a.contextPath, ui.UI, "/backup_codes_regenerate?flow="+flowId)) + if err != nil { + err = fmt.Errorf("unable to build backup codes redirect path, possible misconfiguration, err: %v", err) + a.logger.Error(err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + redirect := redirectUrl.String() + errorId := RegenerateBackupCodesError + + w.WriteHeader(http.StatusSeeOther) + _ = json.NewEncoder(w).Encode( + ErrorBrowserLocationChangeRequired{ + Error: &client.GenericError{Id: &errorId}, + RedirectBrowserTo: &redirect, + }, + ) +} + // TODO: Validate response when server error handling is implemented func (a *API) handleKratosError(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() diff --git a/pkg/kratos/handlers_test.go b/pkg/kratos/handlers_test.go index c5e2ddea5..e4aca4a35 100644 --- a/pkg/kratos/handlers_test.go +++ b/pkg/kratos/handlers_test.go @@ -483,6 +483,67 @@ func TestHandleUpdateFlowFailOnParseLoginFlowMethodBody(t *testing.T) { } } +func TestHandleUpdateLoginFlowRedirectToRegenerateBackupCodes(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + session := kClient.NewSession("test", *kClient.NewIdentity("test", "test.json", "https://test.com/test.json", map[string]string{"name": "name"})) + + lookupMethod := kClient.NewSessionAuthenticationMethodWithDefaults() + lookupMethod.SetMethod("lookup_secret") + + pwdMethod := kClient.NewSessionAuthenticationMethodWithDefaults() + pwdMethod.SetMethod("password") + + session.SetAuthenticatorAssuranceLevel("aal2") + session.AuthenticationMethods = []kClient.SessionAuthenticationMethod{*pwdMethod, *lookupMethod} + + flowId := "test" + redirectTo := "https://some/path/to/somewhere" + redirectFlow := new(BrowserLocationChangeRequired) + redirectFlow.RedirectTo = &redirectTo + + flow := kClient.NewLoginFlowWithDefaults() + flow.Id = flowId + + flowBody := new(kClient.UpdateLoginFlowBody) + flowBody.UpdateLoginFlowWithLookupSecretMethod = kClient.NewUpdateLoginFlowWithLookupSecretMethod("xt879l1a", "lookup_secret") + + req := httptest.NewRequest(http.MethodPost, HANDLE_UPDATE_LOGIN_FLOW_URL, nil) + values := req.URL.Query() + values.Add("flow", flowId) + req.URL.RawQuery = values.Encode() + + mockService.EXPECT().ParseLoginFlowMethodBody(gomock.Any()).Return(flowBody, nil) + mockService.EXPECT().GetLoginFlow(gomock.Any(), flowId, req.Cookies()).Return(flow, nil, nil) + mockService.EXPECT().CheckAllowedProvider(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockService.EXPECT().UpdateLoginFlow(gomock.Any(), flowId, *flowBody, req.Cookies()).Return(redirectFlow, req.Cookies(), nil) + + mockService.EXPECT().CheckSession(gomock.Any(), req.Cookies()).Return(session, nil, nil) + mockService.EXPECT().HasTOTPAvailable(gomock.Any(), gomock.Any()).Return(true, nil) + + mockService.EXPECT().CheckSession(gomock.Any(), req.Cookies()).Return(session, nil, nil) + mockService.EXPECT().HasNotEnoughLookupSecretsLeft(gomock.Any(), session.Identity.GetId()).Return(true, nil) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, true, BASE_URL, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + + if _, err := json.Marshal(flow); err != nil { + t.Fatalf("Expected error to be nil got %v", err) + } + if res.StatusCode != http.StatusSeeOther { + t.Fatal("Expected HTTP status code 303, got: ", res.Status) + } +} + func TestHandleUpdateFlowFailOnUpdateOIDCLoginFlow(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/pkg/kratos/interfaces.go b/pkg/kratos/interfaces.go index 9dcb0d5f5..4c2063ddb 100644 --- a/pkg/kratos/interfaces.go +++ b/pkg/kratos/interfaces.go @@ -45,4 +45,5 @@ type ServiceInterface interface { ParseRecoveryFlowMethodBody(*http.Request) (*kClient.UpdateRecoveryFlowBody, error) ParseSettingsFlowMethodBody(*http.Request) (*kClient.UpdateSettingsFlowBody, error) HasTOTPAvailable(context.Context, string) (bool, error) + HasNotEnoughLookupSecretsLeft(context.Context, string) (bool, error) } diff --git a/pkg/kratos/service.go b/pkg/kratos/service.go index 0d3b2ecda..0957e7614 100644 --- a/pkg/kratos/service.go +++ b/pkg/kratos/service.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "time" hClient "github.com/ory/hydra-client-go/v2" kClient "github.com/ory/kratos-client-go" @@ -19,13 +20,17 @@ import ( ) const ( - IncorrectCredentials = 4000006 - InactiveAccount = 4000010 - InvalidRecoveryCode = 4060006 - RecoveryCodeSent = 1060003 - InvalidProperty = 4000002 - InvalidAuthCode = 4000008 - MissingSecurityKeySetup = 4000015 + IncorrectCredentials = 4000006 + InactiveAccount = 4000010 + InvalidRecoveryCode = 4060006 + RecoveryCodeSent = 1060003 + InvalidProperty = 4000002 + InvalidAuthCode = 4000008 + MissingSecurityKeySetup = 4000015 + BackupCodeAlreadyUsed = 4000012 + InvalidBackupCode = 4000016 + MissingBackupCodesSetup = 4000014 + MinimumBackupCodesAmount = 3 ) type Service struct { @@ -70,6 +75,11 @@ type methodOnly struct { Method string `json:"method"` } +type LookupSecrets []struct { + Code string `json:"code"` + UsedAt time.Time `json:"used_at,omitempty"` +} + func (s *Service) CheckSession(ctx context.Context, cookies []*http.Cookie) (*kClient.Session, []*http.Cookie, error) { ctx, span := s.tracer.Start(ctx, "kratos.Service.ToSession") defer span.End() @@ -401,6 +411,12 @@ func (s *Service) getUiError(responseBody io.ReadCloser) (err error) { err = fmt.Errorf("invalid authentication code") case MissingSecurityKeySetup: err = fmt.Errorf("choose a different login method") + case BackupCodeAlreadyUsed: + err = fmt.Errorf("this backup code was already used") + case InvalidBackupCode: + err = fmt.Errorf("invalid backup code") + case MissingBackupCodesSetup: + err = fmt.Errorf("login with backup codes unavailable") default: err = fmt.Errorf("unknown kratos error code: %v", errorCode) } @@ -539,6 +555,18 @@ func (s *Service) ParseLoginFlowMethodBody(r *http.Request) (*kClient.UpdateLogi ret = kClient.UpdateLoginFlowWithWebAuthnMethodAsUpdateLoginFlowBody( body, ) + case "lookup_secret": + body := new(kClient.UpdateLoginFlowWithLookupSecretMethod) + + err := parseBody(r.Body, &body) + + if err != nil { + return nil, err + } + + ret = kClient.UpdateLoginFlowWithLookupSecretMethodAsUpdateLoginFlowBody( + body, + ) // method field is empty for oidc: https://github.com/ory/kratos/pull/3564 default: body := new(kClient.UpdateLoginFlowWithOidcMethod) @@ -628,6 +656,18 @@ func (s *Service) ParseSettingsFlowMethodBody(r *http.Request) (*kClient.UpdateS ret = kClient.UpdateSettingsFlowWithWebAuthnMethodAsUpdateSettingsFlowBody( body, ) + case "lookup_secret": + body := new(kClient.UpdateSettingsFlowWithLookupMethod) + + err := parseBody(r.Body, &body) + + if err != nil { + return nil, err + } + + ret = kClient.UpdateSettingsFlowWithLookupMethodAsUpdateSettingsFlowBody( + body, + ) } return &ret, nil @@ -657,6 +697,57 @@ func (s *Service) HasTOTPAvailable(ctx context.Context, id string) (bool, error) return ok, nil } +func (s *Service) HasNotEnoughLookupSecretsLeft(ctx context.Context, id string) (bool, error) { + + identity, _, err := s.kratosAdmin.IdentityApi(). + GetIdentity(ctx, id). + IncludeCredential([]string{"lookup_secret"}). + Execute() + + if err != nil { + return false, err + } + + lookupSecret, ok := identity.GetCredentials()["lookup_secret"] + if !ok { + s.logger.Debugf("User has no lookup secret credentials") + return false, nil + } + + lookupCredentials, ok := lookupSecret.Config["recovery_codes"] + if !ok { + s.logger.Debugf("Recovery codes unavailable") + return false, nil + } + + jsonbody, err := json.Marshal(lookupCredentials) + if err != nil { + s.logger.Errorf("Marshalling to json failed: %s", err) + return false, err + } + + lookupSecrets := new(LookupSecrets) + if err := json.Unmarshal(jsonbody, &lookupSecrets); err != nil { + s.logger.Errorf("Unmarshalling failed: %s", err) + return false, err + } + + unusedCodes := 0 + for _, code := range *lookupSecrets { + if code.UsedAt.IsZero() { + unusedCodes += 1 + } + } + + if unusedCodes > MinimumBackupCodesAmount { + return false, nil + } + + s.logger.Debugf("Only %d backup codes are left, redirect the user to generate a new set", unusedCodes) + + return true, nil +} + func NewService(kratos KratosClientInterface, kratosAdmin KratosAdminClientInterface, hydra HydraClientInterface, authzClient AuthorizerInterface, tracer tracing.TracingInterface, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *Service { s := new(Service) diff --git a/pkg/kratos/service_test.go b/pkg/kratos/service_test.go index 168a7c074..d64d2121e 100644 --- a/pkg/kratos/service_test.go +++ b/pkg/kratos/service_test.go @@ -24,6 +24,7 @@ import ( //go:generate mockgen -build_flags=--mod=mod -package kratos -destination ./mock_monitor.go -source=../../internal/monitoring/interfaces.go //go:generate mockgen -build_flags=--mod=mod -package kratos -destination ./mock_tracing.go -source=../../internal/tracing/interfaces.go //go:generate mockgen -build_flags=--mod=mod -package kratos -destination ./mock_kratos.go github.com/ory/kratos-client-go FrontendApi +//go:generate mockgen -build_flags=--mod=mod -package kratos -destination ./mock_identity.go github.com/ory/kratos-client-go IdentityApi //go:generate mockgen -build_flags=--mod=mod -package kratos -destination ./mock_hydra.go -source=../../internal/hydra/interfaces.go func TestCheckSessionSuccess(t *testing.T) { @@ -543,6 +544,60 @@ func TestUpdateLoginFlowErrorWebAuthnNotSet(t *testing.T) { } } +func TestUpdateLoginFlowErrorWhenBackupCodesNotSet(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockHydra := NewMockHydraClientInterface(ctrl) + mockKratos := NewMockKratosClientInterface(ctrl) + mockAdminKratos := NewMockKratosAdminClientInterface(ctrl) + mockAuthz := NewMockAuthorizerInterface(ctrl) + mockTracer := NewMockTracingInterface(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockKratosFrontendApi := NewMockFrontendApi(ctrl) + + ctx := context.Background() + cookies := make([]*http.Cookie, 0) + cookie := &http.Cookie{Name: "test", Value: "test"} + cookies = append(cookies, cookie) + flowId := "flow" + body := new(kClient.UpdateLoginFlowBody) + + request := kClient.FrontendApiUpdateLoginFlowRequest{ + ApiService: mockKratosFrontendApi, + } + errorBody := &UiErrorMessages{ + Ui: kClient.UiContainer{ + Messages: []kClient.UiText{ + { + Id: MissingBackupCodesSetup, + }, + }, + }, + } + errorBodyJson, _ := json.Marshal(errorBody) + resp := http.Response{ + Body: io.NopCloser(bytes.NewBuffer(errorBodyJson)), + StatusCode: 400, + } + + mockTracer.EXPECT().Start(ctx, "kratos.Service.UpdateLoginFlow").Times(1).Return(ctx, trace.SpanFromContext(ctx)) + mockKratos.EXPECT().FrontendApi().Times(1).Return(mockKratosFrontendApi) + mockKratosFrontendApi.EXPECT().UpdateLoginFlow(ctx).Times(1).Return(request) + mockKratosFrontendApi.EXPECT().UpdateLoginFlowExecute(gomock.Any()).Times(1).Return(nil, &resp, fmt.Errorf("error")) + + _, _, err := NewService(mockKratos, mockAdminKratos, mockHydra, mockAuthz, mockTracer, mockMonitor, mockLogger).UpdateLoginFlow(ctx, flowId, *body, cookies) + + if err == nil { + t.Fatalf("expected error not nil") + } + expectedError := fmt.Errorf("login with backup codes unavailable") + if err.Error() != expectedError.Error() { + t.Fatalf("expected error to be %v not %v", expectedError, err) + } +} + func TestUpdateLoginFlowFail(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -1107,6 +1162,40 @@ func TestParseLoginFlowTotpMethodBody(t *testing.T) { } } +func TestParseLoginFlowLookupSecretMethodBody(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockHydra := NewMockHydraClientInterface(ctrl) + mockKratos := NewMockKratosClientInterface(ctrl) + mockAdminKratos := NewMockKratosAdminClientInterface(ctrl) + mockAuthz := NewMockAuthorizerInterface(ctrl) + mockTracer := NewMockTracingInterface(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + + flow := kClient.NewUpdateLoginFlowWithLookupSecretMethodWithDefaults() + flow.SetMethod("lookup_secret") + + body := kClient.UpdateLoginFlowWithLookupSecretMethodAsUpdateLoginFlowBody(flow) + + jsonBody, _ := body.MarshalJSON() + + req := httptest.NewRequest(http.MethodPost, "http://some/path", io.NopCloser(bytes.NewBuffer(jsonBody))) + + b, err := NewService(mockKratos, mockAdminKratos, mockHydra, mockAuthz, mockTracer, mockMonitor, mockLogger).ParseLoginFlowMethodBody(req) + + actual, _ := b.MarshalJSON() + expected, _ := body.MarshalJSON() + + if !reflect.DeepEqual(string(actual), string(expected)) { + t.Fatalf("expected flow to be %s not %s", string(expected), string(actual)) + } + if err != nil { + t.Fatalf("expected error to be nil not %v", err) + } +} + func TestParseLoginFlowWebAuthnMethodBody(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -1652,6 +1741,40 @@ func TestParseSettingsFlowTotpMethodBody(t *testing.T) { } } +func TestParseSettingsFlowLookupMethodBody(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockHydra := NewMockHydraClientInterface(ctrl) + mockKratos := NewMockKratosClientInterface(ctrl) + mockAdminKratos := NewMockKratosAdminClientInterface(ctrl) + mockAuthz := NewMockAuthorizerInterface(ctrl) + mockTracer := NewMockTracingInterface(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + + flow := kClient.NewUpdateSettingsFlowWithLookupMethodWithDefaults() + flow.SetMethod("lookup_secret") + + body := kClient.UpdateSettingsFlowWithLookupMethodAsUpdateSettingsFlowBody(flow) + + jsonBody, _ := body.MarshalJSON() + + req := httptest.NewRequest(http.MethodPost, "http://some/path", io.NopCloser(bytes.NewBuffer(jsonBody))) + + b, err := NewService(mockKratos, mockAdminKratos, mockHydra, mockAuthz, mockTracer, mockMonitor, mockLogger).ParseSettingsFlowMethodBody(req) + + actual, _ := b.MarshalJSON() + expected, _ := body.MarshalJSON() + + if !reflect.DeepEqual(string(actual), string(expected)) { + t.Fatalf("expected flow to be %s not %s", string(expected), string(actual)) + } + if err != nil { + t.Fatalf("expected error to be nil not %v", err) + } +} + func TestParseSettingsFlowWebAuthnMethodBody(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -1981,3 +2104,83 @@ func TestUpdateSettingsFlowFailOnUpdateSettingsFlowExecute(t *testing.T) { t.Fatalf("expected error not nil") } } + +func TestHasNotEnoughLookupSecretsLeftSuccess(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockHydra := NewMockHydraClientInterface(ctrl) + mockKratos := NewMockKratosClientInterface(ctrl) + mockAdminKratos := NewMockKratosAdminClientInterface(ctrl) + mockAuthz := NewMockAuthorizerInterface(ctrl) + mockTracer := NewMockTracingInterface(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockKratosIdentityApi := NewMockIdentityApi(ctrl) + + ctx := context.Background() + cookie := &http.Cookie{Name: "test", Value: "test"} + resp := http.Response{ + Header: http.Header{"Set-Cookie": []string{cookie.Raw}}, + } + identityRequest := kClient.IdentityApiGetIdentityRequest{ + ApiService: mockKratosIdentityApi, + } + identity := kClient.Identity{ + Id: "test", + } + + mockAdminKratos.EXPECT().IdentityApi().Times(1).Return(mockKratosIdentityApi) + mockKratosIdentityApi.EXPECT().GetIdentity(ctx, gomock.Any()).Times(1).Return(identityRequest) + mockKratosIdentityApi.EXPECT().GetIdentityExecute(gomock.Any()).Times(1).DoAndReturn( + func(r kClient.IdentityApiGetIdentityRequest) (*kClient.Identity, *http.Response, error) { + return &identity, &resp, nil + }, + ) + mockLogger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Times(1) + + hasNotEnoughLookupSecretsLeft, err := NewService(mockKratos, mockAdminKratos, mockHydra, mockAuthz, mockTracer, mockMonitor, mockLogger).HasNotEnoughLookupSecretsLeft(ctx, "test") + + if hasNotEnoughLookupSecretsLeft != false { + t.Fatalf("expected return value to be false not %v", hasNotEnoughLookupSecretsLeft) + } + if err != nil { + t.Fatalf("expected error to be nil not %v", err) + } +} + +func TestHasNotEnoughLookupSecretsLeftFailonGetIdentityExecute(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockHydra := NewMockHydraClientInterface(ctrl) + mockKratos := NewMockKratosClientInterface(ctrl) + mockAdminKratos := NewMockKratosAdminClientInterface(ctrl) + mockAuthz := NewMockAuthorizerInterface(ctrl) + mockTracer := NewMockTracingInterface(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockKratosIdentityApi := NewMockIdentityApi(ctrl) + + ctx := context.Background() + cookie := &http.Cookie{Name: "test", Value: "test"} + resp := http.Response{ + Header: http.Header{"Set-Cookie": []string{cookie.Raw}}, + } + identityRequest := kClient.IdentityApiGetIdentityRequest{ + ApiService: mockKratosIdentityApi, + } + + mockAdminKratos.EXPECT().IdentityApi().Times(1).Return(mockKratosIdentityApi) + mockKratosIdentityApi.EXPECT().GetIdentity(ctx, gomock.Any()).Times(1).Return(identityRequest) + mockKratosIdentityApi.EXPECT().GetIdentityExecute(gomock.Any()).Times(1).Return(nil, &resp, fmt.Errorf("error")) + + hasNotEnoughLookupSecretsLeft, err := NewService(mockKratos, mockAdminKratos, mockHydra, mockAuthz, mockTracer, mockMonitor, mockLogger).HasNotEnoughLookupSecretsLeft(ctx, "test") + + if hasNotEnoughLookupSecretsLeft != false { + t.Fatalf("expected return value to be false not %v", hasNotEnoughLookupSecretsLeft) + } + if err == nil { + t.Fatalf("expected error not nil") + } +} diff --git a/ui/util/handleFlowError.ts b/ui/util/handleFlowError.ts index af0e9c818..a324859db 100644 --- a/ui/util/handleFlowError.ts +++ b/ui/util/handleFlowError.ts @@ -71,6 +71,10 @@ export const handleFlowError = // Ory Kratos asked us to point the user to this URL. window.location.href = err.response.data.redirect_browser_to; return; + case "regenerate_backup_codes": + // The user logged in with a lookup secret and is running out of backup codes, redirect to generate a new set + window.location.href = err.response.data.redirect_browser_to; + return; } switch (err.response?.status) { From 2e58ed5b05174a311d121b54f7070d128468bf6f Mon Sep 17 00:00:00 2001 From: natalia Date: Wed, 21 Aug 2024 13:22:03 +0200 Subject: [PATCH 02/12] ui: support backup codes --- ui/components/Flow.tsx | 9 +- ui/components/NodeInputSubmit.tsx | 3 +- ui/components/NodeInputText.tsx | 1 + ui/pages/login.tsx | 3 + ui/pages/setup_backup_codes.tsx | 137 ++++++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 ui/pages/setup_backup_codes.tsx diff --git a/ui/components/Flow.tsx b/ui/components/Flow.tsx index 4c2e9e3bd..3ec338204 100644 --- a/ui/components/Flow.tsx +++ b/ui/components/Flow.tsx @@ -21,7 +21,14 @@ export type Values = Partial< | UpdateVerificationFlowBody >; -export type Methods = "oidc" | "password" | "code" | "totp" | "webauthn"; +export type Methods = + | "oidc" + | "password" + | "code" + | "totp" + | "webauthn" + | "lookup" + | "lookup_secret"; export interface Props { // The flow diff --git a/ui/components/NodeInputSubmit.tsx b/ui/components/NodeInputSubmit.tsx index c1f37f946..fd9624442 100644 --- a/ui/components/NodeInputSubmit.tsx +++ b/ui/components/NodeInputSubmit.tsx @@ -40,7 +40,8 @@ export const NodeInputSubmit: FC = ({ node.group === "password" || node.group === "code" || node.group === "totp" || - node.group === "webauthn" + node.group === "webauthn" || + node.group === "lookup_secret" ? "positive" : "" } diff --git a/ui/components/NodeInputText.tsx b/ui/components/NodeInputText.tsx index 0cc7de0f0..1e062916c 100644 --- a/ui/components/NodeInputText.tsx +++ b/ui/components/NodeInputText.tsx @@ -25,6 +25,7 @@ export const NodeInputText: FC = ({ attributes.name === "code" || attributes.name === "totp" || attributes.name === "totp_code" || + attributes.name === "lookup_secret" || (isWebauthn && attributes.name === "identifier") ? error : undefined diff --git a/ui/pages/login.tsx b/ui/pages/login.tsx index cfb472416..1b3ae3050 100644 --- a/ui/pages/login.tsx +++ b/ui/pages/login.tsx @@ -86,6 +86,9 @@ const Login: NextPage = () => { if (values.method === "webauthn") { return "webauthn"; } + if (values.method === "lookup_secret") { + return "lookup_secret"; + } if (isAuthCode) { return "totp"; } diff --git a/ui/pages/setup_backup_codes.tsx b/ui/pages/setup_backup_codes.tsx new file mode 100644 index 000000000..43a2e4dbd --- /dev/null +++ b/ui/pages/setup_backup_codes.tsx @@ -0,0 +1,137 @@ +import { + SettingsFlow, + UpdateSettingsFlowBody, + UpdateSettingsFlowWithLookupMethod, +} from "@ory/client"; +import type { NextPage } from "next"; +import { useRouter } from "next/router"; +import { useEffect, useState, useCallback } from "react"; +import React from "react"; +import { handleFlowError } from "../util/handleFlowError"; +import { Flow } from "../components/Flow"; +import { kratos } from "../api/kratos"; +import PageLayout from "../components/PageLayout"; +import { AxiosError } from "axios"; +import { Notification, Spinner } from "@canonical/react-components"; +import { UiNodeInputAttributes } from "@ory/client/api"; + +const SetupBackupCodes: NextPage = () => { + const [flow, setFlow] = useState(); + + // Get ?flow=... from the URL + const router = useRouter(); + const { + return_to: returnTo, + flow: flowId, + pw_changed: pwChanged, + } = router.query; + + useEffect(() => { + // If the router is not ready yet, or we already have a flow, do nothing. + if (!router.isReady || flow) { + return; + } + + // If ?flow=... was in the URL, we fetch it + if (flowId) { + kratos + .getSettingsFlow({ id: String(flowId) }) + .then((res) => setFlow(res.data)) + .catch(handleFlowError("settings", setFlow)); + return; + } + + // Otherwise we initialize it + kratos + .createBrowserSettingsFlow({ + returnTo: returnTo ? String(returnTo) : undefined, + }) + .then(({ data }) => { + if (data.request_url !== undefined) { + const pwParam = pwChanged + ? `&pw_changed=${pwChanged.toString()}` + : ""; + window.location.href = `./setup_backup_codes?flow=${data.id}${pwParam}`; + return; + } + setFlow(data); + }) + .catch(handleFlowError("settings", setFlow)) + .catch(async (err: AxiosError) => { + if (err.response?.data.trim() === "Failed to create settings flow") { + setFlow(undefined); + window.location.href = "./login"; + return; + } + + return Promise.reject(err); + }); + }, [flowId, router, router.isReady, returnTo, flow]); + + const handleSubmit = useCallback( + (values: UpdateSettingsFlowBody) => { + console.log(values); + const methodValues = values as UpdateSettingsFlowWithLookupMethod; + return kratos + .updateSettingsFlow({ + flow: String(flow?.id), + updateSettingsFlowBody: { + csrf_token: (flow?.ui?.nodes[0].attributes as UiNodeInputAttributes) + .value as string, + method: "lookup_secret", + lookup_secret_reveal: methodValues.lookup_secret_reveal + ? true + : undefined, + lookup_secret_confirm: methodValues.lookup_secret_confirm + ? true + : undefined, + lookup_secret_regenerate: methodValues.lookup_secret_regenerate + ? true + : undefined, + lookup_secret_disable: methodValues.lookup_secret_disable + ? true + : undefined, + }, + }) + .then(({ data }) => { + if (flow?.state === "success") { + window.location.href = "./setup_complete"; + } + if ("redirect_to" in data) { + window.location.href = data.redirect_to as string; + return; + } + if (flow?.return_to) { + window.location.href = flow.return_to; + return; + } + window.location.href = "./error"; + }) + .catch(handleFlowError("settings", setFlow)); + }, + [flow, router], + ); + + const lookupFlow = { + ...flow, + ui: { + ...flow?.ui, + nodes: flow?.ui.nodes.filter(({ group }) => { + return group === "lookup_secret"; + }), + }, + } as SettingsFlow; + + return ( + + {pwChanged === "success" && ( + + Password was changed successfully + + )} + {flow ? : } + + ); +}; + +export default SetupBackupCodes; From 008a17338b7a4c25d718343d0d75a35b0b995d94 Mon Sep 17 00:00:00 2001 From: David Edler Date: Thu, 22 Aug 2024 13:27:53 +0200 Subject: [PATCH 03/12] fix ui: ensure backup codes can be used on login and can be generated, regenerated and viewed Signed-off-by: David Edler --- ui/components/NodeInputSubmit.tsx | 78 +++++++++++++++++++------------ ui/components/NodeText.tsx | 51 +++++++++++++++----- ui/pages/login.tsx | 20 +++++++- ui/pages/setup_backup_codes.tsx | 34 ++------------ ui/util/replaceAuthLabel.ts | 3 ++ 5 files changed, 113 insertions(+), 73 deletions(-) diff --git a/ui/components/NodeInputSubmit.tsx b/ui/components/NodeInputSubmit.tsx index fd9624442..9431202d2 100644 --- a/ui/components/NodeInputSubmit.tsx +++ b/ui/components/NodeInputSubmit.tsx @@ -33,39 +33,55 @@ export const NodeInputSubmit: FC = ({ const isProvider = attributes.name === "provider"; const provider = attributes.value as string; const image = getProviderImage(provider); + const showBackupLink = + (node.meta.label as unknown as { hasBackupLink: boolean })?.hasBackupLink ?? + false; return ( - + {showBackupLink && ( + )} - + ); }; diff --git a/ui/components/NodeText.tsx b/ui/components/NodeText.tsx index 7140f4b6d..952762b8e 100644 --- a/ui/components/NodeText.tsx +++ b/ui/components/NodeText.tsx @@ -1,4 +1,4 @@ -import { CodeSnippet } from "@canonical/react-components"; +import { Button, CodeSnippet, List } from "@canonical/react-components"; import { UiNode, UiNodeTextAttributes } from "@ory/client"; import { UiText } from "@ory/client"; import React, { FC } from "react"; @@ -18,23 +18,50 @@ const Content: FC = ({ attributes }) => { // This text node contains lookup secrets. Let's make them a bit more beautiful! // eslint-disable-next-line no-case-declarations const secrets = (attributes.text.context as ContextSecrets).secrets.map( - (text, k) => ( -
- {/* Used lookup_secret has ID 1050014 */} - {text.id === 1050014 ? "Used" : text.text} -
- ), + (text) => { + return text.id === 1050014 ? "Used" : text.text; + }, ); + return (
-
{secrets}
+
+ +
+ + + +
+
); } diff --git a/ui/pages/login.tsx b/ui/pages/login.tsx index 1b3ae3050..b49f28a05 100644 --- a/ui/pages/login.tsx +++ b/ui/pages/login.tsx @@ -34,6 +34,7 @@ const Login: NextPage = () => { // to perform two-factor authentication/verification. aal, login_challenge, + use_backup_code: useBackupCode, } = router.query; useEffect(() => { @@ -145,7 +146,24 @@ const Login: NextPage = () => { const title = isAuthCode ? "Verify your identity" : `Sign in${getTitleSuffix()}`; - const renderFlow = isAuthCode ? replaceAuthLabel(flow) : flow; + + const filterFlow = (flow: LoginFlow | undefined): LoginFlow => { + if (!flow) { + return flow as unknown as LoginFlow; + } + + return { + ...flow, + ui: { + ...flow.ui, + nodes: flow.ui.nodes.filter(({ group }) => { + return useBackupCode ? group !== "totp" : group !== "lookup_secret"; + }), + }, + }; + }; + + const renderFlow = isAuthCode ? filterFlow(replaceAuthLabel(flow)) : flow; let isWebauthn = false; if (renderFlow?.ui) { diff --git a/ui/pages/setup_backup_codes.tsx b/ui/pages/setup_backup_codes.tsx index 43a2e4dbd..d29f9b6a6 100644 --- a/ui/pages/setup_backup_codes.tsx +++ b/ui/pages/setup_backup_codes.tsx @@ -12,7 +12,7 @@ import { Flow } from "../components/Flow"; import { kratos } from "../api/kratos"; import PageLayout from "../components/PageLayout"; import { AxiosError } from "axios"; -import { Notification, Spinner } from "@canonical/react-components"; +import { Spinner } from "@canonical/react-components"; import { UiNodeInputAttributes } from "@ory/client/api"; const SetupBackupCodes: NextPage = () => { @@ -20,11 +20,7 @@ const SetupBackupCodes: NextPage = () => { // Get ?flow=... from the URL const router = useRouter(); - const { - return_to: returnTo, - flow: flowId, - pw_changed: pwChanged, - } = router.query; + const { return_to: returnTo, flow: flowId } = router.query; useEffect(() => { // If the router is not ready yet, or we already have a flow, do nothing. @@ -48,10 +44,7 @@ const SetupBackupCodes: NextPage = () => { }) .then(({ data }) => { if (data.request_url !== undefined) { - const pwParam = pwChanged - ? `&pw_changed=${pwChanged.toString()}` - : ""; - window.location.href = `./setup_backup_codes?flow=${data.id}${pwParam}`; + window.location.href = `./setup_backup_codes?flow=${data.id}`; return; } setFlow(data); @@ -70,7 +63,6 @@ const SetupBackupCodes: NextPage = () => { const handleSubmit = useCallback( (values: UpdateSettingsFlowBody) => { - console.log(values); const methodValues = values as UpdateSettingsFlowWithLookupMethod; return kratos .updateSettingsFlow({ @@ -93,19 +85,8 @@ const SetupBackupCodes: NextPage = () => { : undefined, }, }) - .then(({ data }) => { - if (flow?.state === "success") { - window.location.href = "./setup_complete"; - } - if ("redirect_to" in data) { - window.location.href = data.redirect_to as string; - return; - } - if (flow?.return_to) { - window.location.href = flow.return_to; - return; - } - window.location.href = "./error"; + .then(() => { + setFlow(undefined); // Reset the flow to trigger refresh }) .catch(handleFlowError("settings", setFlow)); }, @@ -124,11 +105,6 @@ const SetupBackupCodes: NextPage = () => { return ( - {pwChanged === "success" && ( - - Password was changed successfully - - )} {flow ? : } ); diff --git a/ui/util/replaceAuthLabel.ts b/ui/util/replaceAuthLabel.ts index 2d8ec75a7..1c745904a 100644 --- a/ui/util/replaceAuthLabel.ts +++ b/ui/util/replaceAuthLabel.ts @@ -19,6 +19,9 @@ export const replaceAuthLabel = ( label: { ...node.meta.label, text: "Sign in", + hasBackupLink: flow.ui.nodes.some( + (item) => item.group === "lookup_secret", + ), }, }, }; From 7f1c95d9c96f6353a2dabbbb4c0a41e6b4f03717 Mon Sep 17 00:00:00 2001 From: David Edler Date: Thu, 22 Aug 2024 18:32:28 +0200 Subject: [PATCH 04/12] feat ui: download backup codes as pdf document Signed-off-by: David Edler --- ui/components/BackupCodePdf.tsx | 32 +++ ui/components/NodeText.tsx | 32 ++- ui/package-lock.json | 473 +++++++++++++++++++++++++++++++- ui/package.json | 1 + 4 files changed, 521 insertions(+), 17 deletions(-) create mode 100644 ui/components/BackupCodePdf.tsx diff --git a/ui/components/BackupCodePdf.tsx b/ui/components/BackupCodePdf.tsx new file mode 100644 index 000000000..99953c377 --- /dev/null +++ b/ui/components/BackupCodePdf.tsx @@ -0,0 +1,32 @@ +import React, { FC } from "react"; +import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer"; + +interface Props { + codes: string[]; +} + +const BackupCodePdf: FC = ({ codes }) => { + return ( + + + + + These are your back up recovery codes. Please keep them in a safe + place! + + {codes.map((code, i) => ( + {code} + ))} + + + + ); +}; + +const styles = StyleSheet.create({ + page: { + padding: 30, + }, +}); + +export default BackupCodePdf; diff --git a/ui/components/NodeText.tsx b/ui/components/NodeText.tsx index 952762b8e..a913311d5 100644 --- a/ui/components/NodeText.tsx +++ b/ui/components/NodeText.tsx @@ -2,6 +2,8 @@ import { Button, CodeSnippet, List } from "@canonical/react-components"; import { UiNode, UiNodeTextAttributes } from "@ory/client"; import { UiText } from "@ory/client"; import React, { FC } from "react"; +import ReactPDF from "@react-pdf/renderer"; +import BackupCodePdf from "./BackupCodePdf"; interface Props { node: UiNode; @@ -29,21 +31,27 @@ const Content: FC = ({ attributes }) => { data-testid={`node/text/${attributes.id}/text`} >
- + ( + {item} + ))} + divided + />
+ +
+ + ); +}; + +export default BackupCodesRegenerate; From 3f2f0b7b7a217ac5f2ed3db977df6b8e30d8266e Mon Sep 17 00:00:00 2001 From: David Edler Date: Fri, 23 Aug 2024 09:56:44 +0200 Subject: [PATCH 06/12] fix ui: add favicon Signed-off-by: David Edler --- ui/components/PageLayout.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ui/components/PageLayout.tsx b/ui/components/PageLayout.tsx index 69bad765f..06252bbe1 100644 --- a/ui/components/PageLayout.tsx +++ b/ui/components/PageLayout.tsx @@ -15,6 +15,18 @@ const PageLayout: FC = ({ children, title }) => { return ( <> + + {title} From 3968f4475f06716d7378b6fb9942c29b78e094bb Mon Sep 17 00:00:00 2001 From: David Edler Date: Fri, 23 Aug 2024 09:57:02 +0200 Subject: [PATCH 07/12] fix ui: improve pdf with logo and spacing Signed-off-by: David Edler --- ui/components/BackupCodePdf.tsx | 19 +++++++++++++++---- ui/components/NodeInputSubmit.tsx | 4 +++- ui/public/logos/Canonical.png | Bin 0 -> 13315 bytes 3 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 ui/public/logos/Canonical.png diff --git a/ui/components/BackupCodePdf.tsx b/ui/components/BackupCodePdf.tsx index 99953c377..a7b2ba860 100644 --- a/ui/components/BackupCodePdf.tsx +++ b/ui/components/BackupCodePdf.tsx @@ -1,5 +1,12 @@ import React, { FC } from "react"; -import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer"; +import { + Document, + Page, + View, + Text, + StyleSheet, + Image, +} from "@react-pdf/renderer"; interface Props { codes: string[]; @@ -10,9 +17,13 @@ const BackupCodePdf: FC = ({ codes }) => { - - These are your back up recovery codes. Please keep them in a safe - place! + + These are your back up recovery codes. + + Please keep them in a safe place! {codes.map((code, i) => ( {code} diff --git a/ui/components/NodeInputSubmit.tsx b/ui/components/NodeInputSubmit.tsx index 9431202d2..167e13661 100644 --- a/ui/components/NodeInputSubmit.tsx +++ b/ui/components/NodeInputSubmit.tsx @@ -56,7 +56,9 @@ export const NodeInputSubmit: FC = ({ ); }} disabled={attributes.disabled || disabled} - className={node.group === "oidc" ? "oidc-login-button" : ""} + className={ + node.group === "oidc" ? "oidc-login-button u-no-print" : "u-no-print" + } > {isProvider ? ( <> diff --git a/ui/public/logos/Canonical.png b/ui/public/logos/Canonical.png new file mode 100644 index 0000000000000000000000000000000000000000..4e11f94abd68d64e57c8448949246b3317c48f05 GIT binary patch literal 13315 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 ReLVz6Thl<}%VWFO{{=fa|Jncm literal 0 HcmV?d00001 From eae7fa7303e0a78ed7b6d11d80e74f6d4f3220f9 Mon Sep 17 00:00:00 2001 From: David Edler Date: Tue, 27 Aug 2024 10:11:22 +0200 Subject: [PATCH 08/12] fix ui: regenerate backup codes screen to allow continue sign in process Signed-off-by: David Edler --- ui/pages/backup_codes_regenerate.tsx | 30 ++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/ui/pages/backup_codes_regenerate.tsx b/ui/pages/backup_codes_regenerate.tsx index dc04b85e5..e585f4e66 100644 --- a/ui/pages/backup_codes_regenerate.tsx +++ b/ui/pages/backup_codes_regenerate.tsx @@ -1,9 +1,35 @@ import type { NextPage } from "next"; -import React from "react"; +import React, { useEffect, useState } from "react"; import PageLayout from "../components/PageLayout"; import { Button } from "@canonical/react-components"; +import { useRouter } from "next/router"; +import { kratos } from "../api/kratos"; +import { handleFlowError } from "../util/handleFlowError"; +import { LoginFlow } from "@ory/client"; const BackupCodesRegenerate: NextPage = () => { + const [flow, setFlow] = useState(); + + const router = useRouter(); + const { flow: flowId } = router.query; + + useEffect(() => { + // If the router is not ready yet, or we already have a flow, do nothing. + if (!router.isReady || flow) { + return; + } + + if (flowId) { + kratos + .getLoginFlow({ id: String(flowId) }) + .then((res) => setFlow(res.data)) + .catch(handleFlowError("login", setFlow)); + return; + } + }, [flowId, router, router.isReady, flow]); + + const signInUrl = flow?.return_to ?? "./login"; + return (

@@ -18,7 +44,7 @@ const BackupCodesRegenerate: NextPage = () => { Generate new codes -