diff --git a/backend/Dockerfile b/backend/Dockerfile index da38c943..dd0d7423 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -17,6 +17,7 @@ COPY crypto crypto/ COPY dto dto/ COPY session session/ COPY mail mail/ +COPY audit_log audit_log/ # Build RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o hanko main.go diff --git a/backend/audit_log/client.go b/backend/audit_log/client.go new file mode 100644 index 00000000..2f3bafa0 --- /dev/null +++ b/backend/audit_log/client.go @@ -0,0 +1,70 @@ +package auditlog + +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/labstack/echo/v4" + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type Client interface { + Create(echo.Context, models.AuditLogType, *models.User, error) error +} + +type client struct { + persister persistence.Persister + enableStoring bool +} + +func NewClient(persister persistence.Persister, config config.AuditLog) Client { + return &client{ + persister: persister, + enableStoring: config.EnableStoring, + } +} + +func (c *client) Create(context echo.Context, auditLogType models.AuditLogType, user *models.User, logError error) error { + var err error = nil + if c.enableStoring { + err = c.store(context, auditLogType, user, logError) + if err != nil { + return err + } + } + + // TODO: log each auditLogType (logrus, zerolog, ...) + + return nil +} + +func (c *client) store(context echo.Context, auditLogType models.AuditLogType, user *models.User, logError error) error { + id, err := uuid.NewV4() + if err != nil { + return fmt.Errorf("failed to create id: %w", err) + } + var userId *uuid.UUID = nil + var userEmail = "" + if user != nil { + userId = &user.ID + userEmail = user.Email + } + errString := "" + if logError != nil { + // check if error is not nil, because else the string (formatted with fmt.Sprintf) would not be empty but look like this: `%!s()` + errString = fmt.Sprintf("%s", logError) + } + e := models.AuditLog{ + ID: id, + Type: auditLogType, + Error: errString, + MetaHttpRequestId: context.Response().Header().Get(echo.HeaderXRequestID), + MetaUserAgent: context.Request().UserAgent(), + MetaSourceIp: context.RealIP(), + ActorUserId: userId, + ActorEmail: userEmail, + } + + return c.persister.GetAuditLogPersister().Create(e) +} diff --git a/backend/config/config.go b/backend/config/config.go index 9188d804..21fddcd3 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -22,6 +22,7 @@ type Config struct { Secrets Secrets `yaml:"secrets" json:"secrets" koanf:"secrets"` Service Service `yaml:"service" json:"service" koanf:"service"` Session Session `yaml:"session" json:"session" koanf:"session"` + AuditLog AuditLog `yaml:"audit_log" json:"audit_log" koanf:"audit_log"` } func Load(cfgFile *string) (*Config, error) { @@ -315,3 +316,7 @@ func (s *Session) Validate() error { } return nil } + +type AuditLog struct { + EnableStoring bool `yaml:"enable_storing" json:"enable_storing" koanf:"enable_storing"` +} diff --git a/backend/handler/audit_log.go b/backend/handler/audit_log.go new file mode 100644 index 00000000..166d2da2 --- /dev/null +++ b/backend/handler/audit_log.go @@ -0,0 +1,41 @@ +package handler + +import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/teamhanko/hanko/backend/dto" + "github.com/teamhanko/hanko/backend/persistence" + "net/http" +) + +type AuditLogHandler struct { + persister persistence.Persister +} + +func NewAuditLogHandler(persister persistence.Persister) *AuditLogHandler { + return &AuditLogHandler{ + persister: persister, + } +} + +type AuditLogListRequest struct { + Page int `query:"page"` + PerPage int `query:"per_page"` +} + +func (h AuditLogHandler) List(c echo.Context) error { + var request AuditLogListRequest + err := (&echo.DefaultBinder{}).BindQueryParams(c, &request) + if err != nil { + return dto.ToHttpError(err) + } + + auditLogs, err := h.persister.GetAuditLogPersister().List(request.Page, request.PerPage) + if err != nil { + return fmt.Errorf("failed to get list of audit logs: %w", err) + } + + // TODO: maybe change return format to more structured json + + return c.JSON(http.StatusOK, auditLogs) +} diff --git a/backend/handler/passcode.go b/backend/handler/passcode.go index cd3aa126..5d15b885 100644 --- a/backend/handler/passcode.go +++ b/backend/handler/passcode.go @@ -6,6 +6,7 @@ import ( "github.com/gobuffalo/pop/v6" "github.com/gofrs/uuid" "github.com/labstack/echo/v4" + "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/crypto" "github.com/teamhanko/hanko/backend/dto" @@ -29,11 +30,12 @@ type PasscodeHandler struct { TTL int sessionManager session.Manager cfg *config.Config + auditLogClient auditlog.Client } var maxPasscodeTries = 3 -func NewPasscodeHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, mailer mail.Mailer) (*PasscodeHandler, error) { +func NewPasscodeHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, mailer mail.Mailer, auditLogClient auditlog.Client) (*PasscodeHandler, error) { renderer, err := mail.NewRenderer() if err != nil { return nil, fmt.Errorf("failed to create new renderer: %w", err) @@ -48,6 +50,7 @@ func NewPasscodeHandler(cfg *config.Config, persister persistence.Persister, ses TTL: cfg.Passcode.TTL, sessionManager: sessionManager, cfg: cfg, + auditLogClient: auditLogClient, }, nil } @@ -71,6 +74,10 @@ func (h *PasscodeHandler) Init(c echo.Context) error { return fmt.Errorf("failed to get user: %w", err) } if user == nil { + err = h.auditLogClient.Create(c, models.AuditLogPasscodeLoginInitFailed, nil, fmt.Errorf("unknown user")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusBadRequest).SetInternal(errors.New("user not found")) } @@ -128,6 +135,11 @@ func (h *PasscodeHandler) Init(c echo.Context) error { return fmt.Errorf("failed to send passcode: %w", err) } + err = h.auditLogClient.Create(c, models.AuditLogPasscodeLoginInitSucceeded, user, nil) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } + return c.JSON(http.StatusOK, dto.PasscodeReturn{ Id: passcodeId.String(), TTL: h.TTL, @@ -151,7 +163,7 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { return dto.NewHTTPError(http.StatusBadRequest, "failed to parse passcodeId as uuid").SetInternal(err) } - // only if an internal server occurs the transaction should be rolled back + // only if an internal server error occurs the transaction should be rolled back var businessError error transactionError := h.persister.Transaction(func(tx *pop.Connection) error { passcodePersister := h.persister.GetPasscodePersisterWithConnection(tx) @@ -161,12 +173,25 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { return fmt.Errorf("failed to get passcode: %w", err) } if passcode == nil { + err = h.auditLogClient.Create(c, models.AuditLogPasscodeLoginFailed, nil, fmt.Errorf("unknown passcode")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } businessError = dto.NewHTTPError(http.StatusNotFound, "passcode not found") return nil } + user, err := userPersister.Get(passcode.UserId) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + lastVerificationTime := passcode.CreatedAt.Add(time.Duration(passcode.Ttl) * time.Second) if lastVerificationTime.Before(startTime) { + err = h.auditLogClient.Create(c, models.AuditLogPasscodeLoginFailed, user, fmt.Errorf("timed out passcode")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } businessError = dto.NewHTTPError(http.StatusRequestTimeout, "passcode request timed out").SetInternal(errors.New(fmt.Sprintf("createdAt: %s -> lastVerificationTime: %s", passcode.CreatedAt, lastVerificationTime))) // TODO: maybe we should use BadRequest, because RequestTimeout might be to technical and can refer to different error return nil } @@ -180,6 +205,10 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { if err != nil { return fmt.Errorf("failed to delete passcode: %w", err) } + err = h.auditLogClient.Create(c, models.AuditLogPasscodeLoginFailed, user, fmt.Errorf("max attempts reached")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } businessError = dto.NewHTTPError(http.StatusGone, "max attempts reached") return nil } @@ -189,6 +218,10 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { return fmt.Errorf("failed to update passcode: %w", err) } + err = h.auditLogClient.Create(c, models.AuditLogPasscodeLoginFailed, user, fmt.Errorf("passcode invalid")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } businessError = dto.NewHTTPError(http.StatusUnauthorized).SetInternal(errors.New("passcode invalid")) return nil } @@ -198,11 +231,6 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { return fmt.Errorf("failed to delete passcode: %w", err) } - user, err := userPersister.Get(passcode.UserId) - if err != nil { - return fmt.Errorf("failed to get user: %w", err) - } - if !user.Verified { user.Verified = true err = userPersister.Update(*user) @@ -227,6 +255,11 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { c.Response().Header().Set("X-Auth-Token", token) } + err = h.auditLogClient.Create(c, models.AuditLogPasscodeLoginSucceeded, user, nil) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } + return c.JSON(http.StatusOK, dto.PasscodeReturn{ Id: passcode.ID.String(), TTL: passcode.Ttl, diff --git a/backend/handler/passcode_test.go b/backend/handler/passcode_test.go index eac06688..7b6870a0 100644 --- a/backend/handler/passcode_test.go +++ b/backend/handler/passcode_test.go @@ -19,13 +19,13 @@ import ( ) func TestNewPasscodeHandler(t *testing.T) { - passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}) + passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(nil, nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogClient()) assert.NoError(t, err) assert.NotEmpty(t, passcodeHandler) } func TestPasscodeHandler_Init(t *testing.T) { - passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, nil, nil, nil, nil, nil), sessionManager{}, mailer{}) + passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogClient()) require.NoError(t, err) body := dto.PasscodeInitRequest{ @@ -47,7 +47,7 @@ func TestPasscodeHandler_Init(t *testing.T) { } func TestPasscodeHandler_Init_UnknownUserId(t *testing.T) { - passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, nil, nil, nil, nil, nil), sessionManager{}, mailer{}) + passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, nil, nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogClient()) require.NoError(t, err) body := dto.PasscodeInitRequest{ @@ -71,7 +71,7 @@ func TestPasscodeHandler_Init_UnknownUserId(t *testing.T) { } func TestPasscodeHandler_Finish(t *testing.T) { - passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil), sessionManager{}, mailer{}) + passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogClient()) require.NoError(t, err) body := dto.PasscodeFinishRequest{ @@ -94,7 +94,7 @@ func TestPasscodeHandler_Finish(t *testing.T) { } func TestPasscodeHandler_Finish_WrongCode(t *testing.T) { - passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil), sessionManager{}, mailer{}) + passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogClient()) require.NoError(t, err) body := dto.PasscodeFinishRequest{ @@ -119,7 +119,7 @@ func TestPasscodeHandler_Finish_WrongCode(t *testing.T) { } func TestPasscodeHandler_Finish_WrongCode_3_Times(t *testing.T) { - passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil), sessionManager{}, mailer{}) + passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogClient()) require.NoError(t, err) body := dto.PasscodeFinishRequest{ @@ -153,7 +153,7 @@ func TestPasscodeHandler_Finish_WrongCode_3_Times(t *testing.T) { } func TestPasscodeHandler_Finish_WrongId(t *testing.T) { - passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil), sessionManager{}, mailer{}) + passcodeHandler, err := NewPasscodeHandler(&config.Config{}, test.NewPersister(users, passcodes(), nil, nil, nil, nil, nil), sessionManager{}, mailer{}, test.NewAuditLogClient()) require.NoError(t, err) body := dto.PasscodeFinishRequest{ diff --git a/backend/handler/password.go b/backend/handler/password.go index a9f8f452..e4496954 100644 --- a/backend/handler/password.go +++ b/backend/handler/password.go @@ -7,6 +7,7 @@ import ( "github.com/gofrs/uuid" "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/dto" "github.com/teamhanko/hanko/backend/persistence" @@ -21,13 +22,15 @@ type PasswordHandler struct { persister persistence.Persister sessionManager session.Manager cfg *config.Config + auditLogClient auditlog.Client } -func NewPasswordHandler(persister persistence.Persister, sessionManager session.Manager, cfg *config.Config) *PasswordHandler { +func NewPasswordHandler(persister persistence.Persister, sessionManager session.Manager, cfg *config.Config, auditLogClient auditlog.Client) *PasswordHandler { return &PasswordHandler{ persister: persister, sessionManager: sessionManager, cfg: cfg, + auditLogClient: auditLogClient, } } @@ -56,28 +59,45 @@ func (h *PasswordHandler) Set(c echo.Context) error { return dto.NewHTTPError(http.StatusBadRequest, "failed to parse userId as uuid").SetInternal(err) } + user, err := h.persister.GetUserPersister().Get(uuid.FromStringOrNil(body.UserID)) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + pwBytes := []byte(body.Password) if utf8.RuneCountInString(body.Password) < h.cfg.Password.MinPasswordLength { // use utf8.RuneCountInString, so utf8 characters would count as 1 + err = h.auditLogClient.Create(c, models.AuditLogPasswordSetFailed, user, fmt.Errorf("password too short")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("password must be at least %d characters long", h.cfg.Password.MinPasswordLength)) } + if len(pwBytes) > 72 { + err = h.auditLogClient.Create(c, models.AuditLogPasswordSetFailed, user, fmt.Errorf("password too long")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusBadRequest, "password must not be longer than 72 bytes") } - return h.persister.Transaction(func(tx *pop.Connection) error { - user, err := h.persister.GetUserPersisterWithConnection(tx).Get(uuid.FromStringOrNil(body.UserID)) + if user == nil { + err = h.auditLogClient.Create(c, models.AuditLogPasswordSetFailed, user, fmt.Errorf("unknown user: %s", body.UserID)) if err != nil { - return fmt.Errorf("failed to get user: %w", err) + return fmt.Errorf("failed to create audit log: %w", err) } + return dto.NewHTTPError(http.StatusUnauthorized).SetInternal(errors.New(fmt.Sprintf("user %s not found ", sessionUserId))) + } - if user == nil { - return dto.NewHTTPError(http.StatusUnauthorized).SetInternal(errors.New(fmt.Sprintf("user %s not found ", sessionUserId))) - } - - if sessionUserId != user.ID { - return dto.NewHTTPError(http.StatusForbidden).SetInternal(errors.New(fmt.Sprintf("session.userId %s tried to set password credentials for body.userId %s", sessionUserId, user.ID))) + if sessionUserId != user.ID { + err = h.auditLogClient.Create(c, models.AuditLogPasswordSetFailed, user, fmt.Errorf("wrong user: expected %s -> got %s", sessionUserId, user.ID)) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) } + return dto.NewHTTPError(http.StatusForbidden).SetInternal(errors.New(fmt.Sprintf("session.userId %s tried to set password credentials for body.userId %s", sessionUserId, user.ID))) + } + return h.persister.Transaction(func(tx *pop.Connection) error { pwPersister := h.persister.GetPasswordCredentialPersisterWithConnection(tx) pw, err := pwPersister.GetByUserID(user.ID) if err != nil { @@ -99,6 +119,10 @@ func (h *PasswordHandler) Set(c echo.Context) error { if err != nil { return fmt.Errorf("failed to create password: %w", err) } else { + err = h.auditLogClient.Create(c, models.AuditLogPasswordSetSucceeded, user, nil) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return c.JSON(http.StatusCreated, nil) } } else { @@ -107,6 +131,10 @@ func (h *PasswordHandler) Set(c echo.Context) error { if err != nil { return fmt.Errorf("failed to set password: %w", err) } else { + err = h.auditLogClient.Create(c, models.AuditLogPasswordSetSucceeded, user, nil) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return c.JSON(http.StatusOK, nil) } } @@ -128,13 +156,38 @@ func (h *PasswordHandler) Login(c echo.Context) error { return dto.ToHttpError(err) } + userId, err := uuid.FromString(body.UserId) + if err != nil { + return dto.NewHTTPError(http.StatusBadRequest, "user_id is not a uuid").SetInternal(err) + } + + user, err := h.persister.GetUserPersister().Get(userId) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + if user == nil { + err = h.auditLogClient.Create(c, models.AuditLogPasswordLoginFailed, nil, fmt.Errorf("unknown user: %s", userId)) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } + return dto.NewHTTPError(http.StatusUnauthorized).SetInternal(errors.New("user not found")) + } + pwBytes := []byte(body.Password) if len(pwBytes) > 72 { + err = h.auditLogClient.Create(c, models.AuditLogPasswordLoginFailed, user, errors.New("password too long")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusBadRequest, "password must not be longer than 72 bytes") } pw, err := h.persister.GetPasswordCredentialPersister().GetByUserID(uuid.FromStringOrNil(body.UserId)) if pw == nil { + err = h.auditLogClient.Create(c, models.AuditLogPasswordLoginFailed, user, fmt.Errorf("user has no password credential")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusUnauthorized).SetInternal(errors.New(fmt.Sprintf("no password credential found for: %s", body.UserId))) } @@ -143,6 +196,10 @@ func (h *PasswordHandler) Login(c echo.Context) error { } if err = bcrypt.CompareHashAndPassword([]byte(pw.Password), pwBytes); err != nil { + err = h.auditLogClient.Create(c, models.AuditLogPasswordLoginFailed, user, fmt.Errorf("password hash not equal")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusUnauthorized).SetInternal(err) } @@ -162,5 +219,10 @@ func (h *PasswordHandler) Login(c echo.Context) error { c.Response().Header().Set("X-Auth-Token", token) } + err = h.auditLogClient.Create(c, models.AuditLogPasswordLoginSucceeded, user, nil) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } + return c.JSON(http.StatusOK, nil) } diff --git a/backend/handler/password_test.go b/backend/handler/password_test.go index 6806b388..9086ff6b 100644 --- a/backend/handler/password_test.go +++ b/backend/handler/password_test.go @@ -47,8 +47,8 @@ func TestPasswordHandler_Set_Create(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister(users, nil, nil, nil, nil, []models.PasswordCredential{}) - handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}) + p := test.NewPersister(users, nil, nil, nil, nil, []models.PasswordCredential{}, nil) + handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogClient()) if assert.NoError(t, handler.Set(c)) { assert.Equal(t, http.StatusCreated, rec.Code) @@ -82,8 +82,8 @@ func TestPasswordHandler_Set_Create_PasswordTooShort(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister(users, nil, nil, nil, nil, []models.PasswordCredential{}) - handler := NewPasswordHandler(p, sessionManager{}, &config.Config{Password: config.Password{MinPasswordLength: 8}}) + p := test.NewPersister(users, nil, nil, nil, nil, []models.PasswordCredential{}, nil) + handler := NewPasswordHandler(p, sessionManager{}, &config.Config{Password: config.Password{MinPasswordLength: 8}}, test.NewAuditLogClient()) err = handler.Set(c) if assert.Error(t, err) { @@ -119,8 +119,8 @@ func TestPasswordHandler_Set_Create_PasswordTooLong(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister(users, nil, nil, nil, nil, []models.PasswordCredential{}) - handler := NewPasswordHandler(p, sessionManager{}, &config.Config{Password: config.Password{MinPasswordLength: 8}}) + p := test.NewPersister(users, nil, nil, nil, nil, []models.PasswordCredential{}, nil) + handler := NewPasswordHandler(p, sessionManager{}, &config.Config{Password: config.Password{MinPasswordLength: 8}}, test.NewAuditLogClient()) err = handler.Set(c) if assert.Error(t, err) { @@ -172,8 +172,8 @@ func TestPasswordHandler_Set_Update(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister(users, nil, nil, nil, nil, passwords) - handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}) + p := test.NewPersister(users, nil, nil, nil, nil, passwords, nil) + handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogClient()) if assert.NoError(t, handler.Set(c)) { assert.Equal(t, http.StatusOK, rec.Code) @@ -197,8 +197,8 @@ func TestPasswordHandler_Set_UserNotFound(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister([]models.User{}, nil, nil, nil, nil, []models.PasswordCredential{}) - handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}) + p := test.NewPersister([]models.User{}, nil, nil, nil, nil, []models.PasswordCredential{}, nil) + handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogClient()) err = handler.Set(c) if assert.Error(t, err) { @@ -250,8 +250,8 @@ func TestPasswordHandler_Set_TokenHasWrongSubject(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister(users, nil, nil, nil, nil, passwords) - handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}) + p := test.NewPersister(users, nil, nil, nil, nil, passwords, nil) + handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogClient()) err = handler.Set(c) if assert.Error(t, err) { @@ -275,8 +275,8 @@ func TestPasswordHandler_Set_BadRequestBody(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister(nil, nil, nil, nil, nil, nil) - handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) + handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogClient()) err = handler.Set(c) if assert.Error(t, err) { @@ -322,8 +322,8 @@ func TestPasswordHandler_Login(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(users, nil, nil, nil, nil, passwords) - handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}) + p := test.NewPersister(users, nil, nil, nil, nil, passwords, nil) + handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogClient()) if assert.NoError(t, handler.Login(c)) { assert.Equal(t, http.StatusOK, rec.Code) @@ -375,8 +375,8 @@ func TestPasswordHandler_Login_WrongPassword(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(users, nil, nil, nil, nil, passwords) - handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}) + p := test.NewPersister(users, nil, nil, nil, nil, passwords, nil) + handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogClient()) err = handler.Login(c) if assert.Error(t, err) { @@ -395,8 +395,8 @@ func TestPasswordHandler_Login_NonExistingUser(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister([]models.User{}, nil, nil, nil, nil, []models.PasswordCredential{}) - handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}) + p := test.NewPersister([]models.User{}, nil, nil, nil, nil, []models.PasswordCredential{}, nil) + handler := NewPasswordHandler(p, sessionManager{}, &config.Config{}, test.NewAuditLogClient()) err := handler.Login(c) if assert.Error(t, err) { diff --git a/backend/handler/user.go b/backend/handler/user.go index b0455b9b..f0f6a715 100644 --- a/backend/handler/user.go +++ b/backend/handler/user.go @@ -7,6 +7,7 @@ import ( "github.com/gofrs/uuid" "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/dto" "github.com/teamhanko/hanko/backend/persistence" "github.com/teamhanko/hanko/backend/persistence/models" @@ -15,11 +16,15 @@ import ( ) type UserHandler struct { - persister persistence.Persister + persister persistence.Persister + auditLogClient auditlog.Client } -func NewUserHandler(persister persistence.Persister) *UserHandler { - return &UserHandler{persister: persister} +func NewUserHandler(persister persistence.Persister, auditLogClient auditlog.Client) *UserHandler { + return &UserHandler{ + persister: persister, + auditLogClient: auditLogClient, + } } type UserCreateBody struct { @@ -54,6 +59,8 @@ func (h *UserHandler) Create(c echo.Context) error { return fmt.Errorf("failed to store user: %w", err) } + _ = h.auditLogClient.Create(c, models.AuditLogUserCreated, &newUser, nil) // TODO: what to do on error + return c.JSON(http.StatusOK, newUser) }) } diff --git a/backend/handler/user_admin_test.go b/backend/handler/user_admin_test.go index 49163d5f..b3592951 100644 --- a/backend/handler/user_admin_test.go +++ b/backend/handler/user_admin_test.go @@ -35,7 +35,7 @@ func TestUserHandlerAdmin_Delete(t *testing.T) { c.SetParamNames("id") c.SetParamValues(userId.String()) - p := test.NewPersister(users, nil, nil, nil, nil, nil) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) handler := NewUserHandlerAdmin(p) if assert.NoError(t, handler.Delete(c)) { @@ -52,7 +52,7 @@ func TestUserHandlerAdmin_Delete_InvalidUserId(t *testing.T) { c.SetParamNames("id") c.SetParamValues("invalidId") - p := test.NewPersister(nil, nil, nil, nil, nil, nil) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) handler := NewUserHandlerAdmin(p) err := handler.Delete(c) @@ -72,7 +72,7 @@ func TestUserHandlerAdmin_Delete_UnknownUserId(t *testing.T) { c.SetParamNames("id") c.SetParamValues(userId.String()) - p := test.NewPersister(nil, nil, nil, nil, nil, nil) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) handler := NewUserHandlerAdmin(p) err := handler.Delete(c) @@ -104,7 +104,7 @@ func TestUserHandlerAdmin_Patch(t *testing.T) { c.SetParamNames("id") c.SetParamValues(userId.String()) - p := test.NewPersister(users, nil, nil, nil, nil, nil) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) handler := NewUserHandlerAdmin(p) if assert.NoError(t, handler.Patch(c)) { @@ -124,7 +124,7 @@ func TestUserHandlerAdmin_Patch_InvalidUserIdAndEmail(t *testing.T) { c.SetParamNames("id") c.SetParamValues("invalidUserId") - p := test.NewPersister(nil, nil, nil, nil, nil, nil) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) handler := NewUserHandlerAdmin(p) err := handler.Patch(c) @@ -167,7 +167,7 @@ func TestUserHandlerAdmin_Patch_EmailNotAvailable(t *testing.T) { c.SetParamNames("id") c.SetParamValues(users[0].ID.String()) - p := test.NewPersister(users, nil, nil, nil, nil, nil) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) handler := NewUserHandlerAdmin(p) err := handler.Patch(c) @@ -200,7 +200,7 @@ func TestUserHandlerAdmin_Patch_UnknownUserId(t *testing.T) { unknownUserId, _ := uuid.NewV4() c.SetParamValues(unknownUserId.String()) - p := test.NewPersister(users, nil, nil, nil, nil, nil) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) handler := NewUserHandlerAdmin(p) err := handler.Patch(c) @@ -233,7 +233,7 @@ func TestUserHandlerAdmin_Patch_InvalidJson(t *testing.T) { unknownUserId, _ := uuid.NewV4() c.SetParamValues(unknownUserId.String()) - p := test.NewPersister(users, nil, nil, nil, nil, nil) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) handler := NewUserHandlerAdmin(p) err := handler.Patch(c) @@ -271,7 +271,7 @@ func TestUserHandlerAdmin_List(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(users, nil, nil, nil, nil, nil) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) handler := NewUserHandlerAdmin(p) if assert.NoError(t, handler.List(c)) { @@ -314,7 +314,7 @@ func TestUserHandlerAdmin_List_Pagination(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(users, nil, nil, nil, nil, nil) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) handler := NewUserHandlerAdmin(p) if assert.NoError(t, handler.List(c)) { @@ -336,7 +336,7 @@ func TestUserHandlerAdmin_List_NoUsers(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(nil, nil, nil, nil, nil, nil) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) handler := NewUserHandlerAdmin(p) if assert.NoError(t, handler.List(c)) { @@ -357,7 +357,7 @@ func TestUserHandlerAdmin_List_InvalidPaginationParam(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(nil, nil, nil, nil, nil, nil) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) handler := NewUserHandlerAdmin(p) err := handler.List(c) diff --git a/backend/handler/user_test.go b/backend/handler/user_test.go index 316287c0..ff8d690c 100644 --- a/backend/handler/user_test.go +++ b/backend/handler/user_test.go @@ -42,8 +42,8 @@ func TestUserHandler_Create(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(users, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) if assert.NoError(t, handler.Create(c)) { user := models.User{} @@ -78,8 +78,8 @@ func TestUserHandler_Create_CaseInsensitive(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(users, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) if assert.NoError(t, handler.Create(c)) { user := models.User{} @@ -113,8 +113,8 @@ func TestUserHandler_Create_UserExists(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(users, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) err = handler.Create(c) if assert.Error(t, err) { @@ -146,8 +146,8 @@ func TestUserHandler_Create_UserExists_CaseInsensitive(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(users, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) err = handler.Create(c) if assert.Error(t, err) { @@ -165,8 +165,8 @@ func TestUserHandler_Create_InvalidEmail(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(nil, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) err := handler.Create(c) if assert.Error(t, err) { @@ -184,8 +184,8 @@ func TestUserHandler_Create_EmailMissing(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(nil, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) err := handler.Create(c) if assert.Error(t, err) { @@ -220,8 +220,8 @@ func TestUserHandler_Get(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister(users, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) if assert.NoError(t, handler.Get(c)) { assert.Equal(t, rec.Code, http.StatusOK) @@ -270,8 +270,8 @@ func TestUserHandler_GetUserWithWebAuthnCredential(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister(users, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) if assert.NoError(t, handler.Get(c)) { assert.Equal(t, rec.Code, http.StatusOK) @@ -295,8 +295,8 @@ func TestUserHandler_Get_InvalidUserId(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister(nil, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) err = handler.Get(c) if assert.Error(t, err) { @@ -313,8 +313,8 @@ func TestUserHandler_GetUserIdByEmail_InvalidEmail(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(nil, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) err := handler.GetUserIdByEmail(c) if assert.Error(t, err) { @@ -330,8 +330,8 @@ func TestUserHandler_GetUserIdByEmail_InvalidJson(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(nil, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) assert.Error(t, handler.GetUserIdByEmail(c)) } @@ -344,8 +344,8 @@ func TestUserHandler_GetUserIdByEmail_UserNotFound(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(nil, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) err := handler.GetUserIdByEmail(c) if assert.Error(t, err) { @@ -372,8 +372,8 @@ func TestUserHandler_GetUserIdByEmail(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(users, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) if assert.NoError(t, handler.GetUserIdByEmail(c)) { assert.Equal(t, http.StatusOK, rec.Code) @@ -406,8 +406,8 @@ func TestUserHandler_GetUserIdByEmail_CaseInsensitive(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(users, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) if assert.NoError(t, handler.GetUserIdByEmail(c)) { assert.Equal(t, http.StatusOK, rec.Code) @@ -436,8 +436,8 @@ func TestUserHandler_Me(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister(users, nil, nil, nil, nil, nil) - handler := NewUserHandler(p) + p := test.NewPersister(users, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(p, test.NewAuditLogClient()) if assert.NoError(t, handler.Me(c)) { assert.Equal(t, http.StatusOK, rec.Code) diff --git a/backend/handler/webauthn.go b/backend/handler/webauthn.go index 76657631..9ede0215 100644 --- a/backend/handler/webauthn.go +++ b/backend/handler/webauthn.go @@ -10,6 +10,7 @@ import ( "github.com/gofrs/uuid" "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/dto" "github.com/teamhanko/hanko/backend/dto/intern" @@ -24,10 +25,11 @@ type WebauthnHandler struct { webauthn *webauthn.WebAuthn sessionManager session.Manager cfg *config.Config + auditLogClient auditlog.Client } // NewWebauthnHandler creates a new handler which handles all webauthn related routes -func NewWebauthnHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager) (*WebauthnHandler, error) { +func NewWebauthnHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, auditLogClient auditlog.Client) (*WebauthnHandler, error) { f := false wa, err := webauthn.New(&webauthn.Config{ RPDisplayName: cfg.Webauthn.RelyingParty.DisplayName, @@ -52,6 +54,7 @@ func NewWebauthnHandler(cfg *config.Config, persister persistence.Persister, ses webauthn: wa, sessionManager: sessionManager, cfg: cfg, + auditLogClient: auditLogClient, }, nil } @@ -65,11 +68,15 @@ func (h *WebauthnHandler) BeginRegistration(c echo.Context) error { if err != nil { return fmt.Errorf("failed to parse userId from JWT subject:%w", err) } - webauthnUser, err := h.getWebauthnUser(h.persister.GetConnection(), uId) + webauthnUser, user, err := h.getWebauthnUser(h.persister.GetConnection(), uId) if err != nil { return fmt.Errorf("failed to get user: %w", err) } if webauthnUser == nil { + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnRegistrationInitFailed, nil, fmt.Errorf("unknown user")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusBadRequest, "user not found").SetInternal(errors.New(fmt.Sprintf("user %s not found ", uId))) } @@ -95,6 +102,11 @@ func (h *WebauthnHandler) BeginRegistration(c echo.Context) error { return fmt.Errorf("failed to store creation options session data: %w", err) } + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnRegistrationInitSucceeded, user, nil) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } + return c.JSON(http.StatusOK, options) } @@ -121,24 +133,40 @@ func (h *WebauthnHandler) FinishRegistration(c echo.Context) error { } if sessionData == nil { + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnRegistrationFailed, nil, fmt.Errorf("received unkown challenge")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusBadRequest, "Stored challenge and received challenge do not match").SetInternal(errors.New("sessionData not found")) } if sessionToken.Subject() != sessionData.UserId.String() { + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnRegistrationFailed, nil, fmt.Errorf("user session does not match sessionData subject")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusBadRequest, "Stored challenge and received challenge do not match").SetInternal(errors.New("userId in webauthn.sessionData does not match user session")) } - webauthnUser, err := h.getWebauthnUser(tx, sessionData.UserId) + webauthnUser, user, err := h.getWebauthnUser(tx, sessionData.UserId) if err != nil { return fmt.Errorf("failed to get user: %w", err) } if webauthnUser == nil { + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnRegistrationFailed, nil, fmt.Errorf("unkown user")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusBadRequest).SetInternal(errors.New("user not found")) } credential, err := h.webauthn.CreateCredential(webauthnUser, *intern.WebauthnSessionDataFromModel(sessionData), request) if err != nil { + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnRegistrationFailed, user, fmt.Errorf("attestation validation failed")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusBadRequest, "Failed to validate attestation").SetInternal(err) } @@ -153,6 +181,11 @@ func (h *WebauthnHandler) FinishRegistration(c echo.Context) error { c.Logger().Errorf("failed to delete attestation session data: %w", err) } + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnRegistrationSucceeded, user, nil) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } + return c.JSON(http.StatusOK, map[string]string{"credential_id": model.ID, "user_id": webauthnUser.UserId.String()}) }) } @@ -171,17 +204,27 @@ func (h *WebauthnHandler) BeginAuthentication(c echo.Context) error { var options *protocol.CredentialAssertion var sessionData *webauthn.SessionData + var user *models.User if request.UserID != nil { // non discoverable login initialization userId, err := uuid.FromString(*request.UserID) if err != nil { + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnAuthenticationInitFailed, nil, fmt.Errorf("user_id is not a uuid")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusBadRequest, "failed to parse UserID as uuid").SetInternal(err) } - webauthnUser, err := h.getWebauthnUser(h.persister.GetConnection(), userId) + var webauthnUser *intern.WebauthnUser + webauthnUser, user, err = h.getWebauthnUser(h.persister.GetConnection(), userId) // TODO: if err != nil { return dto.NewHTTPError(http.StatusInternalServerError).SetInternal(fmt.Errorf("failed to get user: %w", err)) } if webauthnUser == nil { + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnAuthenticationInitFailed, nil, fmt.Errorf("unkown user")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusBadRequest, "user not found") } @@ -211,6 +254,11 @@ func (h *WebauthnHandler) BeginAuthentication(c echo.Context) error { options.Response.AllowedCredentials[i].Transport = nil } + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnAuthenticationInitSucceeded, user, nil) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } + return c.JSON(http.StatusOK, options) } @@ -233,6 +281,10 @@ func (h *WebauthnHandler) FinishAuthentication(c echo.Context) error { } if sessionData == nil { + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnAuthenticationFailed, nil, fmt.Errorf("received unkown challenge")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusUnauthorized, "Stored challenge and received challenge do not match").SetInternal(errors.New("sessionData not found")) } @@ -240,18 +292,23 @@ func (h *WebauthnHandler) FinishAuthentication(c echo.Context) error { var credential *webauthn.Credential var webauthnUser *intern.WebauthnUser + var user *models.User if sessionData.UserId.IsNil() { // Discoverable Login userId, err := uuid.FromBytes(request.Response.UserHandle) if err != nil { return dto.NewHTTPError(http.StatusBadRequest, "failed to parse userHandle as uuid").SetInternal(err) } - webauthnUser, err = h.getWebauthnUser(tx, userId) + webauthnUser, user, err = h.getWebauthnUser(tx, userId) if err != nil { return fmt.Errorf("failed to get user: %w", err) } if webauthnUser == nil { + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnAuthenticationFailed, nil, fmt.Errorf("unkown user")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusUnauthorized).SetInternal(errors.New("user not found")) } @@ -259,19 +316,31 @@ func (h *WebauthnHandler) FinishAuthentication(c echo.Context) error { return webauthnUser, nil }, *model, request) if err != nil { + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnAuthenticationFailed, user, fmt.Errorf("assertion validation failed")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusUnauthorized, "failed to validate assertion").SetInternal(err) } } else { // non discoverable Login - webauthnUser, err = h.getWebauthnUser(tx, sessionData.UserId) + webauthnUser, user, err = h.getWebauthnUser(tx, sessionData.UserId) if err != nil { return fmt.Errorf("failed to get user: %w", err) } if webauthnUser == nil { + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnAuthenticationFailed, nil, fmt.Errorf("unkown user")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusUnauthorized).SetInternal(errors.New("user not found")) } credential, err = h.webauthn.ValidateLogin(webauthnUser, *model, request) if err != nil { + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnAuthenticationFailed, user, fmt.Errorf("assertion validation failed")) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } return dto.NewHTTPError(http.StatusUnauthorized, "failed to validate assertion").SetInternal(err) } } @@ -297,24 +366,29 @@ func (h *WebauthnHandler) FinishAuthentication(c echo.Context) error { c.Response().Header().Set("X-Auth-Token", token) } + err = h.auditLogClient.Create(c, models.AuditLogWebAuthnAuthenticationSucceeded, user, nil) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } + return c.JSON(http.StatusOK, map[string]string{"credential_id": base64.RawURLEncoding.EncodeToString(credential.ID), "user_id": webauthnUser.UserId.String()}) }) } -func (h WebauthnHandler) getWebauthnUser(connection *pop.Connection, userId uuid.UUID) (*intern.WebauthnUser, error) { +func (h WebauthnHandler) getWebauthnUser(connection *pop.Connection, userId uuid.UUID) (*intern.WebauthnUser, *models.User, error) { user, err := h.persister.GetUserPersisterWithConnection(connection).Get(userId) if err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) + return nil, nil, fmt.Errorf("failed to get user: %w", err) } if user == nil { - return nil, nil + return nil, nil, nil } credentials, err := h.persister.GetWebauthnCredentialPersisterWithConnection(connection).GetFromUser(user.ID) if err != nil { - return nil, fmt.Errorf("failed to get webauthn credentials: %w", err) + return nil, nil, fmt.Errorf("failed to get webauthn credentials: %w", err) } - return intern.NewWebauthnUser(*user, credentials), nil + return intern.NewWebauthnUser(*user, credentials), user, nil } diff --git a/backend/handler/webauthn_test.go b/backend/handler/webauthn_test.go index 3227d43d..f1501e21 100644 --- a/backend/handler/webauthn_test.go +++ b/backend/handler/webauthn_test.go @@ -23,8 +23,8 @@ var userId = "ec4ef049-5b88-4321-a173-21b0eff06a04" var userIdBytes = []byte{0xec, 0x4e, 0xf0, 0x49, 0x5b, 0x88, 0x43, 0x21, 0xa1, 0x73, 0x21, 0xb0, 0xef, 0xf0, 0x6a, 0x4} func TestNewWebauthnHandler(t *testing.T) { - p := test.NewPersister(nil, nil, nil, nil, nil, nil) - handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}) + p := test.NewPersister(nil, nil, nil, nil, nil, nil, nil) + handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogClient()) assert.NoError(t, err) assert.NotEmpty(t, handler) } @@ -39,8 +39,8 @@ func TestWebauthnHandler_BeginRegistration(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister(users, nil, nil, credentials, sessionData, nil) - handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}) + p := test.NewPersister(users, nil, nil, credentials, sessionData, nil, nil) + handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogClient()) require.NoError(t, err) if assert.NoError(t, handler.BeginRegistration(c)) { @@ -75,8 +75,8 @@ func TestWebauthnHandler_FinishRegistration(t *testing.T) { require.NoError(t, err) c.Set("session", token) - p := test.NewPersister(users, nil, nil, nil, sessionData, nil) - handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}) + p := test.NewPersister(users, nil, nil, nil, sessionData, nil, nil) + handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogClient()) require.NoError(t, err) if assert.NoError(t, handler.FinishRegistration(c)) { @@ -106,8 +106,8 @@ func TestWebauthnHandler_BeginAuthentication(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(users, nil, nil, nil, sessionData, nil) - handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}) + p := test.NewPersister(users, nil, nil, nil, sessionData, nil, nil) + handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogClient()) require.NoError(t, err) if assert.NoError(t, handler.BeginAuthentication(c)) { @@ -138,8 +138,8 @@ func TestWebauthnHandler_FinishAuthentication(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - p := test.NewPersister(users, nil, nil, credentials, sessionData, nil) - handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}) + p := test.NewPersister(users, nil, nil, credentials, sessionData, nil, nil) + handler, err := NewWebauthnHandler(&defaultConfig, p, sessionManager{}, test.NewAuditLogClient()) require.NoError(t, err) if assert.NoError(t, handler.FinishAuthentication(c)) { diff --git a/backend/persistence/audit_log_persister.go b/backend/persistence/audit_log_persister.go new file mode 100644 index 00000000..b93742d1 --- /dev/null +++ b/backend/persistence/audit_log_persister.go @@ -0,0 +1,72 @@ +package persistence + +import ( + "database/sql" + "fmt" + "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type AuditLogPersister interface { + Create(auditLog models.AuditLog) error + Get(id uuid.UUID) (*models.AuditLog, error) + List(page int, perPage int) ([]models.AuditLog, error) + Delete(auditLog models.AuditLog) error +} + +type auditLogPersister struct { + db *pop.Connection +} + +func NewAuditLogPersister(db *pop.Connection) AuditLogPersister { + return &auditLogPersister{db: db} +} + +func (p *auditLogPersister) Create(auditLog models.AuditLog) error { + vErr, err := p.db.ValidateAndCreate(&auditLog) + if err != nil { + return fmt.Errorf("failed to store auditlog: %w", err) + } + if vErr != nil && vErr.HasAny() { + return fmt.Errorf("auditlog object validation failed: %w", vErr) + } + + return nil +} + +func (p *auditLogPersister) Get(id uuid.UUID) (*models.AuditLog, error) { + auditLog := models.AuditLog{} + err := p.db.Eager().Find(&auditLog, id) + if err != nil && err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get auditlog: %w", err) + } + + return &auditLog, nil +} + +func (p *auditLogPersister) List(page int, perPage int) ([]models.AuditLog, error) { + auditLogs := []models.AuditLog{} + + err := p.db.Eager().Q().Paginate(page, perPage).Order("created_at desc").All(&auditLogs) + if err != nil && err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to fetch auditLogs: %w", err) + } + + return auditLogs, nil +} + +func (p *auditLogPersister) Delete(auditLog models.AuditLog) error { + err := p.db.Eager().Destroy(&auditLog) + if err != nil { + return fmt.Errorf("failed to delete auditlog: %w", err) + } + + return nil +} diff --git a/backend/persistence/migrations/20220818111000_create_audit_logs.down.fizz b/backend/persistence/migrations/20220818111000_create_audit_logs.down.fizz new file mode 100644 index 00000000..a34cd8cf --- /dev/null +++ b/backend/persistence/migrations/20220818111000_create_audit_logs.down.fizz @@ -0,0 +1 @@ +drop_table("audit_logs") diff --git a/backend/persistence/migrations/20220818111000_create_audit_logs.up.fizz b/backend/persistence/migrations/20220818111000_create_audit_logs.up.fizz new file mode 100644 index 00000000..642756e4 --- /dev/null +++ b/backend/persistence/migrations/20220818111000_create_audit_logs.up.fizz @@ -0,0 +1,14 @@ +create_table("audit_logs") { + t.Column("id", "uuid", {"primary": true}) + t.Column("type", "string", {}) + t.Column("error", "string", {}) + t.Column("meta_http_request_id", "string", {}) + t.Column("meta_source_ip", "string", {}) + t.Column("meta_user_agent", "string", {}) + t.Column("actor_user_id", "uuid", {"null": true}) + t.Column("actor_email", "string", {}) + t.Timestamps() + t.Index("type") + t.Index("actor_user_id") + t.Index("actor_email") +} diff --git a/backend/persistence/models/audit_log.go b/backend/persistence/models/audit_log.go new file mode 100644 index 00000000..a6148106 --- /dev/null +++ b/backend/persistence/models/audit_log.go @@ -0,0 +1,46 @@ +package models + +import ( + "github.com/gofrs/uuid" + "time" +) + +type AuditLog struct { + ID uuid.UUID `db:"id" json:"id"` + Type AuditLogType `db:"type" json:"type"` + Error string `db:"error" json:"error"` + MetaHttpRequestId string `db:"meta_http_request_id" json:"meta_http_request_id"` + MetaSourceIp string `db:"meta_source_ip" json:"meta_source_ip"` + MetaUserAgent string `db:"meta_user_agent" json:"meta_user_agent"` + ActorUserId *uuid.UUID `db:"actor_user_id" json:"actor_user_id"` + ActorEmail string `db:"actor_email" json:"actor_email"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +type AuditLogType string + +var ( + AuditLogUserCreated AuditLogType = "user_created" + + AuditLogPasswordSetSucceeded AuditLogType = "password_set_succeeded" + AuditLogPasswordSetFailed AuditLogType = "password_set_failed" + + AuditLogPasswordLoginSucceeded AuditLogType = "password_login_succeeded" + AuditLogPasswordLoginFailed AuditLogType = "password_login_failed" + + AuditLogPasscodeLoginInitSucceeded AuditLogType = "passcode_login_init_succeeded" + AuditLogPasscodeLoginInitFailed AuditLogType = "passcode_login_init_failed" + AuditLogPasscodeLoginSucceeded AuditLogType = "passcode_login_succeeded" + AuditLogPasscodeLoginFailed AuditLogType = "passcode_login_failed" + + AuditLogWebAuthnRegistrationInitSucceeded AuditLogType = "webauthn_registration_init_succeeded" + AuditLogWebAuthnRegistrationInitFailed AuditLogType = "webauthn_registration_init_failed" + AuditLogWebAuthnRegistrationSucceeded AuditLogType = "webauthn_registration_succeeded" + AuditLogWebAuthnRegistrationFailed AuditLogType = "webauthn_registration_failed" + + AuditLogWebAuthnAuthenticationInitSucceeded AuditLogType = "webauthn_authentication_init_succeeded" + AuditLogWebAuthnAuthenticationInitFailed AuditLogType = "webauthn_authentication_init_failed" + AuditLogWebAuthnAuthenticationSucceeded AuditLogType = "webauthn_authentication_succeeded" + AuditLogWebAuthnAuthenticationFailed AuditLogType = "webauthn_authentication_failed" +) diff --git a/backend/persistence/persister.go b/backend/persistence/persister.go index bfc54822..24109366 100644 --- a/backend/persistence/persister.go +++ b/backend/persistence/persister.go @@ -29,6 +29,8 @@ type Persister interface { GetWebauthnSessionDataPersisterWithConnection(tx *pop.Connection) WebauthnSessionDataPersister GetJwkPersister() JwkPersister GetJwkPersisterWithConnection(tx *pop.Connection) JwkPersister + GetAuditLogPersister() AuditLogPersister + GetAuditLogPersisterWithConnection(tx *pop.Connection) AuditLogPersister } type Migrator interface { @@ -145,6 +147,14 @@ func (p *persister) GetJwkPersisterWithConnection(tx *pop.Connection) JwkPersist return NewJwkPersister(tx) } +func (p *persister) GetAuditLogPersister() AuditLogPersister { + return NewAuditLogPersister(p.DB) +} + +func (p *persister) GetAuditLogPersisterWithConnection(tx *pop.Connection) AuditLogPersister { + return NewAuditLogPersister(tx) +} + func (p *persister) Transaction(fn func(tx *pop.Connection) error) error { return p.DB.Transaction(fn) } diff --git a/backend/server/admin_router.go b/backend/server/admin_router.go index 29f46eb9..bf7f539e 100644 --- a/backend/server/admin_router.go +++ b/backend/server/admin_router.go @@ -31,5 +31,10 @@ func NewPrivateRouter(persister persistence.Persister) *echo.Echo { user.PATCH("/:id", userHandler.Patch) user.GET("", userHandler.List) + auditLogHandler := handler.NewAuditLogHandler(persister) + + auditLogs := e.Group("/audit_logs") + auditLogs.GET("", auditLogHandler.List) + return e } diff --git a/backend/server/public_router.go b/backend/server/public_router.go index 47b0d7fa..d57815c3 100644 --- a/backend/server/public_router.go +++ b/backend/server/public_router.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/crypto/jwk" "github.com/teamhanko/hanko/backend/dto" @@ -49,15 +50,17 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister) *echo. panic(fmt.Errorf("failed to create mailer: %w", err)) } + auditLogClient := auditlog.NewClient(persister, cfg.AuditLog) + if cfg.Password.Enabled { - passwordHandler := handler.NewPasswordHandler(persister, sessionManager, cfg) + passwordHandler := handler.NewPasswordHandler(persister, sessionManager, cfg, auditLogClient) password := e.Group("/password") password.PUT("", passwordHandler.Set, hankoMiddleware.Session(sessionManager)) password.POST("/login", passwordHandler.Login) } - userHandler := handler.NewUserHandler(persister) + userHandler := handler.NewUserHandler(persister, auditLogClient) e.GET("/me", userHandler.Me, hankoMiddleware.Session(sessionManager)) @@ -68,11 +71,11 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister) *echo. e.POST("/user", userHandler.GetUserIdByEmail) healthHandler := handler.NewHealthHandler() - webauthnHandler, err := handler.NewWebauthnHandler(cfg, persister, sessionManager) + webauthnHandler, err := handler.NewWebauthnHandler(cfg, persister, sessionManager, auditLogClient) if err != nil { panic(fmt.Errorf("failed to create public webauthn handler: %w", err)) } - passcodeHandler, err := handler.NewPasscodeHandler(cfg, persister, sessionManager, mailer) + passcodeHandler, err := handler.NewPasscodeHandler(cfg, persister, sessionManager, mailer, auditLogClient) if err != nil { panic(fmt.Errorf("failed to create public passcode handler: %w", err)) } diff --git a/backend/test/audit_log_client.go b/backend/test/audit_log_client.go new file mode 100644 index 00000000..2be2fae5 --- /dev/null +++ b/backend/test/audit_log_client.go @@ -0,0 +1,18 @@ +package test + +import ( + "github.com/labstack/echo/v4" + "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +func NewAuditLogClient() auditlog.Client { + return &auditLogClient{} +} + +type auditLogClient struct { +} + +func (a *auditLogClient) Create(context echo.Context, logType models.AuditLogType, user *models.User, err error) error { + return nil +} diff --git a/backend/test/audit_log_persister.go b/backend/test/audit_log_persister.go new file mode 100644 index 00000000..4992f9c2 --- /dev/null +++ b/backend/test/audit_log_persister.go @@ -0,0 +1,76 @@ +package test + +import ( + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +func NewAuditLogPersister(init []models.AuditLog) persistence.AuditLogPersister { + if init == nil { + return &auditLogPersister{[]models.AuditLog{}} + } + return &auditLogPersister{append([]models.AuditLog{}, init...)} +} + +type auditLogPersister struct { + logs []models.AuditLog +} + +func (p *auditLogPersister) Create(auditLog models.AuditLog) error { + p.logs = append(p.logs, auditLog) + return nil +} + +func (p *auditLogPersister) Get(id uuid.UUID) (*models.AuditLog, error) { + var found *models.AuditLog + for _, data := range p.logs { + if data.ID == id { + d := data + found = &d + } + } + return found, nil +} + +func (p *auditLogPersister) List(page int, perPage int) ([]models.AuditLog, error) { + if len(p.logs) == 0 { + return p.logs, nil + } + + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + + var result [][]models.AuditLog + var j int + for i := 0; i < len(p.logs); i += perPage { + j += perPage + if j > len(p.logs) { + j = len(p.logs) + } + result = append(result, p.logs[i:j]) + } + + if page > len(result) { + return []models.AuditLog{}, nil + } + return result[page-1], nil +} + +func (p *auditLogPersister) Delete(auditLog models.AuditLog) error { + index := -1 + for i, log := range p.logs { + if log.ID == auditLog.ID { + index = i + } + } + if index > -1 { + p.logs = append(p.logs[:index], p.logs[index+1:]...) + } + + return nil +} diff --git a/backend/test/persister.go b/backend/test/persister.go index a334a369..9760799d 100644 --- a/backend/test/persister.go +++ b/backend/test/persister.go @@ -6,7 +6,7 @@ import ( "github.com/teamhanko/hanko/backend/persistence/models" ) -func NewPersister(user []models.User, passcodes []models.Passcode, jwks []models.Jwk, credentials []models.WebauthnCredential, sessionData []models.WebauthnSessionData, passwords []models.PasswordCredential) persistence.Persister { +func NewPersister(user []models.User, passcodes []models.Passcode, jwks []models.Jwk, credentials []models.WebauthnCredential, sessionData []models.WebauthnSessionData, passwords []models.PasswordCredential, auditLogs []models.AuditLog) persistence.Persister { return &persister{ userPersister: NewUserPersister(user), passcodePersister: NewPasscodePersister(passcodes), @@ -14,6 +14,7 @@ func NewPersister(user []models.User, passcodes []models.Passcode, jwks []models webauthnCredentialPersister: NewWebauthnCredentialPersister(credentials), webauthnSessionDataPersister: NewWebauthnSessionDataPersister(sessionData), passwordCredentialPersister: NewPasswordCredentialPersister(passwords), + auditLogPersister: NewAuditLogPersister(auditLogs), } } @@ -24,6 +25,7 @@ type persister struct { webauthnCredentialPersister persistence.WebauthnCredentialPersister webauthnSessionDataPersister persistence.WebauthnSessionDataPersister passwordCredentialPersister persistence.PasswordCredentialPersister + auditLogPersister persistence.AuditLogPersister } func (p *persister) GetPasswordCredentialPersister() persistence.PasswordCredentialPersister { @@ -81,3 +83,11 @@ func (p *persister) GetJwkPersister() persistence.JwkPersister { func (p *persister) GetJwkPersisterWithConnection(tx *pop.Connection) persistence.JwkPersister { return p.jwkPersister } + +func (p *persister) GetAuditLogPersister() persistence.AuditLogPersister { + return p.auditLogPersister +} + +func (p *persister) GetAuditLogPersisterWithConnection(tx *pop.Connection) persistence.AuditLogPersister { + return p.auditLogPersister +}