Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support MFA with backup codes #269

Merged
merged 12 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docker/kratos/kratos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
77 changes: 77 additions & 0 deletions pkg/kratos/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -199,17 +203,69 @@ 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
}
nsklikas marked this conversation as resolved.
Show resolved Hide resolved

setCookies(w, cookies)

if shouldEnforceMfa {
natalian98 marked this conversation as resolved.
Show resolved Hide resolved
a.mfaSettingsRedirect(w)
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
Expand Down Expand Up @@ -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))
natalian98 marked this conversation as resolved.
Show resolved Hide resolved
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,
},
)
}
natalian98 marked this conversation as resolved.
Show resolved Hide resolved

// TODO: Validate response when server error handling is implemented
func (a *API) handleKratosError(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
Expand Down
61 changes: 61 additions & 0 deletions pkg/kratos/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions pkg/kratos/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
105 changes: 98 additions & 7 deletions pkg/kratos/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
natalian98 marked this conversation as resolved.
Show resolved Hide resolved

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
}
natalian98 marked this conversation as resolved.
Show resolved Hide resolved

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)

Expand Down
Loading
Loading