From 9fce98710679e522233c3b7af8d3b6cd2ce5594c Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 29 Jun 2026 12:11:18 +0200 Subject: [PATCH] refactor: migrate API key functionality to single `apikey` module --- .../{dto/api_key_dto.go => apikey/dto.go} | 12 +- backend/internal/apikey/handler.go | 139 ++++++++++++++++ .../{model/api_key.go => apikey/models.go} | 12 +- backend/internal/apikey/module.go | 58 +++++++ .../api_key_service.go => apikey/service.go} | 129 ++++++--------- .../internal/bootstrap/router_bootstrap.go | 7 +- .../internal/bootstrap/scheduler_bootstrap.go | 2 +- .../internal/bootstrap/services_bootstrap.go | 12 +- backend/internal/common/reserved.go | 5 + .../internal/controller/api_key_controller.go | 156 ------------------ backend/internal/job/api_key_expiry_job.go | 30 +++- backend/internal/middleware/api_key_auth.go | 13 +- .../internal/middleware/auth_middleware.go | 5 +- .../middleware/auth_middleware_test.go | 16 +- backend/internal/service/e2etest_service.go | 3 +- .../internal/service/user_signup_service.go | 2 +- 16 files changed, 331 insertions(+), 270 deletions(-) rename backend/internal/{dto/api_key_dto.go => apikey/dto.go} (83%) create mode 100644 backend/internal/apikey/handler.go rename backend/internal/{model/api_key.go => apikey/models.go} (51%) create mode 100644 backend/internal/apikey/module.go rename backend/internal/{service/api_key_service.go => apikey/service.go} (55%) create mode 100644 backend/internal/common/reserved.go delete mode 100644 backend/internal/controller/api_key_controller.go diff --git a/backend/internal/dto/api_key_dto.go b/backend/internal/apikey/dto.go similarity index 83% rename from backend/internal/dto/api_key_dto.go rename to backend/internal/apikey/dto.go index e1920b67..11b27fc5 100644 --- a/backend/internal/dto/api_key_dto.go +++ b/backend/internal/apikey/dto.go @@ -1,20 +1,20 @@ -package dto +package apikey import ( datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" ) -type ApiKeyCreateDto struct { +type apiKeyCreateDto struct { Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"` Description *string `json:"description" unorm:"nfc"` ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"` } -type ApiKeyRenewDto struct { +type apiKeyRenewDto struct { ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"` } -type ApiKeyDto struct { +type apiKeyDto struct { ID string `json:"id"` Name string `json:"name"` Description *string `json:"description"` @@ -24,7 +24,7 @@ type ApiKeyDto struct { ExpirationEmailSent bool `json:"expirationEmailSent"` } -type ApiKeyResponseDto struct { - ApiKey ApiKeyDto `json:"apiKey"` +type apiKeyResponseDto struct { + ApiKey apiKeyDto `json:"apiKey"` Token string `json:"token"` } diff --git a/backend/internal/apikey/handler.go b/backend/internal/apikey/handler.go new file mode 100644 index 00000000..d11caf0d --- /dev/null +++ b/backend/internal/apikey/handler.go @@ -0,0 +1,139 @@ +package apikey + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +type handler struct { + service *Service +} + +func newHandler(service *Service) *handler { + return &handler{service: service} +} + +// list godoc +// @Summary List API keys +// @Description Get a paginated list of API keys belonging to the current user +// @Tags API Keys +// @Param pagination[page] query int false "Page number for pagination" default(1) +// @Param pagination[limit] query int false "Number of items per page" default(20) +// @Param sort[column] query string false "Column to sort by" +// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") +// @Success 200 {object} dto.Paginated[apiKeyDto] +// @Router /api/api-keys [get] +func (h *handler) list(c *gin.Context) { + listRequestOptions := utils.ParseListRequestOptions(c) + + userID := c.GetString("userID") + + apiKeys, pagination, err := h.service.ListApiKeys(c.Request.Context(), userID, listRequestOptions) + if err != nil { + _ = c.Error(err) + return + } + + var apiKeysDto []apiKeyDto + if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, dto.Paginated[apiKeyDto]{ + Data: apiKeysDto, + Pagination: pagination, + }) +} + +// create godoc +// @Summary Create API key +// @Description Create a new API key for the current user +// @Tags API Keys +// @Param api_key body apiKeyCreateDto true "API key information" +// @Success 201 {object} apiKeyResponseDto "Created API key with token" +// @Router /api/api-keys [post] +func (h *handler) create(c *gin.Context) { + userID := c.GetString("userID") + + var input apiKeyCreateDto + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { + _ = c.Error(err) + return + } + + apiKey, token, err := h.service.CreateApiKey(c.Request.Context(), userID, input) + if err != nil { + _ = c.Error(err) + return + } + + var responseDto apiKeyDto + if err := dto.MapStruct(apiKey, &responseDto); err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusCreated, apiKeyResponseDto{ + ApiKey: responseDto, + Token: token, + }) +} + +// renew godoc +// @Summary Renew API key +// @Description Renew an existing API key by ID +// @Tags API Keys +// @Param id path string true "API Key ID" +// @Success 200 {object} apiKeyResponseDto "Renewed API key with new token" +// @Router /api/api-keys/{id}/renew [post] +func (h *handler) renew(c *gin.Context) { + userID := c.GetString("userID") + apiKeyID := c.Param("id") + + var input apiKeyRenewDto + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { + _ = c.Error(err) + return + } + + apiKey, token, err := h.service.RenewApiKey(c.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime()) + if err != nil { + _ = c.Error(err) + return + } + + var responseDto apiKeyDto + if err := dto.MapStruct(apiKey, &responseDto); err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, apiKeyResponseDto{ + ApiKey: responseDto, + Token: token, + }) +} + +// revoke godoc +// @Summary Revoke API key +// @Description Revoke (delete) an existing API key by ID +// @Tags API Keys +// @Param id path string true "API Key ID" +// @Success 204 "No Content" +// @Router /api/api-keys/{id} [delete] +func (h *handler) revoke(c *gin.Context) { + userID := c.GetString("userID") + apiKeyID := c.Param("id") + + if err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID); err != nil { + _ = c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/model/api_key.go b/backend/internal/apikey/models.go similarity index 51% rename from backend/internal/model/api_key.go rename to backend/internal/apikey/models.go index c2ff9308..e5a128e6 100644 --- a/backend/internal/model/api_key.go +++ b/backend/internal/apikey/models.go @@ -1,9 +1,13 @@ -package model +package apikey -import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" +import ( + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" +) +// ApiKey is a personal access token a user can use to authenticate against the API type ApiKey struct { - Base + model.Base Name string `sortable:"true"` Key string @@ -13,5 +17,5 @@ type ApiKey struct { ExpirationEmailSent bool UserID string - User User + User model.User } diff --git a/backend/internal/apikey/module.go b/backend/internal/apikey/module.go new file mode 100644 index 00000000..dbc18afc --- /dev/null +++ b/backend/internal/apikey/module.go @@ -0,0 +1,58 @@ +package apikey + +import ( + "context" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/model" +) + +type Dependencies struct { + DB *gorm.DB + StaticApiKey string +} + +type Module struct { + service *Service + handler *handler +} + +func New(ctx context.Context, deps Dependencies) (*Module, error) { + service, err := newService(ctx, deps.DB, deps.StaticApiKey) + if err != nil { + return nil, err + } + + return &Module{ + service: service, + handler: newHandler(service), + }, nil +} + +// RegisterRoutes mounts the API key management endpoints +// authWithoutApiKey disables API key authentication so an API key cannot be used to mint or renew further API keys +func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, authWithoutApiKey gin.HandlerFunc) { + group := apiGroup.Group("/api-keys") + group.GET("", auth, m.handler.list) + group.POST("", authWithoutApiKey, m.handler.create) + group.POST("/:id/renew", authWithoutApiKey, m.handler.renew) + group.DELETE("/:id", auth, m.handler.revoke) +} + +// ValidateApiKey resolves the user that owns the given raw API key +// It is used by the authentication middleware +func (m *Module) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) { + return m.service.ValidateApiKey(ctx, apiKey) +} + +// ListExpiringApiKeys returns API keys expiring within the given number of days that have not been notified yet +func (m *Module) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]ApiKey, error) { + return m.service.ListExpiringApiKeys(ctx, daysAhead) +} + +// MarkExpirationEmailSent records that the expiration notification email was sent for the given API key +func (m *Module) MarkExpirationEmailSent(ctx context.Context, apiKeyID string) error { + return m.service.MarkExpirationEmailSent(ctx, apiKeyID) +} diff --git a/backend/internal/service/api_key_service.go b/backend/internal/apikey/service.go similarity index 55% rename from backend/internal/service/api_key_service.go rename to backend/internal/apikey/service.go index cb409ec5..581106c3 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/apikey/service.go @@ -1,33 +1,32 @@ -package service +package apikey import ( "context" "errors" - "fmt" "time" - datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" - "github.com/pocket-id/pocket-id/backend/internal/utils/email" - - "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/pocket-id/pocket-id/backend/internal/dto" - "github.com/pocket-id/pocket-id/backend/internal/model" - "github.com/pocket-id/pocket-id/backend/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/utils" ) -const staticApiKeyUserID = "00000000-0000-0000-0000-000000000000" - -type ApiKeyService struct { +// Service holds the business logic for managing user API keys +type Service struct { db *gorm.DB - emailService *EmailService + staticApiKey string } -func NewApiKeyService(ctx context.Context, db *gorm.DB, emailService *EmailService) (*ApiKeyService, error) { - s := &ApiKeyService{db: db, emailService: emailService} +func newService(ctx context.Context, db *gorm.DB, staticApiKey string) (*Service, error) { + s := &Service{ + db: db, + staticApiKey: staticApiKey, + } - if common.EnvConfig.StaticApiKey == "" { + if staticApiKey == "" { err := s.deleteStaticApiKeyUser(ctx) if err != nil { return nil, err @@ -35,16 +34,15 @@ func NewApiKeyService(ctx context.Context, db *gorm.DB, emailService *EmailServi } return s, nil - } -func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.ApiKey, utils.PaginationResponse, error) { +func (s *Service) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]ApiKey, utils.PaginationResponse, error) { query := s.db. WithContext(ctx). Where("user_id = ?", userID). - Model(&model.ApiKey{}) + Model(&ApiKey{}) - var apiKeys []model.ApiKey + var apiKeys []ApiKey pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &apiKeys) if err != nil { return nil, utils.PaginationResponse{}, err @@ -53,19 +51,19 @@ func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequ return apiKeys, pagination, nil } -func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input dto.ApiKeyCreateDto) (model.ApiKey, string, error) { +func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyCreateDto) (ApiKey, string, error) { // Check if expiration is in the future if !input.ExpiresAt.ToTime().After(time.Now()) { - return model.ApiKey{}, "", &common.APIKeyExpirationDateError{} + return ApiKey{}, "", &common.APIKeyExpirationDateError{} } // Generate a secure random API key token, err := utils.GenerateRandomAlphanumericString(32) if err != nil { - return model.ApiKey{}, "", err + return ApiKey{}, "", err } - apiKey := model.ApiKey{ + apiKey := ApiKey{ Name: input.Name, Key: utils.CreateSha256Hash(token), // Hash the token for storage Description: input.Description, @@ -79,48 +77,48 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d Error if err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { - return model.ApiKey{}, "", &common.AlreadyInUseError{Property: "API key name"} + return ApiKey{}, "", &common.AlreadyInUseError{Property: "API key name"} } - return model.ApiKey{}, "", err + return ApiKey{}, "", err } // Return the raw token only once - it cannot be retrieved later return apiKey, token, nil } -func (s *ApiKeyService) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (model.ApiKey, string, error) { +func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (ApiKey, string, error) { // Check if expiration is in the future if !expiration.After(time.Now()) { - return model.ApiKey{}, "", &common.APIKeyExpirationDateError{} + return ApiKey{}, "", &common.APIKeyExpirationDateError{} } tx := s.db.Begin() defer tx.Rollback() - var apiKey model.ApiKey + var apiKey ApiKey err := tx. WithContext(ctx). - Model(&model.ApiKey{}). + Model(&ApiKey{}). Where("id = ? AND user_id = ?", apiKeyID, userID). First(&apiKey). Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return model.ApiKey{}, "", &common.APIKeyNotFoundError{} + return ApiKey{}, "", &common.APIKeyNotFoundError{} } - return model.ApiKey{}, "", err + return ApiKey{}, "", err } // Only allow renewal if the key has already expired if apiKey.ExpiresAt.ToTime().After(time.Now()) { - return model.ApiKey{}, "", &common.APIKeyNotExpiredError{} + return ApiKey{}, "", &common.APIKeyNotExpiredError{} } // Generate a secure random API key token, err := utils.GenerateRandomAlphanumericString(32) if err != nil { - return model.ApiKey{}, "", err + return ApiKey{}, "", err } apiKey.Key = utils.CreateSha256Hash(token) @@ -128,18 +126,18 @@ func (s *ApiKeyService) RenewApiKey(ctx context.Context, userID, apiKeyID string err = tx.WithContext(ctx).Save(&apiKey).Error if err != nil { - return model.ApiKey{}, "", err + return ApiKey{}, "", err } if err := tx.Commit().Error; err != nil { - return model.ApiKey{}, "", err + return ApiKey{}, "", err } return apiKey, token, nil } -func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error { - var apiKey model.ApiKey +func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error { + var apiKey ApiKey err := s.db. WithContext(ctx). Where("id = ? AND user_id = ?", apiKeyID, userID). @@ -155,25 +153,25 @@ func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID strin return nil } -func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) { +func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) { if apiKey == "" { return model.User{}, &common.NoAPIKeyProvidedError{} } - if common.EnvConfig.StaticApiKey != "" && apiKey == common.EnvConfig.StaticApiKey { + if s.staticApiKey != "" && apiKey == s.staticApiKey { return s.initStaticApiKeyUser(ctx) } now := time.Now() hashedKey := utils.CreateSha256Hash(apiKey) - var key model.ApiKey + var key ApiKey err := s.db. WithContext(ctx). - Model(&model.ApiKey{}). + Model(&ApiKey{}). Clauses(clause.Returning{}). Where("key = ? AND expires_at > ?", hashedKey, datatype.DateTime(now)). - Updates(&model.ApiKey{ + Updates(&ApiKey{ LastUsedAt: new(datatype.DateTime(now)), }). Preload("User"). @@ -190,8 +188,8 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode return key.User, nil } -func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]model.ApiKey, error) { - var keys []model.ApiKey +func (s *Service) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]ApiKey, error) { + var keys []ApiKey now := time.Now() cutoff := now.AddDate(0, 0, daysAhead) @@ -205,40 +203,19 @@ func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int) return keys, err } -func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error { - if apiKey.User.Email == nil { - return &common.UserEmailNotSetError{} - } - - err := SendEmail(ctx, s.emailService, email.Address{ - Name: apiKey.User.FullName(), - Email: *apiKey.User.Email, - }, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{ - ApiKeyName: apiKey.Name, - ExpiresAt: apiKey.ExpiresAt.ToTime(), - Name: apiKey.User.FirstName, - }) - if err != nil { - return fmt.Errorf("error sending notification email: %w", err) - } - - // Mark the API key as having had an expiration email sent - err = s.db.WithContext(ctx). - Model(&model.ApiKey{}). - Where("id = ?", apiKey.ID). +// MarkExpirationEmailSent records that the expiration notification email was sent for the given API key +func (s *Service) MarkExpirationEmailSent(ctx context.Context, apiKeyID string) error { + return s.db.WithContext(ctx). + Model(&ApiKey{}). + Where("id = ?", apiKeyID). Update("expiration_email_sent", true). Error - if err != nil { - return fmt.Errorf("error recording expiration sent email in database: %w", err) - } - - return nil } -func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) { +func (s *Service) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) { err = s.db. WithContext(ctx). - First(&user, "id = ?", staticApiKeyUserID). + First(&user, "id = ?", common.StaticApiKeyUserID). Error if err == nil { @@ -256,7 +233,7 @@ func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.Us user = model.User{ Base: model.Base{ - ID: staticApiKeyUserID, + ID: common.StaticApiKeyUserID, }, FirstName: "Static API User", Username: "static-api-user-" + usernameSuffix, @@ -272,9 +249,9 @@ func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.Us return user, err } -func (s *ApiKeyService) deleteStaticApiKeyUser(ctx context.Context) error { +func (s *Service) deleteStaticApiKeyUser(ctx context.Context) error { return s.db. WithContext(ctx). - Delete(&model.User{}, "id = ?", staticApiKeyUserID). + Delete(&model.User{}, "id = ?", common.StaticApiKeyUserID). Error } diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 4fb21e1e..fbb453a2 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -117,14 +117,17 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error { } // Initialize middleware for specific routes - authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService) + authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyModule, svc.userService, svc.jwtService) fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() apiRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 100) apiGroup := r.Group("/api", apiRateLimitMiddleware) baseGroup := r.Group("/", apiRateLimitMiddleware) - controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService) + svc.apiKeyModule.RegisterRoutes(apiGroup, + authMiddleware.WithAdminNotRequired().Add(), + authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), + ) controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService) controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService) controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.webauthnService, svc.appConfigService) diff --git a/backend/internal/bootstrap/scheduler_bootstrap.go b/backend/internal/bootstrap/scheduler_bootstrap.go index 0c87d0ee..a4d343fa 100644 --- a/backend/internal/bootstrap/scheduler_bootstrap.go +++ b/backend/internal/bootstrap/scheduler_bootstrap.go @@ -27,7 +27,7 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, http if err != nil { return fmt.Errorf("failed to register file cleanup jobs in scheduler: %w", err) } - err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyService, svc.appConfigService) + err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyModule, svc.appConfigService, svc.emailService) if err != nil { return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err) } diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index e20aed55..9afc3b9b 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "github.com/pocket-id/pocket-id/backend/internal/apikey" "github.com/pocket-id/pocket-id/backend/internal/job" "gorm.io/gorm" @@ -28,14 +29,14 @@ type services struct { oidcService *service.OidcService userGroupService *service.UserGroupService ldapService *service.LdapService - apiKeyService *service.ApiKeyService versionService *service.VersionService fileStorage storage.FileStorage appLockService *service.AppLockService userSignUpService *service.UserSignUpService oneTimeAccessService *service.OneTimeAccessService - oidcModule *oidc.Module + apiKeyModule *apikey.Module + oidcModule *oidc.Module } // Initializes all services @@ -97,9 +98,12 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage) svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage) - svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService) + svc.apiKeyModule, err = apikey.New(ctx, apikey.Dependencies{ + DB: db, + StaticApiKey: common.EnvConfig.StaticApiKey, + }) if err != nil { - return nil, fmt.Errorf("failed to create API key service: %w", err) + return nil, fmt.Errorf("failed to create API key module: %w", err) } svc.userSignUpService = service.NewUserSignupService(db, svc.jwtService, svc.auditLogService, svc.appConfigService, svc.userService) diff --git a/backend/internal/common/reserved.go b/backend/internal/common/reserved.go new file mode 100644 index 00000000..e7a01bac --- /dev/null +++ b/backend/internal/common/reserved.go @@ -0,0 +1,5 @@ +package common + +// StaticApiKeyUserID is the fixed ID of the synthetic admin user that the static API key authenticates as +// It is excluded from real-user counts such as the initial-admin-setup check +const StaticApiKeyUserID = "00000000-0000-0000-0000-000000000000" diff --git a/backend/internal/controller/api_key_controller.go b/backend/internal/controller/api_key_controller.go deleted file mode 100644 index 0162b96b..00000000 --- a/backend/internal/controller/api_key_controller.go +++ /dev/null @@ -1,156 +0,0 @@ -package controller - -import ( - "net/http" - - "github.com/pocket-id/pocket-id/backend/internal/utils" - - "github.com/gin-gonic/gin" - "github.com/pocket-id/pocket-id/backend/internal/dto" - "github.com/pocket-id/pocket-id/backend/internal/middleware" - "github.com/pocket-id/pocket-id/backend/internal/service" -) - -// swag init -g cmd/main.go -o ./docs/swagger --parseDependency - -// ApiKeyController manages API keys for authenticated users -type ApiKeyController struct { - apiKeyService *service.ApiKeyService -} - -// NewApiKeyController creates a new controller for API key management -// @Summary API key management controller -// @Description Initializes API endpoints for managing API keys -// @Tags API Keys -func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, apiKeyService *service.ApiKeyService) { - uc := &ApiKeyController{apiKeyService: apiKeyService} - - apiKeyGroup := group.Group("/api-keys") - { - apiKeyGroup.GET("", authMiddleware.WithAdminNotRequired().Add(), uc.listApiKeysHandler) - apiKeyGroup.POST("", authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), uc.createApiKeyHandler) - apiKeyGroup.POST("/:id/renew", authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), uc.renewApiKeyHandler) - apiKeyGroup.DELETE("/:id", authMiddleware.WithAdminNotRequired().Add(), uc.revokeApiKeyHandler) - } -} - -// listApiKeysHandler godoc -// @Summary List API keys -// @Description Get a paginated list of API keys belonging to the current user -// @Tags API Keys -// @Param pagination[page] query int false "Page number for pagination" default(1) -// @Param pagination[limit] query int false "Number of items per page" default(20) -// @Param sort[column] query string false "Column to sort by" -// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") -// @Success 200 {object} dto.Paginated[dto.ApiKeyDto] -// @Router /api/api-keys [get] -func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) { - listRequestOptions := utils.ParseListRequestOptions(ctx) - - userID := ctx.GetString("userID") - - apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, listRequestOptions) - if err != nil { - _ = ctx.Error(err) - return - } - - var apiKeysDto []dto.ApiKeyDto - if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil { - _ = ctx.Error(err) - return - } - - ctx.JSON(http.StatusOK, dto.Paginated[dto.ApiKeyDto]{ - Data: apiKeysDto, - Pagination: pagination, - }) -} - -// createApiKeyHandler godoc -// @Summary Create API key -// @Description Create a new API key for the current user -// @Tags API Keys -// @Param api_key body dto.ApiKeyCreateDto true "API key information" -// @Success 201 {object} dto.ApiKeyResponseDto "Created API key with token" -// @Router /api/api-keys [post] -func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) { - userID := ctx.GetString("userID") - - var input dto.ApiKeyCreateDto - if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil { - _ = ctx.Error(err) - return - } - - apiKey, token, err := c.apiKeyService.CreateApiKey(ctx.Request.Context(), userID, input) - if err != nil { - _ = ctx.Error(err) - return - } - - var apiKeyDto dto.ApiKeyDto - if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil { - _ = ctx.Error(err) - return - } - - ctx.JSON(http.StatusCreated, dto.ApiKeyResponseDto{ - ApiKey: apiKeyDto, - Token: token, - }) -} - -// renewApiKeyHandler godoc -// @Summary Renew API key -// @Description Renew an existing API key by ID -// @Tags API Keys -// @Param id path string true "API Key ID" -// @Success 200 {object} dto.ApiKeyResponseDto "Renewed API key with new token" -// @Router /api/api-keys/{id}/renew [post] -func (c *ApiKeyController) renewApiKeyHandler(ctx *gin.Context) { - userID := ctx.GetString("userID") - apiKeyID := ctx.Param("id") - - var input dto.ApiKeyRenewDto - if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil { - _ = ctx.Error(err) - return - } - - apiKey, token, err := c.apiKeyService.RenewApiKey(ctx.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime()) - if err != nil { - _ = ctx.Error(err) - return - } - - var apiKeyDto dto.ApiKeyDto - if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil { - _ = ctx.Error(err) - return - } - - ctx.JSON(http.StatusOK, dto.ApiKeyResponseDto{ - ApiKey: apiKeyDto, - Token: token, - }) -} - -// revokeApiKeyHandler godoc -// @Summary Revoke API key -// @Description Revoke (delete) an existing API key by ID -// @Tags API Keys -// @Param id path string true "API Key ID" -// @Success 204 "No Content" -// @Router /api/api-keys/{id} [delete] -func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) { - userID := ctx.GetString("userID") - apiKeyID := ctx.Param("id") - - if err := c.apiKeyService.RevokeApiKey(ctx.Request.Context(), userID, apiKeyID); err != nil { - _ = ctx.Error(err) - return - } - - ctx.Status(http.StatusNoContent) -} diff --git a/backend/internal/job/api_key_expiry_job.go b/backend/internal/job/api_key_expiry_job.go index 2481010e..eccdeb98 100644 --- a/backend/internal/job/api_key_expiry_job.go +++ b/backend/internal/job/api_key_expiry_job.go @@ -7,18 +7,22 @@ import ( "github.com/go-co-op/gocron/v2" + "github.com/pocket-id/pocket-id/backend/internal/apikey" "github.com/pocket-id/pocket-id/backend/internal/service" + "github.com/pocket-id/pocket-id/backend/internal/utils/email" ) type ApiKeyEmailJobs struct { - apiKeyService *service.ApiKeyService + apiKeyModule *apikey.Module appConfigService *service.AppConfigService + emailService *service.EmailService } -func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *service.ApiKeyService, appConfigService *service.AppConfigService) error { +func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *service.AppConfigService, emailService *service.EmailService) error { jobs := &ApiKeyEmailJobs{ - apiKeyService: apiKeyService, + apiKeyModule: apiKeyModule, appConfigService: appConfigService, + emailService: emailService, } // Send every day at midnight @@ -31,7 +35,7 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err return nil } - apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7) + apiKeys, err := j.apiKeyModule.ListExpiringApiKeys(ctx, 7) if err != nil { return fmt.Errorf("failed to list expiring API keys: %w", err) } @@ -40,13 +44,29 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err if key.User.Email == nil { continue } - err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key) + + err = service.SendEmail(ctx, j.emailService, email.Address{ + Name: key.User.FullName(), + Email: *key.User.Email, + }, service.ApiKeyExpiringSoonTemplate, &service.ApiKeyExpiringSoonTemplateData{ + Name: key.User.FirstName, + ApiKeyName: key.Name, + ExpiresAt: key.ExpiresAt.ToTime(), + }) if err != nil { slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.String("user", key.User.ID), slog.Any("error", err), ) + continue + } + + if err = j.apiKeyModule.MarkExpirationEmailSent(ctx, key.ID); err != nil { + slog.ErrorContext(ctx, "Failed to record that the expiration email was sent", + slog.String("key", key.ID), + slog.Any("error", err), + ) } } return nil diff --git a/backend/internal/middleware/api_key_auth.go b/backend/internal/middleware/api_key_auth.go index 34dbcbfd..da9a0cb3 100644 --- a/backend/internal/middleware/api_key_auth.go +++ b/backend/internal/middleware/api_key_auth.go @@ -2,19 +2,20 @@ package middleware import ( "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/apikey" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/service" ) type ApiKeyAuthMiddleware struct { - apiKeyService *service.ApiKeyService - jwtService *service.JwtService + apiKeyModule *apikey.Module + jwtService *service.JwtService } -func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, jwtService *service.JwtService) *ApiKeyAuthMiddleware { +func NewApiKeyAuthMiddleware(apiKeyModule *apikey.Module, jwtService *service.JwtService) *ApiKeyAuthMiddleware { return &ApiKeyAuthMiddleware{ - apiKeyService: apiKeyService, - jwtService: jwtService, + apiKeyModule: apiKeyModule, + jwtService: jwtService, } } @@ -36,7 +37,7 @@ func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc { func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) { apiKey := c.GetHeader("X-API-Key") - user, err := m.apiKeyService.ValidateApiKey(c.Request.Context(), apiKey) + user, err := m.apiKeyModule.ValidateApiKey(c.Request.Context(), apiKey) if err != nil { return "", false, &common.NotSignedInError{} } diff --git a/backend/internal/middleware/auth_middleware.go b/backend/internal/middleware/auth_middleware.go index 16a89fc7..62b89897 100644 --- a/backend/internal/middleware/auth_middleware.go +++ b/backend/internal/middleware/auth_middleware.go @@ -4,6 +4,7 @@ import ( "errors" "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/apikey" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/service" ) @@ -22,12 +23,12 @@ type AuthOptions struct { } func NewAuthMiddleware( - apiKeyService *service.ApiKeyService, + apiKeyModule *apikey.Module, userService *service.UserService, jwtService *service.JwtService, ) *AuthMiddleware { return &AuthMiddleware{ - apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyService, jwtService), + apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyModule, jwtService), jwtMiddleware: NewJwtAuthMiddleware(jwtService, userService), options: AuthOptions{ AdminRequired: true, diff --git a/backend/internal/middleware/auth_middleware_test.go b/backend/internal/middleware/auth_middleware_test.go index 60dae01e..acdb344b 100644 --- a/backend/internal/middleware/auth_middleware_test.go +++ b/backend/internal/middleware/auth_middleware_test.go @@ -11,11 +11,12 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/apikey" "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/service" + "github.com/pocket-id/pocket-id/backend/internal/utils" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) @@ -38,20 +39,23 @@ func TestWithApiKeyAuthDisabled(t *testing.T) { require.NoError(t, err) userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, nil, nil, nil, nil) - apiKeyService, err := service.NewApiKeyService(t.Context(), db, nil) + apiKeyModule, err := apikey.New(t.Context(), apikey.Dependencies{DB: db}) require.NoError(t, err) - authMiddleware := NewAuthMiddleware(apiKeyService, userService, jwtService) + authMiddleware := NewAuthMiddleware(apiKeyModule, userService, jwtService) user := createUserForAuthMiddlewareTest(t, db) jwtToken, err := jwtService.GenerateAccessToken(user, "") require.NoError(t, err) - _, apiKeyToken, err := apiKeyService.CreateApiKey(t.Context(), user.ID, dto.ApiKeyCreateDto{ + apiKeyToken := "middleware-test-api-key-raw-token" + apiKeyRecord := apikey.ApiKey{ Name: "Middleware API Key", + Key: utils.CreateSha256Hash(apiKeyToken), + UserID: user.ID, ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)), - }) - require.NoError(t, err) + } + require.NoError(t, db.Create(&apiKeyRecord).Error) router := gin.New() router.Use(NewErrorHandlerMiddleware().Add()) diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index ffcee99c..20eb55ef 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -21,6 +21,7 @@ import ( "github.com/ory/fosite" "github.com/ory/fosite/compose" fositejwt "github.com/ory/fosite/token/jwt" + "github.com/pocket-id/pocket-id/backend/internal/apikey" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/common" @@ -359,7 +360,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { return err } - apiKeys := []model.ApiKey{ + apiKeys := []apikey.ApiKey{ { Base: model.Base{ ID: "5f1fa856-c164-4295-961e-175a0d22d725", diff --git a/backend/internal/service/user_signup_service.go b/backend/internal/service/user_signup_service.go index b733762e..2790b305 100644 --- a/backend/internal/service/user_signup_service.go +++ b/backend/internal/service/user_signup_service.go @@ -166,7 +166,7 @@ func (s *UserSignUpService) IsInitialAdminSetupCompleted(ctx context.Context) (b func (s *UserSignUpService) isInitialAdminSetupCompleted(ctx context.Context, db *gorm.DB) (bool, error) { var userCount int64 if err := db.WithContext(ctx).Model(&model.User{}). - Where("id != ?", staticApiKeyUserID). + Where("id != ?", common.StaticApiKeyUserID). Count(&userCount).Error; err != nil { return false, err }