From c264108f87eb5525a712e8e6e3eb9e1dfafb999d Mon Sep 17 00:00:00 2001 From: Frederic Jahn Date: Tue, 3 Dec 2024 11:22:52 +0100 Subject: [PATCH] Admin api changes (#1974) * feat: return mfa only flag * feat: add webauthn admin handler * feat: add webauthn credential handler to router * feat: add password mgmt admin endpoints * feat: add sessions admin handler * feat: add otp admin handler * feat: add otp to admin user dto * test: add admin password handler test * test: add admin webauthn handler test * test: add admin session handler test * test: add admin otp handler test * chore: merge both loadDto functions * tests: fix test name typos --- backend/dto/admin/otp.go | 15 + backend/dto/admin/password.go | 9 + backend/dto/admin/session.go | 9 + backend/dto/admin/user.go | 10 + backend/dto/admin/webauthn.go | 10 + backend/dto/webauthn.go | 2 + backend/flow_api/services/password.go | 5 +- backend/handler/admin_router.go | 24 +- backend/handler/email_admin.go | 17 - backend/handler/otp_admin.go | 77 +++++ backend/handler/otp_admin_test.go | 145 ++++++++ backend/handler/password_admin.go | 201 +++++++++++ backend/handler/password_admin_test.go | 319 ++++++++++++++++++ backend/handler/public_router.go | 2 +- backend/handler/session_admin.go | 73 ++++ backend/handler/session_admin_test.go | 181 ++++++++++ backend/handler/utils.go | 23 ++ backend/handler/webauthn_credential_admin.go | 129 +++++++ .../handler/webauthn_credential_admin_test.go | 256 ++++++++++++++ backend/persistence/models/session.go | 16 +- backend/test/fixtures/otp/emails.yaml | 36 ++ backend/test/fixtures/otp/otp_secrets.yaml | 5 + backend/test/fixtures/otp/primary_emails.yaml | 10 + backend/test/fixtures/otp/users.yaml | 13 + backend/test/fixtures/sessions/emails.yaml | 18 + .../fixtures/sessions/primary_emails.yaml | 10 + backend/test/fixtures/sessions/sessions.yaml | 24 ++ backend/test/fixtures/sessions/users.yaml | 9 + 28 files changed, 1617 insertions(+), 31 deletions(-) create mode 100644 backend/dto/admin/otp.go create mode 100644 backend/dto/admin/webauthn.go create mode 100644 backend/handler/otp_admin.go create mode 100644 backend/handler/otp_admin_test.go create mode 100644 backend/handler/password_admin.go create mode 100644 backend/handler/password_admin_test.go create mode 100644 backend/handler/session_admin_test.go create mode 100644 backend/handler/utils.go create mode 100644 backend/handler/webauthn_credential_admin.go create mode 100644 backend/handler/webauthn_credential_admin_test.go create mode 100644 backend/test/fixtures/otp/emails.yaml create mode 100644 backend/test/fixtures/otp/otp_secrets.yaml create mode 100644 backend/test/fixtures/otp/primary_emails.yaml create mode 100644 backend/test/fixtures/otp/users.yaml create mode 100644 backend/test/fixtures/sessions/emails.yaml create mode 100644 backend/test/fixtures/sessions/primary_emails.yaml create mode 100644 backend/test/fixtures/sessions/sessions.yaml create mode 100644 backend/test/fixtures/sessions/users.yaml diff --git a/backend/dto/admin/otp.go b/backend/dto/admin/otp.go new file mode 100644 index 000000000..158c7bd87 --- /dev/null +++ b/backend/dto/admin/otp.go @@ -0,0 +1,15 @@ +package admin + +import ( + "github.com/gofrs/uuid" + "time" +) + +type GetOTPRequestDto struct { + UserID string `param:"user_id" validate:"required,uuid4"` +} + +type OTPDto struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/dto/admin/password.go b/backend/dto/admin/password.go index 3e35f65ad..7d65a31ae 100644 --- a/backend/dto/admin/password.go +++ b/backend/dto/admin/password.go @@ -10,3 +10,12 @@ type PasswordCredential struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +type GetPasswordCredentialRequestDto struct { + UserID string `param:"user_id" validate:"required,uuid4"` +} + +type CreateOrUpdatePasswordCredentialRequestDto struct { + GetPasswordCredentialRequestDto + Password string `json:"password" validate:"required"` +} diff --git a/backend/dto/admin/session.go b/backend/dto/admin/session.go index e9c7c4133..d9e7902c4 100644 --- a/backend/dto/admin/session.go +++ b/backend/dto/admin/session.go @@ -9,3 +9,12 @@ type CreateSessionTokenDto struct { type CreateSessionTokenResponse struct { SessionToken string `json:"session_token"` } + +type ListSessionsRequestDto struct { + UserID string `param:"user_id" validate:"required,uuid4"` +} + +type DeleteSessionRequestDto struct { + ListSessionsRequestDto + SessionID string `param:"session_id" validate:"required,uuid4"` +} diff --git a/backend/dto/admin/user.go b/backend/dto/admin/user.go index d477add79..61de32ee9 100644 --- a/backend/dto/admin/user.go +++ b/backend/dto/admin/user.go @@ -16,6 +16,7 @@ type User struct { UpdatedAt time.Time `json:"updated_at"` Password *PasswordCredential `json:"password,omitempty"` Identities []Identity `json:"identities,omitempty"` + OTP *OTPDto `json:"otp"` } // FromUserModel Converts the DB model to a DTO object @@ -46,6 +47,14 @@ func FromUserModel(model models.User) User { } } + var otp *OTPDto = nil + if model.OTPSecret != nil { + otp = &OTPDto{ + ID: model.OTPSecret.ID, + CreatedAt: model.OTPSecret.CreatedAt, + } + } + return User{ ID: model.ID, WebauthnCredentials: credentials, @@ -55,6 +64,7 @@ func FromUserModel(model models.User) User { UpdatedAt: model.UpdatedAt, Password: passwordCredential, Identities: identities, + OTP: otp, } } diff --git a/backend/dto/admin/webauthn.go b/backend/dto/admin/webauthn.go new file mode 100644 index 000000000..ef6a68a03 --- /dev/null +++ b/backend/dto/admin/webauthn.go @@ -0,0 +1,10 @@ +package admin + +type ListWebauthnCredentialsRequestDto struct { + UserID string `param:"user_id" validate:"required,uuid"` +} + +type GetWebauthnCredentialRequestDto struct { + ListWebauthnCredentialsRequestDto + WebauthnCredentialID string `param:"credential_id" validate:"required"` +} diff --git a/backend/dto/webauthn.go b/backend/dto/webauthn.go index 9334677f6..9cc7d6d08 100644 --- a/backend/dto/webauthn.go +++ b/backend/dto/webauthn.go @@ -21,6 +21,7 @@ type WebauthnCredentialResponse struct { Transports []string `json:"transports"` BackupEligible bool `json:"backup_eligible"` BackupState bool `json:"backup_state"` + MFAOnly bool `json:"mfa_only"` } // FromWebauthnCredentialModel Converts the DB model to a DTO object @@ -36,5 +37,6 @@ func FromWebauthnCredentialModel(c *models.WebauthnCredential) *WebauthnCredenti Transports: c.Transports.GetNames(), BackupEligible: c.BackupEligible, BackupState: c.BackupState, + MFAOnly: c.MFAOnly, } } diff --git a/backend/flow_api/services/password.go b/backend/flow_api/services/password.go index 3e668c7d5..67fe9c40c 100644 --- a/backend/flow_api/services/password.go +++ b/backend/flow_api/services/password.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/gobuffalo/pop/v6" "github.com/gofrs/uuid" - "github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/persistence" "github.com/teamhanko/hanko/backend/persistence/models" "golang.org/x/crypto/bcrypt" @@ -25,13 +24,11 @@ type Password interface { type password struct { persister persistence.Persister - cfg config.Config } -func NewPasswordService(cfg config.Config, persister persistence.Persister) Password { +func NewPasswordService(persister persistence.Persister) Password { return &password{ persister, - cfg, } } diff --git a/backend/handler/admin_router.go b/backend/handler/admin_router.go index 8e71aa433..82a494cdb 100644 --- a/backend/handler/admin_router.go +++ b/backend/handler/admin_router.go @@ -60,6 +60,7 @@ func NewAdminRouter(cfg *config.Config, persister persistence.Persister, prometh userHandler := NewUserHandlerAdmin(persister) emailHandler := NewEmailAdminHandler(cfg, persister) + sessionsHandler := NewSessionAdminHandler(cfg, persister, sessionManager, auditLogger) user := g.Group("/users") user.GET("", userHandler.List) @@ -74,6 +75,28 @@ func NewAdminRouter(cfg *config.Config, persister persistence.Persister, prometh email.DELETE("/:email_id", emailHandler.Delete) email.POST("/:email_id/set_primary", emailHandler.SetPrimaryEmail) + webauthnCredentialHandler := NewWebauthnCredentialAdminHandler(persister) + webauthnCredentials := user.Group("/:user_id/webauthn_credentials") + webauthnCredentials.GET("", webauthnCredentialHandler.List) + webauthnCredentials.GET("/:credential_id", webauthnCredentialHandler.Get) + webauthnCredentials.DELETE("/:credential_id", webauthnCredentialHandler.Delete) + + passwordCredentialHandler := NewPasswordAdminHandler(persister) + passwordCredentials := user.Group("/:user_id/password") + passwordCredentials.GET("", passwordCredentialHandler.Get) + passwordCredentials.POST("", passwordCredentialHandler.Create) + passwordCredentials.PUT("", passwordCredentialHandler.Update) + passwordCredentials.DELETE("", passwordCredentialHandler.Delete) + + userSessions := user.Group("/:user_id/sessions") + userSessions.GET("", sessionsHandler.List) + userSessions.DELETE("/:session_id", sessionsHandler.Delete) + + otpHandler := NewOTPAdminHandler(persister) + otp := user.Group("/:user_id/otp") + otp.GET("", otpHandler.Get) + otp.DELETE("", otpHandler.Delete) + auditLogHandler := NewAuditLogHandler(persister) auditLogs := g.Group("/audit_logs") @@ -87,7 +110,6 @@ func NewAdminRouter(cfg *config.Config, persister persistence.Persister, prometh webhooks.DELETE("/:id", webhookHandler.Delete) webhooks.PUT("/:id", webhookHandler.Update) - sessionsHandler := NewSessionAdminHandler(cfg, persister, sessionManager, auditLogger) sessions := g.Group("/sessions") sessions.POST("", sessionsHandler.Generate) diff --git a/backend/handler/email_admin.go b/backend/handler/email_admin.go index 67c09cffe..c0c2fe229 100644 --- a/backend/handler/email_admin.go +++ b/backend/handler/email_admin.go @@ -42,23 +42,6 @@ func NewEmailAdminHandler(cfg *config.Config, persister persistence.Persister) E } } -func loadDto[I admin.EmailRequests](ctx echo.Context) (*I, error) { - var adminDto I - err := ctx.Bind(&adminDto) - if err != nil { - ctx.Logger().Error(err) - return nil, echo.NewHTTPError(http.StatusBadRequest, err) - } - - err = ctx.Validate(adminDto) - if err != nil { - ctx.Logger().Error(err) - return nil, echo.NewHTTPError(http.StatusBadRequest, err) - } - - return &adminDto, nil -} - func (h *emailAdminHandler) List(ctx echo.Context) error { listDto, err := loadDto[admin.ListEmailRequestDto](ctx) if err != nil { diff --git a/backend/handler/otp_admin.go b/backend/handler/otp_admin.go new file mode 100644 index 000000000..0e1935eea --- /dev/null +++ b/backend/handler/otp_admin.go @@ -0,0 +1,77 @@ +package handler + +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/labstack/echo/v4" + "github.com/teamhanko/hanko/backend/dto/admin" + "github.com/teamhanko/hanko/backend/persistence" + "net/http" +) + +type OTPAdminHandler interface { + Get(ctx echo.Context) error + Delete(ctx echo.Context) error +} + +type otpAdminHandler struct { + persister persistence.Persister +} + +func NewOTPAdminHandler(persister persistence.Persister) OTPAdminHandler { + return &otpAdminHandler{persister: persister} +} + +func (h *otpAdminHandler) Get(ctx echo.Context) error { + getDto, err := loadDto[admin.GetOTPRequestDto](ctx) + if err != nil { + return err + } + + userID, err := uuid.FromString(getDto.UserID) + if err != nil { + return fmt.Errorf(parseUserUuidFailureMessage, err) + } + + userModel, err := h.persister.GetUserPersister().Get(userID) + if err != nil { + return err + } + + if userModel == nil || userModel.OTPSecret == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + return ctx.JSON(http.StatusOK, admin.OTPDto{ + ID: userModel.OTPSecret.ID, + CreatedAt: userModel.OTPSecret.CreatedAt, + }) +} + +func (h *otpAdminHandler) Delete(ctx echo.Context) error { + deleteDto, err := loadDto[admin.GetOTPRequestDto](ctx) + if err != nil { + return err + } + + userID, err := uuid.FromString(deleteDto.UserID) + if err != nil { + return fmt.Errorf(parseUserUuidFailureMessage, err) + } + + userModel, err := h.persister.GetUserPersister().Get(userID) + if err != nil { + return err + } + + if userModel == nil || userModel.OTPSecret == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + err = h.persister.GetOTPSecretPersister().Delete(userModel.OTPSecret) + if err != nil { + return err + } + + return ctx.NoContent(http.StatusNoContent) +} diff --git a/backend/handler/otp_admin_test.go b/backend/handler/otp_admin_test.go new file mode 100644 index 000000000..95d682eeb --- /dev/null +++ b/backend/handler/otp_admin_test.go @@ -0,0 +1,145 @@ +package handler + +import ( + "encoding/json" + "fmt" + "github.com/gofrs/uuid" + "github.com/stretchr/testify/suite" + "github.com/teamhanko/hanko/backend/dto/admin" + "github.com/teamhanko/hanko/backend/test" + "net/http" + "net/http/httptest" + "testing" +) + +func TestOtpAdminSuite(t *testing.T) { + t.Parallel() + suite.Run(t, new(otpAdminSuite)) +} + +type otpAdminSuite struct { + test.Suite +} + +func (s *otpAdminSuite) TestOtpAdminHandler_Get() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/otp") + s.Require().NoError(err) + + e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil) + + tests := []struct { + name string + userId string + expectedStatusCode int + }{ + { + name: "should return otp credential", + userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5", + expectedStatusCode: http.StatusOK, + }, + { + name: "should fail if user has no otp credential", + userId: "38bf5a00-d7ea-40a5-a5de-48722c148925", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on non uuid userID", + userId: "customUserId", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on empty userID", + userId: "", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on non existing user", + userId: "30f41697-b413-43cc-8cca-d55298683607", + expectedStatusCode: http.StatusNotFound, + }, + } + + for _, currentTest := range tests { + s.Run(currentTest.name, func() { + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/users/%s/otp", currentTest.userId), nil) + + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if http.StatusOK == rec.Code { + var otpCredential *admin.OTPDto + s.NoError(json.Unmarshal(rec.Body.Bytes(), &otpCredential)) + s.NotNil(otpCredential) + } else { + s.Require().Equal(currentTest.expectedStatusCode, rec.Code) + } + }) + } +} + +func (s *otpAdminSuite) TestOtpAdminHandler_Delete() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/otp") + s.Require().NoError(err) + + e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil) + + tests := []struct { + name string + userId string + expectedStatusCode int + }{ + { + name: "should delete the otp credential", + userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5", + expectedStatusCode: http.StatusNoContent, + }, + { + name: "should fail if user has no otp credential", + userId: "38bf5a00-d7ea-40a5-a5de-48722c148925", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on non uuid userID", + userId: "customUserId", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on empty userID", + userId: "", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on non existing user", + userId: "30f41697-b413-43cc-8cca-d55298683607", + expectedStatusCode: http.StatusNotFound, + }, + } + + for _, currentTest := range tests { + s.Run(currentTest.name, func() { + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/users/%s/otp", currentTest.userId), nil) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + s.Require().Equal(currentTest.expectedStatusCode, rec.Code) + + if http.StatusNoContent == rec.Code { + cred, err := s.Storage.GetPasswordCredentialPersister().GetByUserID(uuid.FromStringOrNil(currentTest.userId)) + s.Require().NoError(err) + s.Require().Nil(cred) + } + }) + } +} diff --git a/backend/handler/password_admin.go b/backend/handler/password_admin.go new file mode 100644 index 000000000..bf3e39ce2 --- /dev/null +++ b/backend/handler/password_admin.go @@ -0,0 +1,201 @@ +package handler + +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/labstack/echo/v4" + "github.com/teamhanko/hanko/backend/dto/admin" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/persistence" + "net/http" +) + +type PasswordAdminHandler interface { + Get(ctx echo.Context) error + Create(ctx echo.Context) error + Update(ctx echo.Context) error + Delete(ctx echo.Context) error +} + +type passwordAdminHandler struct { + persister persistence.Persister + passwordService services.Password +} + +func NewPasswordAdminHandler(persister persistence.Persister) PasswordAdminHandler { + return &passwordAdminHandler{ + persister: persister, + passwordService: services.NewPasswordService(persister), + } +} + +func (h *passwordAdminHandler) Get(ctx echo.Context) error { + getDto, err := loadDto[admin.GetPasswordCredentialRequestDto](ctx) + if err != nil { + return err + } + + userID, err := uuid.FromString(getDto.UserID) + if err != nil { + return fmt.Errorf(parseUserUuidFailureMessage, err) + } + + user, err := h.persister.GetUserPersister().Get(userID) + if err != nil { + return err + } + + if user == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + credential, err := h.persister.GetPasswordCredentialPersister().GetByUserID(userID) + if err != nil { + return err + } + + if credential == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + dto := admin.PasswordCredential{ + ID: credential.ID, + CreatedAt: credential.CreatedAt, + UpdatedAt: credential.UpdatedAt, + } + + return ctx.JSON(http.StatusOK, dto) +} + +func (h *passwordAdminHandler) Create(ctx echo.Context) error { + createDto, err := loadDto[admin.CreateOrUpdatePasswordCredentialRequestDto](ctx) + if err != nil { + return err + } + + userID, err := uuid.FromString(createDto.UserID) + if err != nil { + return fmt.Errorf(parseUserUuidFailureMessage, err) + } + + user, err := h.persister.GetUserPersister().Get(userID) + if err != nil { + return err + } + + if user == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + existingCredential, err := h.persister.GetPasswordCredentialPersister().GetByUserID(userID) + if err != nil { + return err + } + + if existingCredential != nil { + return echo.NewHTTPError(http.StatusConflict) + } + + err = h.passwordService.CreatePassword(h.persister.GetConnection(), userID, createDto.Password) + if err != nil { + return err + } + + credential, err := h.persister.GetPasswordCredentialPersister().GetByUserID(userID) + if err != nil { + return err + } + + dto := admin.PasswordCredential{ + ID: credential.ID, + CreatedAt: credential.CreatedAt, + UpdatedAt: credential.UpdatedAt, + } + + return ctx.JSON(http.StatusOK, dto) +} + +func (h *passwordAdminHandler) Update(ctx echo.Context) error { + updateDto, err := loadDto[admin.CreateOrUpdatePasswordCredentialRequestDto](ctx) + if err != nil { + return err + } + + userID, err := uuid.FromString(updateDto.UserID) + if err != nil { + return fmt.Errorf(parseUserUuidFailureMessage, err) + } + + user, err := h.persister.GetUserPersister().Get(userID) + if err != nil { + return err + } + + if user == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + credential, err := h.persister.GetPasswordCredentialPersister().GetByUserID(userID) + if err != nil { + return err + } + + if credential == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + err = h.passwordService.UpdatePassword(h.persister.GetConnection(), credential, updateDto.Password) + if err != nil { + return err + } + + credential, err = h.persister.GetPasswordCredentialPersister().GetByUserID(userID) + if err != nil { + return err + } + + dto := admin.PasswordCredential{ + ID: credential.ID, + CreatedAt: credential.CreatedAt, + UpdatedAt: credential.UpdatedAt, + } + + return ctx.JSON(http.StatusOK, dto) +} + +func (h *passwordAdminHandler) Delete(ctx echo.Context) error { + getDto, err := loadDto[admin.GetPasswordCredentialRequestDto](ctx) + if err != nil { + return err + } + + userID, err := uuid.FromString(getDto.UserID) + if err != nil { + return fmt.Errorf(parseUserUuidFailureMessage, err) + } + + user, err := h.persister.GetUserPersister().Get(userID) + if err != nil { + return err + } + + if user == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + credential, err := h.persister.GetPasswordCredentialPersister().GetByUserID(userID) + if err != nil { + return err + } + + if credential == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + err = h.persister.GetPasswordCredentialPersister().Delete(*credential) + if err != nil { + return err + } + + return ctx.NoContent(http.StatusNoContent) +} diff --git a/backend/handler/password_admin_test.go b/backend/handler/password_admin_test.go new file mode 100644 index 000000000..4d3088df4 --- /dev/null +++ b/backend/handler/password_admin_test.go @@ -0,0 +1,319 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/gofrs/uuid" + "github.com/stretchr/testify/suite" + "github.com/teamhanko/hanko/backend/dto/admin" + "github.com/teamhanko/hanko/backend/test" + "net/http" + "net/http/httptest" + "testing" +) + +func TestPasswordAdminSuite(t *testing.T) { + t.Parallel() + suite.Run(t, new(passwordAdminSuite)) +} + +type passwordAdminSuite struct { + test.Suite +} + +func (s *passwordAdminSuite) TestPasswordAdminHandler_Get() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/password") + s.Require().NoError(err) + + e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil) + + tests := []struct { + name string + userId string + expectedStatusCode int + }{ + { + name: "should return password credential", + userId: "38bf5a00-d7ea-40a5-a5de-48722c148925", + }, + { + name: "should fail if user has no password", + userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on non uuid userID", + userId: "customUserId", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on empty userID", + userId: "", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on non existing user", + userId: "30f41697-b413-43cc-8cca-d55298683607", + expectedStatusCode: http.StatusNotFound, + }, + } + + for _, currentTest := range tests { + s.Run(currentTest.name, func() { + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/users/%s/password", currentTest.userId), nil) + + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if http.StatusOK == rec.Code { + var passwordCredential *admin.PasswordCredential + s.NoError(json.Unmarshal(rec.Body.Bytes(), &passwordCredential)) + s.NotNil(passwordCredential) + } else { + s.Require().Equal(currentTest.expectedStatusCode, rec.Code) + } + }) + } +} + +func (s *passwordAdminSuite) TestPasswordAdminHandler_Create() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/password") + s.Require().NoError(err) + + e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil) + + tests := []struct { + name string + userId string + password string + expectedStatusCode int + }{ + { + name: "should create a new password credential", + userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5", + password: "superSecure", + }, + { + name: "should fail if user already has a password", + userId: "38bf5a00-d7ea-40a5-a5de-48722c148925", + password: "superSecure", + expectedStatusCode: http.StatusConflict, + }, + { + name: "should fail on non uuid userID", + userId: "customUserId", + password: "superSecure", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on empty userID", + userId: "", + password: "superSecure", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on non existing user", + userId: "30f41697-b413-43cc-8cca-d55298683607", + password: "superSecure", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on empty password", + userId: "30f41697-b413-43cc-8cca-d55298683607", + password: "", + expectedStatusCode: http.StatusBadRequest, + }, + } + + for _, currentTest := range tests { + s.Run(currentTest.name, func() { + testDto := admin.CreateOrUpdatePasswordCredentialRequestDto{ + GetPasswordCredentialRequestDto: admin.GetPasswordCredentialRequestDto{ + UserID: currentTest.userId, + }, + Password: currentTest.password, + } + + testJson, err := json.Marshal(testDto) + s.Require().NoError(err) + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/users/%s/password", currentTest.userId), bytes.NewReader(testJson)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if http.StatusOK == rec.Code { + var passwordCredential *admin.PasswordCredential + s.NoError(json.Unmarshal(rec.Body.Bytes(), &passwordCredential)) + s.NotNil(passwordCredential) + + cred, err := s.Storage.GetPasswordCredentialPersister().GetByUserID(uuid.FromStringOrNil(currentTest.userId)) + s.Require().NoError(err) + s.Require().NotNil(cred) + } else { + s.Require().Equal(currentTest.expectedStatusCode, rec.Code) + } + }) + } +} + +func (s *passwordAdminSuite) TestPasswordAdminHandler_Update() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/password") + s.Require().NoError(err) + + e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil) + + tests := []struct { + name string + userId string + oldHashedPassword string + password string + expectedStatusCode int + }{ + { + name: "should update a password credential", + userId: "38bf5a00-d7ea-40a5-a5de-48722c148925", + oldHashedPassword: "$2a$12$Cf7k.dG6pznTUJ5u2u1pgu6I4VXH5.9O0NZsDk8TwWwyBkZovYVli", + password: "superSecure", + }, + { + name: "should fail if user already has no password", + userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5", + password: "superSecure", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on non uuid userID", + userId: "customUserId", + password: "superSecure", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on empty userID", + userId: "", + password: "superSecure", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on non existing user", + userId: "30f41697-b413-43cc-8cca-d55298683607", + password: "superSecure", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on empty password", + userId: "30f41697-b413-43cc-8cca-d55298683607", + password: "", + expectedStatusCode: http.StatusBadRequest, + }, + } + + for _, currentTest := range tests { + s.Run(currentTest.name, func() { + testDto := admin.CreateOrUpdatePasswordCredentialRequestDto{ + GetPasswordCredentialRequestDto: admin.GetPasswordCredentialRequestDto{ + UserID: currentTest.userId, + }, + Password: currentTest.password, + } + + testJson, err := json.Marshal(testDto) + s.Require().NoError(err) + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%s/password", currentTest.userId), bytes.NewReader(testJson)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if http.StatusOK == rec.Code { + var passwordCredential *admin.PasswordCredential + s.NoError(json.Unmarshal(rec.Body.Bytes(), &passwordCredential)) + s.NotNil(passwordCredential) + + cred, err := s.Storage.GetPasswordCredentialPersister().GetByUserID(uuid.FromStringOrNil(currentTest.userId)) + s.Require().NoError(err) + s.NotEqual(currentTest.oldHashedPassword, cred.Password) + } else { + s.Require().Equal(currentTest.expectedStatusCode, rec.Code) + } + }) + } +} + +func (s *passwordAdminSuite) TestPasswordAdminHandler_Delete() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/password") + s.Require().NoError(err) + + e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil) + + tests := []struct { + name string + userId string + expectedStatusCode int + }{ + { + name: "should delete a password credential", + userId: "38bf5a00-d7ea-40a5-a5de-48722c148925", + expectedStatusCode: http.StatusNoContent, + }, + { + name: "should fail if user already has no password", + userId: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a5", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on non uuid userID", + userId: "customUserId", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on empty userID", + userId: "", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on non existing user", + userId: "30f41697-b413-43cc-8cca-d55298683607", + expectedStatusCode: http.StatusNotFound, + }, + } + + for _, currentTest := range tests { + s.Run(currentTest.name, func() { + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/users/%s/password", currentTest.userId), nil) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + s.Require().Equal(currentTest.expectedStatusCode, rec.Code) + + if http.StatusNoContent == rec.Code { + cred, err := s.Storage.GetPasswordCredentialPersister().GetByUserID(uuid.FromStringOrNil(currentTest.userId)) + s.Require().NoError(err) + s.Require().Nil(cred) + } + }) + } +} diff --git a/backend/handler/public_router.go b/backend/handler/public_router.go index 89865b016..1c34f9b14 100644 --- a/backend/handler/public_router.go +++ b/backend/handler/public_router.go @@ -31,7 +31,7 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister, promet emailService, err := services.NewEmailService(*cfg) passcodeService := services.NewPasscodeService(*cfg, *emailService, persister) - passwordService := services.NewPasswordService(*cfg, persister) + passwordService := services.NewPasswordService(persister) webauthnService := services.NewWebauthnService(*cfg, persister) jwkManager, err := jwk.NewDefaultManager(cfg.Secrets.Keys, persister.GetJwkPersister()) diff --git a/backend/handler/session_admin.go b/backend/handler/session_admin.go index 3e1f75ada..c9c7ec55f 100644 --- a/backend/handler/session_admin.go +++ b/backend/handler/session_admin.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/gofrs/uuid" "github.com/labstack/echo/v4" + "github.com/pkg/errors" auditlog "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/dto" @@ -111,3 +112,75 @@ func (h *SessionAdminHandler) Generate(ctx echo.Context) error { return ctx.JSON(http.StatusOK, response) } + +func (h *SessionAdminHandler) List(ctx echo.Context) error { + listDto, err := loadDto[admin.ListSessionsRequestDto](ctx) + if err != nil { + return err + } + + userID, err := uuid.FromString(listDto.UserID) + if err != nil { + return fmt.Errorf(parseUserUuidFailureMessage, err) + } + + user, err := h.persister.GetUserPersister().Get(userID) + if err != nil { + return err + } + + if user == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + sessions, err := h.persister.GetSessionPersister().ListActive(userID) + if err != nil { + return err + } + + return ctx.JSON(http.StatusOK, sessions) +} + +func (h *SessionAdminHandler) Delete(ctx echo.Context) error { + deleteDto, err := loadDto[admin.DeleteSessionRequestDto](ctx) + if err != nil { + return err + } + + userID, err := uuid.FromString(deleteDto.UserID) + if err != nil { + return fmt.Errorf(parseUserUuidFailureMessage, err) + } + + user, err := h.persister.GetUserPersister().Get(userID) + if err != nil { + return err + } + + if user == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + sessionID, err := uuid.FromString(deleteDto.SessionID) + if err != nil { + return fmt.Errorf("failed to parse session_id as uuid: %s", err) + } + + sessionModel, err := h.persister.GetSessionPersister().Get(sessionID) + if err != nil { + return err + } + + if sessionModel == nil { + return echo.NewHTTPError(http.StatusNotFound) + } else if sessionModel.UserID != userID { + return echo.NewHTTPError(http.StatusNotFound).SetInternal(errors.New("session does not belong to user")) + } + + err = h.persister.GetSessionPersister().Delete(*sessionModel) + if err != nil { + return err + } + + return ctx.NoContent(http.StatusNoContent) +} diff --git a/backend/handler/session_admin_test.go b/backend/handler/session_admin_test.go new file mode 100644 index 000000000..975144c5d --- /dev/null +++ b/backend/handler/session_admin_test.go @@ -0,0 +1,181 @@ +package handler + +import ( + "encoding/json" + "fmt" + "github.com/gofrs/uuid" + "github.com/stretchr/testify/suite" + "github.com/teamhanko/hanko/backend/dto/admin" + "github.com/teamhanko/hanko/backend/test" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSessionAdminSuite(t *testing.T) { + t.Parallel() + suite.Run(t, new(sessionAdminSuite)) +} + +type sessionAdminSuite struct { + test.Suite +} + +func (s *sessionAdminSuite) TestSessionAdminHandler_List() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/sessions") + s.Require().NoError(err) + + e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil) + + tests := []struct { + name string + userID string + expectedStatusCode int + expectedCount int + }{ + { + name: "should return a list of sessions with multiple entries", + userID: "ec4ef049-5b88-4321-a173-21b0eff06a04", + expectedStatusCode: http.StatusOK, + expectedCount: 2, + }, + { + name: "should return a list of sessions with one entry", + userID: "38bf5a00-d7ea-40a5-a5de-48722c148925", + expectedStatusCode: http.StatusOK, + expectedCount: 1, + }, + { + name: "should return an empty list", + userID: "46626836-f2db-4ec0-8752-858b544cbc78", + expectedStatusCode: http.StatusOK, + expectedCount: 0, + }, + { + name: "should fail on non uuid userID", + userID: "customUserId", + expectedStatusCode: http.StatusBadRequest, + expectedCount: 0, + }, + { + name: "should fail on empty userID", + userID: "", + expectedStatusCode: http.StatusBadRequest, + expectedCount: 0, + }, + { + name: "should fail on non existing user", + userID: "30f41697-b413-43cc-8cca-d55298683607", + expectedStatusCode: http.StatusNotFound, + expectedCount: 0, + }, + } + + for _, currentTest := range tests { + s.Run(currentTest.name, func() { + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/users/%s/sessions", currentTest.userID), nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + s.Equal(currentTest.expectedStatusCode, rec.Code) + if http.StatusOK == rec.Code { + var sessions []admin.ListSessionsRequestDto + err = json.Unmarshal(rec.Body.Bytes(), &sessions) + s.Require().NoError(err) + + s.Equal(currentTest.expectedCount, len(sessions)) + } + }) + } +} + +func (s *sessionAdminSuite) TestSessionAdminHandler_Delete() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/sessions") + s.Require().NoError(err) + + e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil) + + tests := []struct { + name string + userID string + sessionID string + expectedStatusCode int + expectedCount int + }{ + { + name: "should delete session for user with multiple sessions", + userID: "ec4ef049-5b88-4321-a173-21b0eff06a04", + sessionID: "d8d6dc27-fcf9-4a5c-bb50-a7a03067d936", + expectedCount: 1, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "should delete session for user with one session", + userID: "38bf5a00-d7ea-40a5-a5de-48722c148925", + sessionID: "108f3789-a795-43bd-a58f-ac8e80a213cd", + expectedCount: 0, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "should fail if session is not found", + userID: "46626836-f2db-4ec0-8752-858b544cbc78", + sessionID: "649c95d7-9840-4e6d-be00-6c6b93c9e885", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail if session is not associated to the user", + userID: "38bf5a00-d7ea-40a5-a5de-48722c148925", + sessionID: "74ba812a-923a-43e4-8020-9535dcadc0a8", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on non existing user", + userID: "30f41697-b413-43cc-8cca-d55298683607", + sessionID: "6e405e60-f70c-4b8a-b0d5-8ba05dd3e793", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on empty userID", + userID: "", + sessionID: "6e405e60-f70c-4b8a-b0d5-8ba05dd3e793", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on empty sessionID", + userID: "46626836-f2db-4ec0-8752-858b544cbc78", + sessionID: "", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on non uuid userID", + userID: "customUserId", + sessionID: "d8d6dc27-fcf9-4a5c-bb50-a7a03067d936", + expectedStatusCode: http.StatusBadRequest, + }, + } + + for _, currentTest := range tests { + s.Run(currentTest.name, func() { + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/users/%s/sessions/%s", currentTest.userID, currentTest.sessionID), nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + s.Equal(currentTest.expectedStatusCode, rec.Code) + if http.StatusNoContent == rec.Code { + credentials, err := s.Storage.GetSessionPersister().ListActive(uuid.FromStringOrNil(currentTest.userID)) + s.Require().NoError(err) + s.Equal(currentTest.expectedCount, len(credentials)) + } + }) + } +} diff --git a/backend/handler/utils.go b/backend/handler/utils.go new file mode 100644 index 000000000..3c2cbeef8 --- /dev/null +++ b/backend/handler/utils.go @@ -0,0 +1,23 @@ +package handler + +import ( + "github.com/labstack/echo/v4" + "net/http" +) + +func loadDto[I any](ctx echo.Context) (*I, error) { + var adminDto I + err := ctx.Bind(&adminDto) + if err != nil { + ctx.Logger().Error(err) + return nil, echo.NewHTTPError(http.StatusBadRequest, err) + } + + err = ctx.Validate(adminDto) + if err != nil { + ctx.Logger().Error(err) + return nil, echo.NewHTTPError(http.StatusBadRequest, err) + } + + return &adminDto, nil +} diff --git a/backend/handler/webauthn_credential_admin.go b/backend/handler/webauthn_credential_admin.go new file mode 100644 index 000000000..8470e8d09 --- /dev/null +++ b/backend/handler/webauthn_credential_admin.go @@ -0,0 +1,129 @@ +package handler + +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/labstack/echo/v4" + "github.com/teamhanko/hanko/backend/dto" + "github.com/teamhanko/hanko/backend/dto/admin" + "github.com/teamhanko/hanko/backend/persistence" + "net/http" +) + +type WebauthnCredentialAdminHandler interface { + List(ctx echo.Context) error + Get(ctx echo.Context) error + Delete(ctx echo.Context) error +} + +type webauthnCredentialAdminHandler struct { + persister persistence.Persister +} + +func NewWebauthnCredentialAdminHandler(persister persistence.Persister) WebauthnCredentialAdminHandler { + return &webauthnCredentialAdminHandler{ + persister: persister, + } +} + +func (h *webauthnCredentialAdminHandler) List(ctx echo.Context) error { + listDto, err := loadDto[admin.ListWebauthnCredentialsRequestDto](ctx) + if err != nil { + return err + } + + userID, err := uuid.FromString(listDto.UserID) + if err != nil { + return fmt.Errorf(parseUserUuidFailureMessage, err) + } + + user, err := h.persister.GetUserPersister().Get(userID) + if err != nil { + return err + } + + if user == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + credentials, err := h.persister.GetWebauthnCredentialPersister().GetFromUser(userID) + if err != nil { + return err + } + + credentialResponses := make([]dto.WebauthnCredentialResponse, len(credentials)) + for i := range credentials { + credentialResponses[i] = *dto.FromWebauthnCredentialModel(&credentials[i]) + } + + return ctx.JSON(http.StatusOK, credentialResponses) +} + +func (h *webauthnCredentialAdminHandler) Get(ctx echo.Context) error { + getDto, err := loadDto[admin.GetWebauthnCredentialRequestDto](ctx) + if err != nil { + return err + } + + userID, err := uuid.FromString(getDto.UserID) + if err != nil { + return fmt.Errorf(parseUserUuidFailureMessage, err) + } + + user, err := h.persister.GetUserPersister().Get(userID) + if err != nil { + return err + } + + if user == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + credential, err := h.persister.GetWebauthnCredentialPersister().Get(getDto.WebauthnCredentialID) + if err != nil { + return err + } + + if credential == nil || credential.UserId != userID { + return echo.NewHTTPError(http.StatusNotFound, "webauthn credential not found") + } + + return ctx.JSON(http.StatusOK, dto.FromWebauthnCredentialModel(credential)) +} + +func (h *webauthnCredentialAdminHandler) Delete(ctx echo.Context) error { + deleteDto, err := loadDto[admin.GetWebauthnCredentialRequestDto](ctx) + if err != nil { + return err + } + + userID, err := uuid.FromString(deleteDto.UserID) + if err != nil { + return fmt.Errorf(parseUserUuidFailureMessage, err) + } + + user, err := h.persister.GetUserPersister().Get(userID) + if err != nil { + return err + } + + if user == nil { + return echo.NewHTTPError(http.StatusNotFound) + } + + credential, err := h.persister.GetWebauthnCredentialPersister().Get(deleteDto.WebauthnCredentialID) + if err != nil { + return err + } + + if credential == nil || credential.UserId != userID { + return echo.NewHTTPError(http.StatusNotFound, "webauthn credential not found") + } + + err = h.persister.GetWebauthnCredentialPersister().Delete(*credential) + if err != nil { + return err + } + + return ctx.NoContent(http.StatusNoContent) +} diff --git a/backend/handler/webauthn_credential_admin_test.go b/backend/handler/webauthn_credential_admin_test.go new file mode 100644 index 000000000..8def308ce --- /dev/null +++ b/backend/handler/webauthn_credential_admin_test.go @@ -0,0 +1,256 @@ +package handler + +import ( + "encoding/json" + "fmt" + "github.com/gofrs/uuid" + "github.com/stretchr/testify/suite" + "github.com/teamhanko/hanko/backend/dto" + "github.com/teamhanko/hanko/backend/test" + "net/http" + "net/http/httptest" + "testing" +) + +func TestWebauthnCredentialAdminSuite(t *testing.T) { + t.Parallel() + suite.Run(t, new(webauthnCredentialAdminSuite)) +} + +type webauthnCredentialAdminSuite struct { + test.Suite +} + +func (s *webauthnCredentialAdminSuite) TestWebauthnCredentialAdminHandler_List() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/webauthn") + s.Require().NoError(err) + + e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil) + + tests := []struct { + name string + userID string + expectedCount int + expectedStatusCode int + }{ + { + name: "should return webauthn credentials for user with multiple credentials", + userID: "ec4ef049-5b88-4321-a173-21b0eff06a04", + expectedCount: 2, + expectedStatusCode: http.StatusOK, + }, + { + name: "should return webauthn credentials for user with one credentials", + userID: "46626836-f2db-4ec0-8752-858b544cbc78", + expectedCount: 1, + expectedStatusCode: http.StatusOK, + }, + { + name: "should return webauthn credentials for user with no credentials", + userID: "38bf5a00-d7ea-40a5-a5de-48722c148925", + expectedCount: 0, + expectedStatusCode: http.StatusOK, + }, + { + name: "should fail on non uuid userID", + userID: "customUserId", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on empty userID", + userID: "", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on non existing user", + userID: "30f41697-b413-43cc-8cca-d55298683607", + expectedStatusCode: http.StatusNotFound, + }, + } + + for _, currentTest := range tests { + s.Run(currentTest.name, func() { + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/users/%s/webauthn_credentials", currentTest.userID), nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + s.Equal(currentTest.expectedStatusCode, rec.Code) + if http.StatusOK == rec.Code { + var credentials []dto.WebauthnCredentialResponse + err = json.Unmarshal(rec.Body.Bytes(), &credentials) + s.Require().NoError(err) + + s.Equal(len(credentials), currentTest.expectedCount) + } + }) + } +} + +func (s *webauthnCredentialAdminSuite) TestWebauthnCredentialAdminHandler_Get() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/webauthn") + s.Require().NoError(err) + + e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil) + + tests := []struct { + name string + userID string + credentialID string + expectedStatusCode int + }{ + { + name: "should return webauthn credential", + userID: "46626836-f2db-4ec0-8752-858b544cbc78", + credentialID: "4iVZGFN_jktXJmwmBmaSq0Qr4T62T0jX7PS7XcgAWlM", + expectedStatusCode: http.StatusOK, + }, + { + name: "should fail if credential is not found", + userID: "46626836-f2db-4ec0-8752-858b544cbc78", + credentialID: "notSoRandomCredentialID", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail if credential is not associated to the user", + userID: "ec4ef049-5b88-4321-a173-21b0eff06a04", + credentialID: "4iVZGFN_jktXJmwmBmaSq0Qr4T62T0jX7PS7XcgAWlM", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on non existing user", + userID: "b5dd5267-b462-48be-b70d-bcd6f1bbe7a6", + credentialID: "4iVZGFN_jktXJmwmBmaSq0Qr4T62T0jX7PS7XcgAWlM", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on empty userID", + userID: "", + credentialID: "4iVZGFN_jktXJmwmBmaSq0Qr4T62T0jX7PS7XcgAWlM", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on empty credentialID", + userID: "46626836-f2db-4ec0-8752-858b544cbc78", + credentialID: "", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on non uuid userID", + userID: "customUserId", + credentialID: "46626836-f2db-4ec0-8752-858b544cbc78", + expectedStatusCode: http.StatusBadRequest, + }, + } + + for _, currentTest := range tests { + s.Run(currentTest.name, func() { + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/users/%s/webauthn_credentials/%s", currentTest.userID, currentTest.credentialID), nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + s.Equal(currentTest.expectedStatusCode, rec.Code) + if http.StatusOK == rec.Code { + var credential dto.WebauthnCredentialResponse + err = json.Unmarshal(rec.Body.Bytes(), &credential) + s.Require().NoError(err) + s.Equal(currentTest.credentialID, credential.ID) + } + }) + } +} + +func (s *webauthnCredentialAdminSuite) TestWebauthnCredentialAdminHandler_Delete() { + if testing.Short() { + s.T().Skip("skipping test in short mode.") + } + + err := s.LoadFixtures("../test/fixtures/webauthn") + s.Require().NoError(err) + + e := NewAdminRouter(&test.DefaultConfig, s.Storage, nil) + + tests := []struct { + name string + userID string + credentialID string + expectedCount int + expectedStatusCode int + }{ + { + name: "should delete webauthn credential for user with multiple credentials", + userID: "ec4ef049-5b88-4321-a173-21b0eff06a04", + credentialID: "AaFdkcD4SuPjF-jwUoRwH8-ZHuY5RW46fsZmEvBX6RNKHaGtVzpATs06KQVheIOjYz-YneG4cmQOedzl0e0jF951ukx17Hl9jeGgWz5_DKZCO12p2-2LlzjH", + expectedCount: 1, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "should delete webauthn credential for user with one credential", + userID: "46626836-f2db-4ec0-8752-858b544cbc78", + credentialID: "4iVZGFN_jktXJmwmBmaSq0Qr4T62T0jX7PS7XcgAWlM", + expectedCount: 0, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "should fail if credential is not found", + userID: "46626836-f2db-4ec0-8752-858b544cbc78", + credentialID: "notSoRandomCredentialID", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail if credential is not associated to the user", + userID: "46626836-f2db-4ec0-8752-858b544cbc78", + credentialID: "AaFdkcD4SuPjF-jwUoRwH8-ZHuY5RW46fsZmEvBX6RNKHaGtVzpATs06KQVheIOjYz-YneG4cmQOedzl0e0jF951ukx17Hl9jeGgWz5_DKZCO12p2-2LlzjK", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on non existing user", + userID: "30f41697-b413-43cc-8cca-d55298683607", + credentialID: "4iVZGFN_jktXJmwmBmaSq0Qr4T62T0jX7PS7XcgAWlM", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on empty userID", + userID: "", + credentialID: "4iVZGFN_jktXJmwmBmaSq0Qr4T62T0jX7PS7XcgAWlM", + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "should fail on empty credentialID", + userID: "46626836-f2db-4ec0-8752-858b544cbc78", + credentialID: "", + expectedStatusCode: http.StatusNotFound, + }, + { + name: "should fail on non uuid userID", + userID: "customUserId", + credentialID: "4iVZGFN_jktXJmwmBmaSq0Qr4T62T0jX7PS7XcgAWlM", + expectedStatusCode: http.StatusBadRequest, + }, + } + + for _, currentTest := range tests { + s.Run(currentTest.name, func() { + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/users/%s/webauthn_credentials/%s", currentTest.userID, currentTest.credentialID), nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + s.Equal(currentTest.expectedStatusCode, rec.Code) + if http.StatusNoContent == rec.Code { + credentials, err := s.Storage.GetWebauthnCredentialPersister().GetFromUser(uuid.FromStringOrNil(currentTest.userID)) + s.Require().NoError(err) + s.Equal(currentTest.expectedCount, len(credentials)) + } + }) + } +} diff --git a/backend/persistence/models/session.go b/backend/persistence/models/session.go index 6dfe7df2c..d0d6f6597 100644 --- a/backend/persistence/models/session.go +++ b/backend/persistence/models/session.go @@ -9,14 +9,14 @@ import ( ) type Session struct { - ID uuid.UUID `db:"id"` - UserID uuid.UUID `db:"user_id"` - UserAgent string `db:"user_agent"` - IpAddress string `db:"ip_address"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - ExpiresAt *time.Time `db:"expires_at"` - LastUsed time.Time `db:"last_used"` + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + UserAgent string `db:"user_agent" json:"user_agent"` + IpAddress string `db:"ip_address" json:"ip_address"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ExpiresAt *time.Time `db:"expires_at" json:"expires_at"` + LastUsed time.Time `db:"last_used" json:"last_used"` } func (session *Session) Validate(tx *pop.Connection) (*validate.Errors, error) { diff --git a/backend/test/fixtures/otp/emails.yaml b/backend/test/fixtures/otp/emails.yaml new file mode 100644 index 000000000..ac30172c2 --- /dev/null +++ b/backend/test/fixtures/otp/emails.yaml @@ -0,0 +1,36 @@ +- id: 51b7c175-ceb6-45ba-aae6-0092221c1b84 + user_id: b5dd5267-b462-48be-b70d-bcd6f1bbe7a5 + address: john.doe@example.com + verified: true + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: 38bf5a00-d7ea-40a5-a5de-48722c148925 + user_id: 38bf5a00-d7ea-40a5-a5de-48722c148925 + address: john.doe+1@example.com + verified: true + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: e0282f3f-b211-4f0e-b777-6fabc69287c9 + user_id: e0282f3f-b211-4f0e-b777-6fabc69287c9 + address: john.doe+2@example.com + verified: true + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: 8bb4c8a7-a3e6-48bb-b54f-20e3b485ab33 + user_id: b5dd5267-b462-48be-b70d-bcd6f1bbe7a5 + address: john.doe+3@example.com + verified: true + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: f194ee0f-dd1a-48f7-8766-c67e4d6cd1fe + user_id: b5dd5267-b462-48be-b70d-bcd6f1bbe7a5 + address: john.doe+4@example.com + verified: true + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: 0394ad95-6ea3-4cbd-940b-3f6dd10440b0 + user_id: 38bf5a00-d7ea-40a5-a5de-48722c148925 + address: john.doe+5@example.com + verified: true + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 diff --git a/backend/test/fixtures/otp/otp_secrets.yaml b/backend/test/fixtures/otp/otp_secrets.yaml new file mode 100644 index 000000000..78a455b6e --- /dev/null +++ b/backend/test/fixtures/otp/otp_secrets.yaml @@ -0,0 +1,5 @@ +- id: f28b15df-6e09-4ac0-b49f-e4e2d274f939 + user_id: b5dd5267-b462-48be-b70d-bcd6f1bbe7a5 + secret: RANDOMOTPSECRET + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 diff --git a/backend/test/fixtures/otp/primary_emails.yaml b/backend/test/fixtures/otp/primary_emails.yaml new file mode 100644 index 000000000..4c941420b --- /dev/null +++ b/backend/test/fixtures/otp/primary_emails.yaml @@ -0,0 +1,10 @@ +- id: 8fe72e5f-edb6-40e7-83a7-a7e858c2c62d + email_id: 51b7c175-ceb6-45ba-aae6-0092221c1b84 + user_id: b5dd5267-b462-48be-b70d-bcd6f1bbe7a5 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: 3a5f340f-07f7-40dc-a507-d5919915e11d + email_id: 38bf5a00-d7ea-40a5-a5de-48722c148925 + user_id: 38bf5a00-d7ea-40a5-a5de-48722c148925 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 diff --git a/backend/test/fixtures/otp/users.yaml b/backend/test/fixtures/otp/users.yaml new file mode 100644 index 000000000..05c64628f --- /dev/null +++ b/backend/test/fixtures/otp/users.yaml @@ -0,0 +1,13 @@ +- id: b5dd5267-b462-48be-b70d-bcd6f1bbe7a5 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: 38bf5a00-d7ea-40a5-a5de-48722c148925 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: e0282f3f-b211-4f0e-b777-6fabc69287c9 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: d41df4b7-c055-45e6-9faf-61aa92a4032e + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 + diff --git a/backend/test/fixtures/sessions/emails.yaml b/backend/test/fixtures/sessions/emails.yaml new file mode 100644 index 000000000..d70f16a1b --- /dev/null +++ b/backend/test/fixtures/sessions/emails.yaml @@ -0,0 +1,18 @@ +- id: 51b7c175-ceb6-45ba-aae6-0092221c1b84 + user_id: ec4ef049-5b88-4321-a173-21b0eff06a04 + address: john.doe@example.com + verified: true + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: 38bf5a00-d7ea-40a5-a5de-48722c148925 + user_id: 38bf5a00-d7ea-40a5-a5de-48722c148925 + address: jane.doe@example.com + verified: true + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: 1215f04b-0fad-4c54-899e-a4f7230fcc63 + user_id: 46626836-f2db-4ec0-8752-858b544cbc78 + address: test@example.com + verified: true + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 diff --git a/backend/test/fixtures/sessions/primary_emails.yaml b/backend/test/fixtures/sessions/primary_emails.yaml new file mode 100644 index 000000000..e7dbac2bb --- /dev/null +++ b/backend/test/fixtures/sessions/primary_emails.yaml @@ -0,0 +1,10 @@ +- id: 8eaaa61b-ad65-45ac-a5b8-d7c6d301d29e + email_id: 51b7c175-ceb6-45ba-aae6-0092221c1b84 + user_id: ec4ef049-5b88-4321-a173-21b0eff06a04 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: df8b4c07-f97e-48aa-8895-6c8e54d5b749 + email_id: 1215f04b-0fad-4c54-899e-a4f7230fcc63 + user_id: 46626836-f2db-4ec0-8752-858b544cbc78 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 diff --git a/backend/test/fixtures/sessions/sessions.yaml b/backend/test/fixtures/sessions/sessions.yaml new file mode 100644 index 000000000..ad8d459a4 --- /dev/null +++ b/backend/test/fixtures/sessions/sessions.yaml @@ -0,0 +1,24 @@ +- id: d8d6dc27-fcf9-4a5c-bb50-a7a03067d936 + user_id: ec4ef049-5b88-4321-a173-21b0eff06a04 + user_agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36 + ip_address: 192.168.0.1 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 + expires_at: 2100-12-31 23:59:59 + last_used: 2020-12-31 23:59:59 +- id: 74ba812a-923a-43e4-8020-9535dcadc0a8 + user_id: ec4ef049-5b88-4321-a173-21b0eff06a04 + user_agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36 + ip_address: 192.168.0.1 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 + expires_at: 2100-12-31 23:59:59 + last_used: 2020-12-31 23:59:59 +- id: 108f3789-a795-43bd-a58f-ac8e80a213cd + user_id: 38bf5a00-d7ea-40a5-a5de-48722c148925 + user_agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36 + ip_address: 192.168.0.1 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 + expires_at: 2100-12-31 23:59:59 + last_used: 2020-12-31 23:59:59 diff --git a/backend/test/fixtures/sessions/users.yaml b/backend/test/fixtures/sessions/users.yaml new file mode 100644 index 000000000..1dcef2904 --- /dev/null +++ b/backend/test/fixtures/sessions/users.yaml @@ -0,0 +1,9 @@ +- id: ec4ef049-5b88-4321-a173-21b0eff06a04 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: 38bf5a00-d7ea-40a5-a5de-48722c148925 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 +- id: 46626836-f2db-4ec0-8752-858b544cbc78 + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59