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/components/BackupCodePdf.tsx b/ui/components/BackupCodePdf.tsx new file mode 100644 index 000000000..d2602acc9 --- /dev/null +++ b/ui/components/BackupCodePdf.tsx @@ -0,0 +1,56 @@ +import React, { FC } from "react"; +import { + Document, + Page, + View, + Text, + StyleSheet, + Image, +} 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) => ( + + {i + 1} + {" "} + {code} + + ))} + + + + ); +}; + +const styles = StyleSheet.create({ + page: { + padding: 30, + fontFamily: "Helvetica", + fontSize: 14, + }, +}); + +export default BackupCodePdf; diff --git a/ui/components/Flow.tsx b/ui/components/Flow.tsx index 4c2e9e3bd..12c49372d 100644 --- a/ui/components/Flow.tsx +++ b/ui/components/Flow.tsx @@ -21,7 +21,13 @@ export type Values = Partial< | UpdateVerificationFlowBody >; -export type Methods = "oidc" | "password" | "code" | "totp" | "webauthn"; +export type Methods = + | "oidc" + | "password" + | "code" + | "totp" + | "webauthn" + | "lookup_secret"; export interface Props { // The flow diff --git a/ui/components/NodeInputSubmit.tsx b/ui/components/NodeInputSubmit.tsx index c1f37f946..ba078c5c7 100644 --- a/ui/components/NodeInputSubmit.tsx +++ b/ui/components/NodeInputSubmit.tsx @@ -33,38 +33,58 @@ 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/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/components/NodeText.tsx b/ui/components/NodeText.tsx index 7140f4b6d..bb885f125 100644 --- a/ui/components/NodeText.tsx +++ b/ui/components/NodeText.tsx @@ -1,7 +1,9 @@ -import { CodeSnippet } from "@canonical/react-components"; +import { Button, CodeSnippet } from "@canonical/react-components"; import { UiNode, UiNodeTextAttributes } from "@ory/client"; import { UiText } from "@ory/client"; -import React, { FC } from "react"; +import React, { FC, useCallback } from "react"; +import ReactPDF from "@react-pdf/renderer"; +import BackupCodePdf from "./BackupCodePdf"; interface Props { node: UiNode; @@ -13,28 +15,58 @@ interface ContextSecrets { } const Content: FC = ({ attributes }) => { + const downloadPdf = useCallback(async (secrets: string[]) => { + const blob = await ReactPDF.pdf().toBlob(); + + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = "backup-codes.pdf"; + link.click(); + + // Clean up the URL object + URL.revokeObjectURL(link.href); + }, []); + + const copySecrets = useCallback((secrets: string[]) => { + const codes = secrets.join("\n"); + void navigator.clipboard.writeText(codes); + }, []); + switch (attributes.text.id) { case 1050015: // 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}
+
+
    + {secrets.map((item, k) => ( +
  1. + {item} +
  2. + ))} +
+
+ + + +
+
); } 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} diff --git a/ui/package-lock.json b/ui/package-lock.json index 42c041c88..bb3ba55eb 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -11,6 +11,7 @@ "@canonical/react-components": "0.51.4", "@ory/client": "1.9.0", "@ory/integrations": "1.1.5", + "@react-pdf/renderer": "^3.4.4", "next": "14.2.1", "react": "18.2.0", "react-toastify": "10.0.5", @@ -140,7 +141,6 @@ }, "node_modules/@babel/runtime": { "version": "7.24.4", - "dev": true, "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -665,6 +665,167 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@react-pdf/fns": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-2.2.1.tgz", + "integrity": "sha512-s78aDg0vDYaijU5lLOCsUD+qinQbfOvcNeaoX9AiE7+kZzzCo6B/nX+l48cmt9OosJmvZvE9DWR9cLhrhOi2pA==", + "dependencies": { + "@babel/runtime": "^7.20.13" + } + }, + "node_modules/@react-pdf/font": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-2.5.1.tgz", + "integrity": "sha512-Hyb2zBb92Glc3lvhmJfy4dO2Mj29KB26Uk12Ua9EhKAdiuCTLBqgP8Oe1cGwrvDI7xA4OOcwvBMdYh0vhOUHzA==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/types": "^2.5.0", + "cross-fetch": "^3.1.5", + "fontkit": "^2.0.2", + "is-url": "^1.2.4" + } + }, + "node_modules/@react-pdf/image": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-2.3.6.tgz", + "integrity": "sha512-7iZDYZrZlJqNzS6huNl2XdMcLFUo68e6mOdzQeJ63d5eApdthhSHBnkGzHfLhH5t8DCpZNtClmklzuLL63ADfw==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/png-js": "^2.3.1", + "cross-fetch": "^3.1.5", + "jay-peg": "^1.0.2" + } + }, + "node_modules/@react-pdf/layout": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-3.12.1.tgz", + "integrity": "sha512-BxSeykDxvADlpe4OGtQ7NH46QXq3uImAYsTHOPLCwbXMniQ1O3uCBx7H+HthxkCNshgYVPp9qS3KyvQv/oIZwg==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "2.2.1", + "@react-pdf/image": "^2.3.6", + "@react-pdf/pdfkit": "^3.1.10", + "@react-pdf/primitives": "^3.1.1", + "@react-pdf/stylesheet": "^4.2.5", + "@react-pdf/textkit": "^4.4.1", + "@react-pdf/types": "^2.5.0", + "cross-fetch": "^3.1.5", + "emoji-regex": "^10.3.0", + "queue": "^6.0.1", + "yoga-layout": "^2.0.1" + } + }, + "node_modules/@react-pdf/layout/node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==" + }, + "node_modules/@react-pdf/pdfkit": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-3.1.10.tgz", + "integrity": "sha512-P/qPBtCFo2HDJD0i6NfbmoBRrsOVO8CIogYsefwG4fklTo50zNgnMM5U1WLckTuX8Qt1ThiQuokmTG5arheblA==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/png-js": "^2.3.1", + "browserify-zlib": "^0.2.0", + "crypto-js": "^4.2.0", + "fontkit": "^2.0.2", + "jay-peg": "^1.0.2", + "vite-compatible-readable-stream": "^3.6.1" + } + }, + "node_modules/@react-pdf/png-js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-2.3.1.tgz", + "integrity": "sha512-pEZ18I4t1vAUS4lmhvXPmXYP4PHeblpWP/pAlMMRkEyP7tdAeHUN7taQl9sf9OPq7YITMY3lWpYpJU6t4CZgZg==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/@react-pdf/primitives": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-3.1.1.tgz", + "integrity": "sha512-miwjxLwTnO3IjoqkTVeTI+9CdyDggwekmSLhVCw+a/7FoQc+gF3J2dSKwsHvAcVFM0gvU8mzCeTofgw0zPDq0w==" + }, + "node_modules/@react-pdf/render": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-3.4.4.tgz", + "integrity": "sha512-CfGxWmVgrY3JgmB1iMnz2W6Ck+8pisZeFt8vGlxP+JfT+0onr208pQvGSV5KwA9LGhAdABxqc/+y17V3vtKdFA==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "2.2.1", + "@react-pdf/primitives": "^3.1.1", + "@react-pdf/textkit": "^4.4.1", + "@react-pdf/types": "^2.5.0", + "abs-svg-path": "^0.1.1", + "color-string": "^1.9.1", + "normalize-svg-path": "^1.1.0", + "parse-svg-path": "^0.1.2", + "svg-arc-to-cubic-bezier": "^3.2.0" + } + }, + "node_modules/@react-pdf/renderer": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-3.4.4.tgz", + "integrity": "sha512-j1TWMHHXDeHdoQE3xjhBh0MZ2rn7wHIlP/uglr/EJZXqnPbfg6bfLzRJCM6bs+XJV3d8+zLQjHf6sF/fWcBDfg==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/font": "^2.5.1", + "@react-pdf/layout": "^3.12.1", + "@react-pdf/pdfkit": "^3.1.10", + "@react-pdf/primitives": "^3.1.1", + "@react-pdf/render": "^3.4.4", + "@react-pdf/types": "^2.5.0", + "events": "^3.3.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "queue": "^6.0.1", + "scheduler": "^0.17.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-pdf/renderer/node_modules/scheduler": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.17.0.tgz", + "integrity": "sha512-7rro8Io3tnCPuY4la/NuI5F2yfESpnfZyT6TtkXnSWVkcu0BCDJ+8gk5ozUaFaxpIyNuWAPXrH0yFcSi28fnDA==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "node_modules/@react-pdf/stylesheet": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-4.2.5.tgz", + "integrity": "sha512-XnmapeCW+hDuNdVwpuvO04WKv71wAs8aH+saIq29Bo2fp1SxznHTcQArTZtK6Wgr/E9BHXeB2iAPpUZuI6G+xA==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "2.2.1", + "@react-pdf/types": "^2.5.0", + "color-string": "^1.9.1", + "hsl-to-hex": "^1.0.0", + "media-engine": "^1.0.3", + "postcss-value-parser": "^4.1.0" + } + }, + "node_modules/@react-pdf/textkit": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-4.4.1.tgz", + "integrity": "sha512-Jl9wdTqIvJ5pX+vAGz0EOhP7ut5Two9H6CzTKo/YYPeD79cM2yTXF3JzTERBC28y7LR0Waq9D2LHQjI+b/EYUQ==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "2.2.1", + "bidi-js": "^1.0.2", + "hyphen": "^1.6.4", + "unicode-properties": "^1.4.1" + } + }, + "node_modules/@react-pdf/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.5.0.tgz", + "integrity": "sha512-XsVRkt0hQ60I4e3leAVt+aZR3KJCaJd179BfJHAv4F4x6Vq3yqkry8lcbUWKGKDw1j3/8sW4FsgGR41SFvsG9A==" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "license": "MIT" @@ -1213,6 +1374,11 @@ "version": "2.0.6", "license": "BSD-3-Clause" }, + "node_modules/abs-svg-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==" + }, "node_modules/acorn": { "version": "8.11.3", "license": "MIT", @@ -1549,6 +1715,25 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "license": "BSD-3-Clause", @@ -1556,6 +1741,14 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "license": "MIT", @@ -1595,6 +1788,22 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.23.0", "dev": true, @@ -1754,6 +1963,14 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.0", "license": "MIT", @@ -1775,6 +1992,15 @@ "version": "1.1.4", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "license": "MIT", @@ -1801,6 +2027,14 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "dev": true, @@ -1814,6 +2048,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/css.escape": { "version": "1.5.1", "dev": true, @@ -1999,6 +2238,11 @@ "node": ">=6" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==" + }, "node_modules/diff-sequences": { "version": "29.6.3", "license": "MIT", @@ -2521,6 +2765,14 @@ "node": ">=0.10.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expect": { "version": "29.7.0", "license": "MIT", @@ -2662,6 +2914,30 @@ } } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/fontkit/node_modules/@swc/helpers": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.12.tgz", + "integrity": "sha512-KMZNXiGibsW9kvZAO1Pam2JPTDBm+KSHMMHWdsyI/1DbIZjT2A6Gy3hblVXUMEDvUAKq+e0vL0X0o54owWji7g==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/for-each": { "version": "0.3.3", "dev": true, @@ -3091,6 +3367,19 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "peer": true }, + "node_modules/hsl-to-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz", + "integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==", + "dependencies": { + "hsl-to-rgb-for-reals": "^1.1.0" + } + }, + "node_modules/hsl-to-rgb-for-reals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz", + "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==" + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "license": "MIT", @@ -3137,6 +3426,11 @@ "node": ">= 6" } }, + "node_modules/hyphen": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.10.4.tgz", + "integrity": "sha512-SejXzIpv9gOVdDWXd4suM1fdF1k2dxZGvuTdkOVLoazYfK7O4DykIQbdrvuyG+EaTNlXAGhMndtKrhykgbt0gg==" + }, "node_modules/iconv-lite": { "version": "0.6.3", "license": "MIT", @@ -3205,8 +3499,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/internal-slot": { "version": "1.0.7", @@ -3236,6 +3529,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, "node_modules/is-async-function": { "version": "2.0.0", "dev": true, @@ -3527,6 +3825,11 @@ "version": "1.0.0", "license": "MIT" }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==" + }, "node_modules/is-weakmap": { "version": "2.0.2", "dev": true, @@ -3621,6 +3924,14 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jay-peg": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.0.2.tgz", + "integrity": "sha512-fyV3NVvv6pTys/3BTapBUGAWAuU9rM2gRcgijZHzptd5KKL+s+S7hESFN+wOsbDH1MzFwdlRAXi0aGxS6uiMKg==", + "dependencies": { + "restructure": "^3.0.0" + } + }, "node_modules/jest-diff": { "version": "29.7.0", "license": "MIT", @@ -3962,6 +4273,11 @@ "lz-string": "bin/bin.js" } }, + "node_modules/media-engine": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz", + "integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==" + }, "node_modules/merge2": { "version": "1.4.1", "dev": true, @@ -4124,6 +4440,44 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.14", "dev": true, @@ -4144,6 +4498,14 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, "node_modules/nwsapi": { "version": "2.2.7", "license": "MIT" @@ -4313,6 +4675,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4325,6 +4692,11 @@ "node": ">=6" } }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==" + }, "node_modules/parse5": { "version": "7.1.2", "license": "MIT", @@ -4560,7 +4932,6 @@ }, "node_modules/postcss-value-parser": { "version": "4.2.0", - "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -4665,6 +5036,14 @@ "version": "2.2.0", "license": "MIT" }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "dev": true, @@ -4802,7 +5181,6 @@ }, "node_modules/regenerator-runtime": { "version": "0.14.1", - "dev": true, "license": "MIT" }, "node_modules/regexp.prototype.flags": { @@ -4871,6 +5249,14 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "license": "MIT" @@ -4900,6 +5286,11 @@ "node": ">=4" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==" + }, "node_modules/reusify": { "version": "1.0.4", "dev": true, @@ -5151,6 +5542,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/slash": { "version": "3.0.0", "license": "MIT", @@ -5219,6 +5618,14 @@ "node": ">=10.0.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "dev": true, @@ -5405,6 +5812,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==" + }, "node_modules/symbol-tree": { "version": "3.2.4", "license": "MIT" @@ -5444,6 +5856,11 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", @@ -5654,6 +6071,29 @@ "version": "5.26.5", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, "node_modules/unicorn-magic": { "version": "0.1.0", "dev": true, @@ -5725,6 +6165,11 @@ "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/uuid": { "version": "3.4.0", "license": "MIT", @@ -5755,6 +6200,19 @@ ], "license": "MIT" }, + "node_modules/vite-compatible-readable-stream": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz", + "integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "license": "MIT", @@ -6017,6 +6475,11 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yoga-layout": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-2.0.1.tgz", + "integrity": "sha512-tT/oChyDXelLo2A+UVnlW9GU7CsvFMaEnd9kVFsaiCQonFAXd3xrHhkLYu+suwwosrAEQ746xBU+HvYtm1Zs2Q==" } } } diff --git a/ui/package.json b/ui/package.json index d56dc07bd..3467b7b2d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -6,6 +6,7 @@ "@canonical/react-components": "0.51.4", "@ory/client": "1.9.0", "@ory/integrations": "1.1.5", + "@react-pdf/renderer": "^3.4.4", "next": "14.2.1", "react": "18.2.0", "react-toastify": "10.0.5", diff --git a/ui/pages/backup_codes_regenerate.tsx b/ui/pages/backup_codes_regenerate.tsx new file mode 100644 index 000000000..e585f4e66 --- /dev/null +++ b/ui/pages/backup_codes_regenerate.tsx @@ -0,0 +1,57 @@ +import type { NextPage } from "next"; +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 ( + +

+ You've just used a backup code. Would you like to generate new ones + to ensure you have a full set? +

+

+ Generating new codes will invalidate all previous codes. +

+
+ + +
+
+ ); +}; + +export default BackupCodesRegenerate; diff --git a/ui/pages/login.tsx b/ui/pages/login.tsx index cfb472416..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(() => { @@ -86,6 +87,9 @@ const Login: NextPage = () => { if (values.method === "webauthn") { return "webauthn"; } + if (values.method === "lookup_secret") { + return "lookup_secret"; + } if (isAuthCode) { return "totp"; } @@ -142,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 new file mode 100644 index 000000000..2294b1996 --- /dev/null +++ b/ui/pages/setup_backup_codes.tsx @@ -0,0 +1,117 @@ +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 { 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 } = 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) { + window.location.href = `./setup_backup_codes?flow=${data.id}`; + 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) => { + 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(() => { + if (methodValues.lookup_secret_confirm) { + window.location.href = "./setup_complete"; + } else { + setFlow(undefined); // Reset the flow to trigger refresh + } + }) + .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 ( + + {flow ? : } + + ); +}; + +export default SetupBackupCodes; diff --git a/ui/public/backup-codes-header.png b/ui/public/backup-codes-header.png new file mode 100644 index 000000000..a1cd8fb33 Binary files /dev/null and b/ui/public/backup-codes-header.png differ diff --git a/ui/static/sass/styles.scss b/ui/static/sass/styles.scss index a2beff774..cc966ba79 100644 --- a/ui/static/sass/styles.scss +++ b/ui/static/sass/styles.scss @@ -37,3 +37,7 @@ $breakpoint-navigation-threshold: 820px; .password-label { width: 100%; } + +.backup-codes li:before { + font-weight: 700; +} 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) { 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", + ), }, }, };