From 62915d863a4adc09cf467b75c414a045be43c2bb Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Tue, 11 Mar 2025 14:16:42 -0500 Subject: [PATCH] feat: api key authentication (#291) Co-authored-by: Elias Schneider --- .../internal/bootstrap/router_bootstrap.go | 19 +- backend/internal/common/errors.go | 24 ++ .../internal/controller/api_key_controller.go | 125 ++++++++ .../controller/app_config_controller.go | 120 +++++++- .../controller/audit_log_controller.go | 24 +- .../controller/custom_claim_controller.go | 53 +++- .../internal/controller/oidc_controller.go | 267 +++++++++++++++--- .../internal/controller/user_controller.go | 208 +++++++++++--- .../controller/user_group_controller.go | 93 +++++- .../controller/webauthn_controller.go | 14 +- .../controller/well_known_controller.go | 17 ++ backend/internal/dto/api_key_dto.go | 25 ++ backend/internal/dto/oidc_dto.go | 12 +- backend/internal/dto/pagination_dto.go | 10 + backend/internal/middleware/api_key_auth.go | 50 ++++ .../internal/middleware/auth_middleware.go | 89 ++++++ backend/internal/middleware/jwt_auth.go | 69 +++-- backend/internal/model/api_key.go | 18 ++ backend/internal/model/base.go | 8 +- backend/internal/service/api_key_service.go | 102 +++++++ backend/internal/service/test_service.go | 12 + backend/internal/utils/hash_util.go | 11 + .../20250302220732_api_key_auth.down.sql | 2 + .../20250302220732_api_key_auth.up.sql | 12 + .../20250302220732_api_key_auth.down.sql | 2 + .../sqlite/20250302220732_api_key_auth.up.sql | 12 + frontend/package-lock.json | 13 +- frontend/package.json | 3 +- .../lib/components/form/date-picker.svelte | 53 ++++ .../src/lib/components/form/form-input.svelte | 29 +- .../ui/calendar/calendar-cell.svelte | 21 ++ .../ui/calendar/calendar-day.svelte | 42 +++ .../ui/calendar/calendar-grid-body.svelte | 13 + .../ui/calendar/calendar-grid-head.svelte | 13 + .../ui/calendar/calendar-grid-row.svelte | 13 + .../ui/calendar/calendar-grid.svelte | 13 + .../ui/calendar/calendar-head-cell.svelte | 16 ++ .../ui/calendar/calendar-header.svelte | 16 ++ .../ui/calendar/calendar-heading.svelte | 19 ++ .../ui/calendar/calendar-months.svelte | 16 ++ .../ui/calendar/calendar-next-button.svelte | 27 ++ .../ui/calendar/calendar-prev-button.svelte | 27 ++ .../components/ui/calendar/calendar.svelte | 141 +++++++++ .../src/lib/components/ui/calendar/index.ts | 30 ++ .../ui/dialog/dialog-content.svelte | 7 +- frontend/src/lib/services/api-key-service.ts | 21 ++ frontend/src/lib/services/oidc-service.ts | 5 + frontend/src/lib/types/api-key.type.ts | 19 ++ frontend/src/lib/types/oidc.type.ts | 8 +- frontend/src/routes/authorize/+page.server.ts | 2 +- frontend/src/routes/settings/+layout.svelte | 1 + .../settings/admin/api-keys/+page.server.ts | 16 ++ .../settings/admin/api-keys/+page.svelte | 89 ++++++ .../admin/api-keys/api-key-dialog.svelte | 46 +++ .../admin/api-keys/api-key-form.svelte | 78 +++++ .../admin/api-keys/api-key-list.svelte | 89 ++++++ frontend/tests/api-key.spec.ts | 70 +++++ frontend/tests/data.ts | 8 + 58 files changed, 2172 insertions(+), 190 deletions(-) create mode 100644 backend/internal/controller/api_key_controller.go create mode 100644 backend/internal/dto/api_key_dto.go create mode 100644 backend/internal/dto/pagination_dto.go create mode 100644 backend/internal/middleware/api_key_auth.go create mode 100644 backend/internal/middleware/auth_middleware.go create mode 100644 backend/internal/model/api_key.go create mode 100644 backend/internal/service/api_key_service.go create mode 100644 backend/internal/utils/hash_util.go create mode 100644 backend/resources/migrations/postgres/20250302220732_api_key_auth.down.sql create mode 100644 backend/resources/migrations/postgres/20250302220732_api_key_auth.up.sql create mode 100644 backend/resources/migrations/sqlite/20250302220732_api_key_auth.down.sql create mode 100644 backend/resources/migrations/sqlite/20250302220732_api_key_auth.up.sql create mode 100644 frontend/src/lib/components/form/date-picker.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar-cell.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar-day.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar-grid-body.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar-grid-head.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar-grid-row.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar-grid.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar-head-cell.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar-header.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar-heading.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar-months.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar-next-button.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar-prev-button.svelte create mode 100644 frontend/src/lib/components/ui/calendar/calendar.svelte create mode 100644 frontend/src/lib/components/ui/calendar/index.ts create mode 100644 frontend/src/lib/services/api-key-service.ts create mode 100644 frontend/src/lib/types/api-key.type.ts create mode 100644 frontend/src/routes/settings/admin/api-keys/+page.server.ts create mode 100644 frontend/src/routes/settings/admin/api-keys/+page.svelte create mode 100644 frontend/src/routes/settings/admin/api-keys/api-key-dialog.svelte create mode 100644 frontend/src/routes/settings/admin/api-keys/api-key-form.svelte create mode 100644 frontend/src/routes/settings/admin/api-keys/api-key-list.svelte create mode 100644 frontend/tests/api-key.spec.ts diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 78397b70..6bb62023 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -46,6 +46,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) { testService := service.NewTestService(db, appConfigService, jwtService) userGroupService := service.NewUserGroupService(db, appConfigService) ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService) + apiKeyService := service.NewApiKeyService(db) rateLimitMiddleware := middleware.NewRateLimitMiddleware() @@ -53,24 +54,24 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) { r.Use(middleware.NewCorsMiddleware().Add()) r.Use(middleware.NewErrorHandlerMiddleware().Add()) r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60)) - r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false)) job.RegisterLdapJobs(ldapService, appConfigService) job.RegisterDbCleanupJobs(db) // Initialize middleware for specific routes - jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false) + authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService) fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() // Set up API routes apiGroup := r.Group("/api") - controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService) - controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService) - controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService) - controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService, ldapService) - controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware) - controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService) - controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService) + controller.NewApiKeyController(apiGroup, authMiddleware, apiKeyService) + controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService) + controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, oidcService, jwtService) + controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService) + controller.NewAppConfigController(apiGroup, authMiddleware, appConfigService, emailService, ldapService) + controller.NewAuditLogController(apiGroup, auditLogService, authMiddleware) + controller.NewUserGroupController(apiGroup, authMiddleware, userGroupService) + controller.NewCustomClaimController(apiGroup, authMiddleware, customClaimService) // Add test controller in non-production environments if common.EnvConfig.AppEnv != "production" { diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index ce186899..0f18d033 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -231,3 +231,27 @@ func (e *OneTimeAccessDisabledError) Error() string { return "One-time access is disabled" } func (e *OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest } + +type InvalidAPIKeyError struct{} + +func (e *InvalidAPIKeyError) Error() string { + return "Invalid Api Key" +} + +type NoAPIKeyProvidedError struct{} + +func (e *NoAPIKeyProvidedError) Error() string { + return "No API Key Provided" +} + +type APIKeyNotFoundError struct{} + +func (e *APIKeyNotFoundError) Error() string { + return "API Key Not Found" +} + +type APIKeyExpirationDateError struct{} + +func (e *APIKeyExpirationDateError) Error() string { + return "API Key expiration time must be in the future" +} diff --git a/backend/internal/controller/api_key_controller.go b/backend/internal/controller/api_key_controller.go new file mode 100644 index 00000000..e12f74ab --- /dev/null +++ b/backend/internal/controller/api_key_controller.go @@ -0,0 +1,125 @@ +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.Use(authMiddleware.WithAdminNotRequired().Add()) + { + apiKeyGroup.GET("", uc.listApiKeysHandler) + apiKeyGroup.POST("", uc.createApiKeyHandler) + apiKeyGroup.DELETE("/:id", 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 page query int false "Page number, starting from 1" default(1) +// @Param limit query int false "Number of items per page" default(10) +// @Param sort_column query string false "Column to sort by" default("created_at") +// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc") +// @Success 200 {object} dto.Paginated[dto.ApiKeyDto] +// @Router /api-keys [get] +func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) { + userID := ctx.GetString("userID") + + var sortedPaginationRequest utils.SortedPaginationRequest + if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil { + ctx.Error(err) + return + } + + apiKeys, pagination, err := c.apiKeyService.ListApiKeys(userID, sortedPaginationRequest) + 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-keys [post] +func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) { + userID := ctx.GetString("userID") + + var input dto.ApiKeyCreateDto + if err := ctx.ShouldBindJSON(&input); err != nil { + ctx.Error(err) + return + } + + apiKey, token, err := c.apiKeyService.CreateApiKey(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, + }) +} + +// 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-keys/{id} [delete] +func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) { + userID := ctx.GetString("userID") + apiKeyID := ctx.Param("id") + + if err := c.apiKeyService.RevokeApiKey(userID, apiKeyID); err != nil { + ctx.Error(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index a9580f04..40c84d85 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -12,9 +12,13 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/utils" ) +// NewAppConfigController creates a new controller for application configuration endpoints +// @Summary Create a new application configuration controller +// @Description Initialize routes for application configuration +// @Tags Application Configuration func NewAppConfigController( group *gin.RouterGroup, - jwtAuthMiddleware *middleware.JwtAuthMiddleware, + authMiddleware *middleware.AuthMiddleware, appConfigService *service.AppConfigService, emailService *service.EmailService, ldapService *service.LdapService, @@ -26,18 +30,18 @@ func NewAppConfigController( ldapService: ldapService, } group.GET("/application-configuration", acc.listAppConfigHandler) - group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler) - group.PUT("/application-configuration", jwtAuthMiddleware.Add(true), acc.updateAppConfigHandler) + group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler) + group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler) group.GET("/application-configuration/logo", acc.getLogoHandler) group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler) group.GET("/application-configuration/favicon", acc.getFaviconHandler) - group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler) - group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler) - group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler) + group.PUT("/application-configuration/logo", authMiddleware.Add(), acc.updateLogoHandler) + group.PUT("/application-configuration/favicon", authMiddleware.Add(), acc.updateFaviconHandler) + group.PUT("/application-configuration/background-image", authMiddleware.Add(), acc.updateBackgroundImageHandler) - group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler) - group.POST("/application-configuration/sync-ldap", jwtAuthMiddleware.Add(true), acc.syncLdapHandler) + group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler) + group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler) } type AppConfigController struct { @@ -46,6 +50,15 @@ type AppConfigController struct { ldapService *service.LdapService } +// listAppConfigHandler godoc +// @Summary List public application configurations +// @Description Get all public application configurations +// @Tags Application Configuration +// @Accept json +// @Produce json +// @Success 200 {array} dto.PublicAppConfigVariableDto +// @Failure 500 {object} object "{"error": "error message"}" +// @Router /application-configuration [get] func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { configuration, err := acc.appConfigService.ListAppConfig(false) if err != nil { @@ -62,6 +75,15 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { c.JSON(200, configVariablesDto) } +// listAllAppConfigHandler godoc +// @Summary List all application configurations +// @Description Get all application configurations including private ones +// @Tags Application Configuration +// @Accept json +// @Produce json +// @Success 200 {array} dto.AppConfigVariableDto +// @Security BearerAuth +// @Router /application-configuration/all [get] func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) { configuration, err := acc.appConfigService.ListAppConfig(true) if err != nil { @@ -78,6 +100,16 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) { c.JSON(200, configVariablesDto) } +// updateAppConfigHandler godoc +// @Summary Update application configurations +// @Description Update application configuration settings +// @Tags Application Configuration +// @Accept json +// @Produce json +// @Param body body dto.AppConfigUpdateDto true "Application Configuration" +// @Success 200 {array} dto.AppConfigVariableDto +// @Security BearerAuth +// @Router /application-configuration [put] func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) { var input dto.AppConfigUpdateDto if err := c.ShouldBindJSON(&input); err != nil { @@ -100,6 +132,16 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) { c.JSON(http.StatusOK, configVariablesDto) } +// getLogoHandler godoc +// @Summary Get logo image +// @Description Get the logo image for the application +// @Tags Application Configuration +// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" +// @Produce image/png +// @Produce image/jpeg +// @Produce image/svg+xml +// @Success 200 {file} binary "Logo image" +// @Router /application-configuration/logo [get] func (acc *AppConfigController) getLogoHandler(c *gin.Context) { lightLogo := c.DefaultQuery("light", "true") == "true" @@ -117,15 +159,42 @@ func (acc *AppConfigController) getLogoHandler(c *gin.Context) { acc.getImage(c, imageName, imageType) } +// getFaviconHandler godoc +// @Summary Get favicon +// @Description Get the favicon for the application +// @Tags Application Configuration +// @Produce image/x-icon +// @Success 200 {file} binary "Favicon image" +// @Failure 404 {object} object "{"error": "File not found"}" +// @Router /application-configuration/favicon [get] func (acc *AppConfigController) getFaviconHandler(c *gin.Context) { acc.getImage(c, "favicon", "ico") } +// getBackgroundImageHandler godoc +// @Summary Get background image +// @Description Get the background image for the application +// @Tags Application Configuration +// @Produce image/png +// @Produce image/jpeg +// @Success 200 {file} binary "Background image" +// @Failure 404 {object} object "{"error": "File not found"}" +// @Router /application-configuration/background-image [get] func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) { imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value acc.getImage(c, "background", imageType) } +// updateLogoHandler godoc +// @Summary Update logo +// @Description Update the application logo +// @Tags Application Configuration +// @Accept multipart/form-data +// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" +// @Param file formData file true "Logo image file" +// @Success 204 "No Content" +// @Security BearerAuth +// @Router /application-configuration/logo [put] func (acc *AppConfigController) updateLogoHandler(c *gin.Context) { lightLogo := c.DefaultQuery("light", "true") == "true" @@ -143,6 +212,15 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) { acc.updateImage(c, imageName, imageType) } +// updateFaviconHandler godoc +// @Summary Update favicon +// @Description Update the application favicon +// @Tags Application Configuration +// @Accept multipart/form-data +// @Param file formData file true "Favicon file (.ico)" +// @Success 204 "No Content" +// @Security BearerAuth +// @Router /application-configuration/favicon [put] func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) { file, err := c.FormFile("file") if err != nil { @@ -158,11 +236,21 @@ func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) { acc.updateImage(c, "favicon", "ico") } +// updateBackgroundImageHandler godoc +// @Summary Update background image +// @Description Update the application background image +// @Tags Application Configuration +// @Accept multipart/form-data +// @Param file formData file true "Background image file" +// @Success 204 "No Content" +// @Security BearerAuth +// @Router /application-configuration/background-image [put] func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) { imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value acc.updateImage(c, "background", imageType) } +// getImage is a helper function to serve image files func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) { imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType) mimeType := utils.GetImageMimeType(imageType) @@ -171,6 +259,7 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType c.File(imagePath) } +// updateImage is a helper function to update image files func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) { file, err := c.FormFile("file") if err != nil { @@ -187,6 +276,13 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol c.Status(http.StatusNoContent) } +// syncLdapHandler godoc +// @Summary Synchronize LDAP +// @Description Manually trigger LDAP synchronization +// @Tags Application Configuration +// @Success 204 "No Content" +// @Security BearerAuth +// @Router /application-configuration/sync-ldap [post] func (acc *AppConfigController) syncLdapHandler(c *gin.Context) { err := acc.ldapService.SyncAll() if err != nil { @@ -196,6 +292,14 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) { c.Status(http.StatusNoContent) } + +// testEmailHandler godoc +// @Summary Send test email +// @Description Send a test email to verify email configuration +// @Tags Application Configuration +// @Success 204 "No Content" +// @Security BearerAuth +// @Router /application-configuration/test-email [post] func (acc *AppConfigController) testEmailHandler(c *gin.Context) { userID := c.GetString("userID") diff --git a/backend/internal/controller/audit_log_controller.go b/backend/internal/controller/audit_log_controller.go index 744a0602..2d64f928 100644 --- a/backend/internal/controller/audit_log_controller.go +++ b/backend/internal/controller/audit_log_controller.go @@ -11,18 +11,32 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/service" ) -func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) { +// NewAuditLogController creates a new controller for audit log management +// @Summary Audit log controller +// @Description Initializes API endpoints for accessing audit logs +// @Tags Audit Logs +func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, authMiddleware *middleware.AuthMiddleware) { alc := AuditLogController{ auditLogService: auditLogService, } - group.GET("/audit-logs", jwtAuthMiddleware.Add(false), alc.listAuditLogsForUserHandler) + group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler) } type AuditLogController struct { auditLogService *service.AuditLogService } +// listAuditLogsForUserHandler godoc +// @Summary List audit logs +// @Description Get a paginated list of audit logs for the current user +// @Tags Audit Logs +// @Param page query int false "Page number, starting from 1" default(1) +// @Param limit query int false "Number of items per page" default(10) +// @Param sort_column query string false "Column to sort by" default("created_at") +// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc") +// @Success 200 {object} dto.Paginated[dto.AuditLogDto] +// @Router /audit-logs [get] func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) { var sortedPaginationRequest utils.SortedPaginationRequest if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil { @@ -53,8 +67,8 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) { logsDtos[i] = logsDto } - c.JSON(http.StatusOK, gin.H{ - "data": logsDtos, - "pagination": pagination, + c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{ + Data: logsDtos, + Pagination: pagination, }) } diff --git a/backend/internal/controller/custom_claim_controller.go b/backend/internal/controller/custom_claim_controller.go index 5a875521..bf0d9cba 100644 --- a/backend/internal/controller/custom_claim_controller.go +++ b/backend/internal/controller/custom_claim_controller.go @@ -9,17 +9,37 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/service" ) -func NewCustomClaimController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, customClaimService *service.CustomClaimService) { +// NewCustomClaimController creates a new controller for custom claim management +// @Summary Custom claim management controller +// @Description Initializes all custom claim-related API endpoints +// @Tags Custom Claims +func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) { wkc := &CustomClaimController{customClaimService: customClaimService} - group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler) - group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler) - group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler) + + customClaimsGroup := group.Group("/custom-claims") + customClaimsGroup.Use(authMiddleware.Add()) + { + customClaimsGroup.GET("/suggestions", wkc.getSuggestionsHandler) + customClaimsGroup.PUT("/user/:userId", wkc.UpdateCustomClaimsForUserHandler) + customClaimsGroup.PUT("/user-group/:userGroupId", wkc.UpdateCustomClaimsForUserGroupHandler) + } } type CustomClaimController struct { customClaimService *service.CustomClaimService } +// getSuggestionsHandler godoc +// @Summary Get custom claim suggestions +// @Description Get a list of suggested custom claim names +// @Tags Custom Claims +// @Produce json +// @Success 200 {array} string "List of suggested custom claim names" +// @Failure 401 {object} object "Unauthorized" +// @Failure 403 {object} object "Forbidden" +// @Failure 500 {object} object "Internal server error" +// @Security BearerAuth +// @Router /custom-claims/suggestions [get] func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) { claims, err := ccc.customClaimService.GetSuggestions() if err != nil { @@ -30,6 +50,16 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) { c.JSON(http.StatusOK, claims) } +// UpdateCustomClaimsForUserHandler godoc +// @Summary Update custom claims for a user +// @Description Update or create custom claims for a specific user +// @Tags Custom Claims +// @Accept json +// @Produce json +// @Param userId path string true "User ID" +// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user" +// @Success 200 {array} dto.CustomClaimDto "Updated custom claims" +// @Router /custom-claims/user/{userId} [put] func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) { var input []dto.CustomClaimCreateDto @@ -54,6 +84,17 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex c.JSON(http.StatusOK, customClaimsDto) } +// UpdateCustomClaimsForUserGroupHandler godoc +// @Summary Update custom claims for a user group +// @Description Update or create custom claims for a specific user group +// @Tags Custom Claims +// @Accept json +// @Produce json +// @Param userGroupId path string true "User Group ID" +// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group" +// @Success 200 {array} dto.CustomClaimDto "Updated custom claims" +// @Security BearerAuth +// @Router /custom-claims/user-group/{userGroupId} [put] func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) { var input []dto.CustomClaimCreateDto @@ -62,8 +103,8 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.C return } - userId := c.Param("userGroupId") - claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input) + userGroupId := c.Param("userGroupId") + claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userGroupId, input) if err != nil { c.Error(err) return diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index e6765d72..055cca15 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -1,13 +1,14 @@ package controller import ( - "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" "log" "net/http" "net/url" "strings" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" + "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/middleware" @@ -15,30 +16,35 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/utils" ) -func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) { +// NewOidcController creates a new controller for OIDC related endpoints +// @Summary OIDC controller +// @Description Initializes all OIDC-related API endpoints for authentication and client management +// @Tags OIDC +func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) { oc := &OidcController{oidcService: oidcService, jwtService: jwtService} - group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler) - group.POST("/oidc/authorization-required", jwtAuthMiddleware.Add(false), oc.authorizationConfirmationRequiredHandler) + group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler) + group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler) group.POST("/oidc/token", oc.createTokensHandler) group.GET("/oidc/userinfo", oc.userInfoHandler) group.POST("/oidc/userinfo", oc.userInfoHandler) - group.POST("/oidc/end-session", oc.EndSessionHandler) - group.GET("/oidc/end-session", oc.EndSessionHandler) + group.POST("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler) + group.GET("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler) - group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler) - group.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler) - group.GET("/oidc/clients/:id", oc.getClientHandler) - group.PUT("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.updateClientHandler) - group.DELETE("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.deleteClientHandler) + group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler) + group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler) + group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler) + group.GET("/oidc/clients/:id/meta", oc.getClientMetaDataHandler) + group.PUT("/oidc/clients/:id", authMiddleware.Add(), oc.updateClientHandler) + group.DELETE("/oidc/clients/:id", authMiddleware.Add(), oc.deleteClientHandler) - group.PUT("/oidc/clients/:id/allowed-user-groups", jwtAuthMiddleware.Add(true), oc.updateAllowedUserGroupsHandler) - group.POST("/oidc/clients/:id/secret", jwtAuthMiddleware.Add(true), oc.createClientSecretHandler) + group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler) + group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler) group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler) group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler) - group.POST("/oidc/clients/:id/logo", jwtAuthMiddleware.Add(true), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler) + group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler) } type OidcController struct { @@ -46,6 +52,16 @@ type OidcController struct { jwtService *service.JwtService } +// authorizeHandler godoc +// @Summary Authorize OIDC client +// @Description Start the OIDC authorization process for a client +// @Tags OIDC +// @Accept json +// @Produce json +// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters" +// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL" +// @Security BearerAuth +// @Router /oidc/authorize [post] func (oc *OidcController) authorizeHandler(c *gin.Context) { var input dto.AuthorizeOidcClientRequestDto if err := c.ShouldBindJSON(&input); err != nil { @@ -67,6 +83,16 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) { c.JSON(http.StatusOK, response) } +// authorizationConfirmationRequiredHandler godoc +// @Summary Check if authorization confirmation is required +// @Description Check if the user needs to confirm authorization for the client +// @Tags OIDC +// @Accept json +// @Produce json +// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters" +// @Success 200 {object} object "{ \"authorizationRequired\": true/false }" +// @Security BearerAuth +// @Router /oidc/authorization-required [post] func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) { var input dto.AuthorizationRequiredDto if err := c.ShouldBindJSON(&input); err != nil { @@ -83,6 +109,19 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient}) } +// createTokensHandler godoc +// @Summary Create OIDC tokens +// @Description Exchange authorization code for ID and access tokens +// @Tags OIDC +// @Accept application/x-www-form-urlencoded +// @Produce json +// @Param client_id formData string false "Client ID (if not using Basic Auth)" +// @Param client_secret formData string false "Client secret (if not using Basic Auth)" +// @Param code formData string true "Authorization code" +// @Param grant_type formData string true "Grant type (must be 'authorization_code')" +// @Param code_verifier formData string false "PKCE code verifier" +// @Success 200 {object} object "{ \"id_token\": \"string\", \"access_token\": \"string\", \"token_type\": \"Bearer\" }" +// @Router /oidc/token [post] func (oc *OidcController) createTokensHandler(c *gin.Context) { // Disable cors for this endpoint c.Writer.Header().Set("Access-Control-Allow-Origin", "*") @@ -111,6 +150,15 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"id_token": idToken, "access_token": accessToken, "token_type": "Bearer"}) } +// userInfoHandler godoc +// @Summary Get user information +// @Description Get user information based on the access token +// @Tags OIDC +// @Accept json +// @Produce json +// @Success 200 {object} object "User claims based on requested scopes" +// @Security OAuth2AccessToken +// @Router /oidc/userinfo [get] func (oc *OidcController) userInfoHandler(c *gin.Context) { authHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ") if len(authHeaderSplit) != 2 { @@ -136,6 +184,30 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) { c.JSON(http.StatusOK, claims) } +// userInfoHandler godoc (POST method) +// @Summary Get user information (POST method) +// @Description Get user information based on the access token using POST +// @Tags OIDC +// @Accept json +// @Produce json +// @Success 200 {object} object "User claims based on requested scopes" +// @Security OAuth2AccessToken +// @Router /oidc/userinfo [post] +func (oc *OidcController) userInfoHandlerPost(c *gin.Context) { + // Implementation is the same as GET +} + +// EndSessionHandler godoc +// @Summary End OIDC session +// @Description End user session and handle OIDC logout +// @Tags OIDC +// @Accept application/x-www-form-urlencoded +// @Produce html +// @Param id_token_hint query string false "ID token" +// @Param post_logout_redirect_uri query string false "URL to redirect to after logout" +// @Param state query string false "State parameter to include in the redirect" +// @Success 302 "Redirect to post-logout URL or application logout page" +// @Router /oidc/end-session [get] func (oc *OidcController) EndSessionHandler(c *gin.Context) { var input dto.OidcLogoutDto @@ -174,6 +246,56 @@ func (oc *OidcController) EndSessionHandler(c *gin.Context) { c.Redirect(http.StatusFound, logoutCallbackURL.String()) } +// EndSessionHandler godoc (POST method) +// @Summary End OIDC session (POST method) +// @Description End user session and handle OIDC logout using POST +// @Tags OIDC +// @Accept application/x-www-form-urlencoded +// @Produce html +// @Param id_token_hint formData string false "ID token" +// @Param post_logout_redirect_uri formData string false "URL to redirect to after logout" +// @Param state formData string false "State parameter to include in the redirect" +// @Success 302 "Redirect to post-logout URL or application logout page" +// @Router /oidc/end-session [post] +func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) { + // Implementation is the same as GET +} + +// getClientMetaDataHandler godoc +// @Summary Get client metadata +// @Description Get OIDC client metadata for discovery and configuration +// @Tags OIDC +// @Produce json +// @Param id path string true "Client ID" +// @Success 200 {object} dto.OidcClientMetaDataDto "Client metadata" +// @Router /oidc/clients/{id}/meta [get] +func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) { + clientId := c.Param("id") + client, err := oc.oidcService.GetClient(clientId) + if err != nil { + c.Error(err) + return + } + + clientDto := dto.OidcClientMetaDataDto{} + err = dto.MapStruct(client, &clientDto) + if err == nil { + c.JSON(http.StatusOK, clientDto) + return + } + + c.Error(err) +} + +// getClientHandler godoc +// @Summary Get OIDC client +// @Description Get detailed information about an OIDC client +// @Tags OIDC +// @Produce json +// @Param id path string true "Client ID" +// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information" +// @Security BearerAuth +// @Router /oidc/clients/{id} [get] func (oc *OidcController) getClientHandler(c *gin.Context) { clientId := c.Param("id") client, err := oc.oidcService.GetClient(clientId) @@ -182,26 +304,28 @@ func (oc *OidcController) getClientHandler(c *gin.Context) { return } - // Return a different DTO based on the user's role - if c.GetBool("userIsAdmin") { - clientDto := dto.OidcClientWithAllowedUserGroupsDto{} - err = dto.MapStruct(client, &clientDto) - if err == nil { - c.JSON(http.StatusOK, clientDto) - return - } - } else { - clientDto := dto.PublicOidcClientDto{} - err = dto.MapStruct(client, &clientDto) - if err == nil { - c.JSON(http.StatusOK, clientDto) - return - } + clientDto := dto.OidcClientWithAllowedUserGroupsDto{} + err = dto.MapStruct(client, &clientDto) + if err == nil { + c.JSON(http.StatusOK, clientDto) + return } c.Error(err) } +// listClientsHandler godoc +// @Summary List OIDC clients +// @Description Get a paginated list of OIDC clients with optional search and sorting +// @Tags OIDC +// @Param search query string false "Search term to filter clients by name" +// @Param page query int false "Page number, starting from 1" default(1) +// @Param limit query int false "Number of items per page" default(10) +// @Param sort_column query string false "Column to sort by" default("name") +// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc") +// @Success 200 {object} dto.Paginated[dto.OidcClientDto] +// @Security BearerAuth +// @Router /oidc/clients [get] func (oc *OidcController) listClientsHandler(c *gin.Context) { searchTerm := c.Query("search") var sortedPaginationRequest utils.SortedPaginationRequest @@ -222,12 +346,22 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{ - "data": clientsDto, - "pagination": pagination, + c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientDto]{ + Data: clientsDto, + Pagination: pagination, }) } +// createClientHandler godoc +// @Summary Create OIDC client +// @Description Create a new OIDC client +// @Tags OIDC +// @Accept json +// @Produce json +// @Param client body dto.OidcClientCreateDto true "Client information" +// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client" +// @Security BearerAuth +// @Router /oidc/clients [post] func (oc *OidcController) createClientHandler(c *gin.Context) { var input dto.OidcClientCreateDto if err := c.ShouldBindJSON(&input); err != nil { @@ -250,6 +384,14 @@ func (oc *OidcController) createClientHandler(c *gin.Context) { c.JSON(http.StatusCreated, clientDto) } +// deleteClientHandler godoc +// @Summary Delete OIDC client +// @Description Delete an OIDC client by ID +// @Tags OIDC +// @Param id path string true "Client ID" +// @Success 204 "No Content" +// @Security BearerAuth +// @Router /oidc/clients/{id} [delete] func (oc *OidcController) deleteClientHandler(c *gin.Context) { err := oc.oidcService.DeleteClient(c.Param("id")) if err != nil { @@ -260,6 +402,17 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) { c.Status(http.StatusNoContent) } +// updateClientHandler godoc +// @Summary Update OIDC client +// @Description Update an existing OIDC client +// @Tags OIDC +// @Accept json +// @Produce json +// @Param id path string true "Client ID" +// @Param client body dto.OidcClientCreateDto true "Client information" +// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client" +// @Security BearerAuth +// @Router /oidc/clients/{id} [put] func (oc *OidcController) updateClientHandler(c *gin.Context) { var input dto.OidcClientCreateDto if err := c.ShouldBindJSON(&input); err != nil { @@ -282,6 +435,15 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) { c.JSON(http.StatusOK, clientDto) } +// createClientSecretHandler godoc +// @Summary Create client secret +// @Description Generate a new secret for an OIDC client +// @Tags OIDC +// @Produce json +// @Param id path string true "Client ID" +// @Success 200 {object} object "{ \"secret\": \"string\" }" +// @Security BearerAuth +// @Router /oidc/clients/{id}/secret [post] func (oc *OidcController) createClientSecretHandler(c *gin.Context) { secret, err := oc.oidcService.CreateClientSecret(c.Param("id")) if err != nil { @@ -292,6 +454,16 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"secret": secret}) } +// getClientLogoHandler godoc +// @Summary Get client logo +// @Description Get the logo image for an OIDC client +// @Tags OIDC +// @Produce image/png +// @Produce image/jpeg +// @Produce image/svg+xml +// @Param id path string true "Client ID" +// @Success 200 {file} binary "Logo image" +// @Router /oidc/clients/{id}/logo [get] func (oc *OidcController) getClientLogoHandler(c *gin.Context) { imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id")) if err != nil { @@ -303,6 +475,16 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) { c.File(imagePath) } +// updateClientLogoHandler godoc +// @Summary Update client logo +// @Description Upload or update the logo for an OIDC client +// @Tags OIDC +// @Accept multipart/form-data +// @Param id path string true "Client ID" +// @Param file formData file true "Logo image file (PNG, JPG, or SVG, max 2MB)" +// @Success 204 "No Content" +// @Security BearerAuth +// @Router /oidc/clients/{id}/logo [post] func (oc *OidcController) updateClientLogoHandler(c *gin.Context) { file, err := c.FormFile("file") if err != nil { @@ -319,6 +501,14 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) { c.Status(http.StatusNoContent) } +// deleteClientLogoHandler godoc +// @Summary Delete client logo +// @Description Delete the logo for an OIDC client +// @Tags OIDC +// @Param id path string true "Client ID" +// @Success 204 "No Content" +// @Security BearerAuth +// @Router /oidc/clients/{id}/logo [delete] func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) { err := oc.oidcService.DeleteClientLogo(c.Param("id")) if err != nil { @@ -329,6 +519,17 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) { c.Status(http.StatusNoContent) } +// updateAllowedUserGroupsHandler godoc +// @Summary Update allowed user groups +// @Description Update the user groups allowed to access an OIDC client +// @Tags OIDC +// @Accept json +// @Produce json +// @Param id path string true "Client ID" +// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs" +// @Success 200 {object} dto.OidcClientDto "Updated client" +// @Security BearerAuth +// @Router /oidc/clients/{id}/allowed-user-groups [put] func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) { var input dto.OidcUpdateAllowedUserGroupsDto if err := c.ShouldBindJSON(&input); err != nil { diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index e892a3cc..21dd957f 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -16,30 +16,34 @@ import ( "golang.org/x/time/rate" ) -func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) { +// NewUserController creates a new controller for user management endpoints +// @Summary User management controller +// @Description Initializes all user-related API endpoints +// @Tags Users +func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) { uc := UserController{ userService: userService, appConfigService: appConfigService, } - group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler) - group.GET("/users/me", jwtAuthMiddleware.Add(false), uc.getCurrentUserHandler) - group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler) - group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler) - group.PUT("/users/:id", jwtAuthMiddleware.Add(true), uc.updateUserHandler) - group.GET("/users/:id/groups", jwtAuthMiddleware.Add(true), uc.getUserGroupsHandler) - group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler) - group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler) + group.GET("/users", authMiddleware.Add(), uc.listUsersHandler) + group.GET("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserHandler) + group.GET("/users/:id", authMiddleware.Add(), uc.getUserHandler) + group.POST("/users", authMiddleware.Add(), uc.createUserHandler) + group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler) + group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler) + group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler) + group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler) - group.PUT("/users/:id/user-groups", jwtAuthMiddleware.Add(true), uc.updateUserGroups) + group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups) group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler) - group.GET("/users/me/profile-picture.png", jwtAuthMiddleware.Add(false), uc.getCurrentUserProfilePictureHandler) - group.PUT("/users/:id/profile-picture", jwtAuthMiddleware.Add(true), uc.updateUserProfilePictureHandler) - group.PUT("/users/me/profile-picture", jwtAuthMiddleware.Add(false), uc.updateCurrentUserProfilePictureHandler) + group.GET("/users/me/profile-picture.png", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserProfilePictureHandler) + group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler) + group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler) - group.POST("/users/me/one-time-access-token", jwtAuthMiddleware.Add(false), uc.createOwnOneTimeAccessTokenHandler) - group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createAdminOneTimeAccessTokenHandler) + group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler) + group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler) group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler) group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler) group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler) @@ -50,6 +54,13 @@ type UserController struct { appConfigService *service.AppConfigService } +// getUserGroupsHandler godoc +// @Summary Get user groups +// @Description Retrieve all groups a specific user belongs to +// @Tags Users,User Groups +// @Param id path string true "User ID" +// @Success 200 {array} dto.UserGroupDtoWithUsers +// @Router /users/{id}/groups [get] func (uc *UserController) getUserGroupsHandler(c *gin.Context) { userID := c.Param("id") groups, err := uc.userService.GetUserGroups(userID) @@ -67,6 +78,17 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) { c.JSON(http.StatusOK, groupsDto) } +// listUsersHandler godoc +// @Summary List users +// @Description Get a paginated list of users with optional search and sorting +// @Tags Users +// @Param search query string false "Search term to filter users" +// @Param page query int false "Page number, starting from 1" default(1) +// @Param limit query int false "Number of items per page" default(10) +// @Param sort_column query string false "Column to sort by" default("created_at") +// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc") +// @Success 200 {object} dto.Paginated[dto.UserDto] +// @Router /users [get] func (uc *UserController) listUsersHandler(c *gin.Context) { searchTerm := c.Query("search") var sortedPaginationRequest utils.SortedPaginationRequest @@ -87,12 +109,19 @@ func (uc *UserController) listUsersHandler(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{ - "data": usersDto, - "pagination": pagination, + c.JSON(http.StatusOK, dto.Paginated[dto.UserDto]{ + Data: usersDto, + Pagination: pagination, }) } +// getUserHandler godoc +// @Summary Get user by ID +// @Description Retrieve detailed information about a specific user +// @Tags Users +// @Param id path string true "User ID" +// @Success 200 {object} dto.UserDto +// @Router /users/{id} [get] func (uc *UserController) getUserHandler(c *gin.Context) { user, err := uc.userService.GetUser(c.Param("id")) if err != nil { @@ -109,6 +138,12 @@ func (uc *UserController) getUserHandler(c *gin.Context) { c.JSON(http.StatusOK, userDto) } +// getCurrentUserHandler godoc +// @Summary Get current user +// @Description Retrieve information about the currently authenticated user +// @Tags Users +// @Success 200 {object} dto.UserDto +// @Router /users/me [get] func (uc *UserController) getCurrentUserHandler(c *gin.Context) { user, err := uc.userService.GetUser(c.GetString("userID")) if err != nil { @@ -125,6 +160,13 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) { c.JSON(http.StatusOK, userDto) } +// deleteUserHandler godoc +// @Summary Delete user +// @Description Delete a specific user by ID +// @Tags Users +// @Param id path string true "User ID" +// @Success 204 "No Content" +// @Router /users/{id} [delete] func (uc *UserController) deleteUserHandler(c *gin.Context) { if err := uc.userService.DeleteUser(c.Param("id")); err != nil { c.Error(err) @@ -134,6 +176,13 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) { c.Status(http.StatusNoContent) } +// createUserHandler godoc +// @Summary Create user +// @Description Create a new user +// @Tags Users +// @Param user body dto.UserCreateDto true "User information" +// @Success 201 {object} dto.UserDto +// @Router /users [post] func (uc *UserController) createUserHandler(c *gin.Context) { var input dto.UserCreateDto if err := c.ShouldBindJSON(&input); err != nil { @@ -156,10 +205,25 @@ func (uc *UserController) createUserHandler(c *gin.Context) { c.JSON(http.StatusCreated, userDto) } +// updateUserHandler godoc +// @Summary Update user +// @Description Update an existing user by ID +// @Tags Users +// @Param id path string true "User ID" +// @Param user body dto.UserCreateDto true "User information" +// @Success 200 {object} dto.UserDto +// @Router /users/{id} [put] func (uc *UserController) updateUserHandler(c *gin.Context) { uc.updateUser(c, false) } +// updateCurrentUserHandler godoc +// @Summary Update current user +// @Description Update the currently authenticated user's information +// @Tags Users +// @Param user body dto.UserCreateDto true "User information" +// @Success 200 {object} dto.UserDto +// @Router /users/me [put] func (uc *UserController) updateCurrentUserHandler(c *gin.Context) { if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" { c.Error(&common.AccountEditNotAllowedError{}) @@ -168,6 +232,14 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) { uc.updateUser(c, true) } +// getUserProfilePictureHandler godoc +// @Summary Get user profile picture +// @Description Retrieve a specific user's profile picture +// @Tags Users +// @Produce image/png +// @Param id path string true "User ID" +// @Success 200 {file} binary "PNG image" +// @Router /users/{id}/profile-picture.png [get] func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) { userID := c.Param("id") @@ -180,6 +252,13 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) { c.DataFromReader(http.StatusOK, size, "image/png", picture, nil) } +// getCurrentUserProfilePictureHandler godoc +// @Summary Get current user's profile picture +// @Description Retrieve the currently authenticated user's profile picture +// @Tags Users +// @Produce image/png +// @Success 200 {file} binary "PNG image" +// @Router /users/me/profile-picture.png [get] func (uc *UserController) getCurrentUserProfilePictureHandler(c *gin.Context) { userID := c.GetString("userID") @@ -192,6 +271,16 @@ func (uc *UserController) getCurrentUserProfilePictureHandler(c *gin.Context) { c.DataFromReader(http.StatusOK, size, "image/png", picture, nil) } +// updateUserProfilePictureHandler godoc +// @Summary Update user profile picture +// @Description Update a specific user's profile picture +// @Tags Users +// @Accept multipart/form-data +// @Produce json +// @Param id path string true "User ID" +// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)" +// @Success 204 "No Content" +// @Router /users/{id}/profile-picture [put] func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) { userID := c.Param("id") fileHeader, err := c.FormFile("file") @@ -214,6 +303,15 @@ func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) { c.Status(http.StatusNoContent) } +// updateCurrentUserProfilePictureHandler godoc +// @Summary Update current user's profile picture +// @Description Update the currently authenticated user's profile picture +// @Tags Users +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)" +// @Success 204 "No Content" +// @Router /users/me/profile-picture [put] func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) { userID := c.GetString("userID") fileHeader, err := c.FormFile("file") @@ -255,6 +353,14 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo c.JSON(http.StatusCreated, gin.H{"token": token}) } +// createOwnOneTimeAccessTokenHandler godoc +// @Summary Create one-time access token for current user +// @Description Generate a one-time access token for the currently authenticated user +// @Tags Users +// @Param id path string true "User ID" +// @Param body body dto.OneTimeAccessTokenCreateDto true "Token options" +// @Success 201 {object} object "{ \"token\": \"string\" }" +// @Router /users/{id}/one-time-access-token [post] func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) { uc.createOneTimeAccessTokenHandler(c, true) } @@ -279,6 +385,13 @@ func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) { c.Status(http.StatusNoContent) } +// exchangeOneTimeAccessTokenHandler godoc +// @Summary Exchange one-time access token +// @Description Exchange a one-time access token for a session token +// @Tags Users +// @Param token path string true "One-time access token" +// @Success 200 {object} dto.UserDto +// @Router /one-time-access-token/{token} [post] func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent()) if err != nil { @@ -299,6 +412,12 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { c.JSON(http.StatusOK, userDto) } +// getSetupAccessTokenHandler godoc +// @Summary Setup initial admin +// @Description Generate setup access token for initial admin user configuration +// @Tags Users +// @Success 200 {object} dto.UserDto +// @Router /one-time-access-token/setup [post] func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) { user, token, err := uc.userService.SetupInitialAdmin() if err != nil { @@ -319,6 +438,37 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) { c.JSON(http.StatusOK, userDto) } +// updateUserGroups godoc +// @Summary Update user groups +// @Description Update the groups a specific user belongs to +// @Tags Users +// @Param id path string true "User ID" +// @Param groups body dto.UserUpdateUserGroupDto true "User group IDs" +// @Success 200 {object} dto.UserDto +// @Router /users/{id}/user-groups [put] +func (uc *UserController) updateUserGroups(c *gin.Context) { + var input dto.UserUpdateUserGroupDto + if err := c.ShouldBindJSON(&input); err != nil { + c.Error(err) + return + } + + user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds) + if err != nil { + c.Error(err) + return + } + + var userDto dto.UserDto + if err := dto.MapStruct(user, &userDto); err != nil { + c.Error(err) + return + } + + c.JSON(http.StatusOK, userDto) +} + +// updateUser is an internal helper method, not exposed as an API endpoint func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { var input dto.UserCreateDto if err := c.ShouldBindJSON(&input); err != nil { @@ -347,25 +497,3 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { c.JSON(http.StatusOK, userDto) } - -func (uc *UserController) updateUserGroups(c *gin.Context) { - var input dto.UserUpdateUserGroupDto - if err := c.ShouldBindJSON(&input); err != nil { - c.Error(err) - return - } - - user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds) - if err != nil { - c.Error(err) - return - } - - var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { - c.Error(err) - return - } - - c.JSON(http.StatusOK, userDto) -} diff --git a/backend/internal/controller/user_group_controller.go b/backend/internal/controller/user_group_controller.go index 1c0dcbe5..4f2e22e2 100644 --- a/backend/internal/controller/user_group_controller.go +++ b/backend/internal/controller/user_group_controller.go @@ -10,23 +10,42 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/utils" ) -func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) { +// NewUserGroupController creates a new controller for user group management +// @Summary User group management controller +// @Description Initializes all user group-related API endpoints +// @Tags User Groups +func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, userGroupService *service.UserGroupService) { ugc := UserGroupController{ UserGroupService: userGroupService, } - group.GET("/user-groups", jwtAuthMiddleware.Add(true), ugc.list) - group.GET("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.get) - group.POST("/user-groups", jwtAuthMiddleware.Add(true), ugc.create) - group.PUT("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.update) - group.DELETE("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.delete) - group.PUT("/user-groups/:id/users", jwtAuthMiddleware.Add(true), ugc.updateUsers) + userGroupsGroup := group.Group("/user-groups") + userGroupsGroup.Use(authMiddleware.Add()) + { + userGroupsGroup.GET("", ugc.list) + userGroupsGroup.GET("/:id", ugc.get) + userGroupsGroup.POST("", ugc.create) + userGroupsGroup.PUT("/:id", ugc.update) + userGroupsGroup.DELETE("/:id", ugc.delete) + userGroupsGroup.PUT("/:id/users", ugc.updateUsers) + } } type UserGroupController struct { UserGroupService *service.UserGroupService } +// list godoc +// @Summary List user groups +// @Description Get a paginated list of user groups with optional search and sorting +// @Tags User Groups +// @Param search query string false "Search term to filter user groups by name" +// @Param page query int false "Page number, starting from 1" default(1) +// @Param limit query int false "Number of items per page" default(10) +// @Param sort_column query string false "Column to sort by" default("name") +// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc") +// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount] +// @Router /user-groups [get] func (ugc *UserGroupController) list(c *gin.Context) { searchTerm := c.Query("search") var sortedPaginationRequest utils.SortedPaginationRequest @@ -41,7 +60,7 @@ func (ugc *UserGroupController) list(c *gin.Context) { return } - // Map the user groups to DTOs. The user count can't be mapped directly, so we have to do it manually. + // Map the user groups to DTOs var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups)) for i, group := range groups { var groupDto dto.UserGroupDtoWithUserCount @@ -57,12 +76,22 @@ func (ugc *UserGroupController) list(c *gin.Context) { groupsDto[i] = groupDto } - c.JSON(http.StatusOK, gin.H{ - "data": groupsDto, - "pagination": pagination, + c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupDtoWithUserCount]{ + Data: groupsDto, + Pagination: pagination, }) } +// get godoc +// @Summary Get user group by ID +// @Description Retrieve detailed information about a specific user group including its users +// @Tags User Groups +// @Accept json +// @Produce json +// @Param id path string true "User Group ID" +// @Success 200 {object} dto.UserGroupDtoWithUsers +// @Security BearerAuth +// @Router /user-groups/{id} [get] func (ugc *UserGroupController) get(c *gin.Context) { group, err := ugc.UserGroupService.Get(c.Param("id")) if err != nil { @@ -79,6 +108,16 @@ func (ugc *UserGroupController) get(c *gin.Context) { c.JSON(http.StatusOK, groupDto) } +// create godoc +// @Summary Create user group +// @Description Create a new user group +// @Tags User Groups +// @Accept json +// @Produce json +// @Param userGroup body dto.UserGroupCreateDto true "User group information" +// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group" +// @Security BearerAuth +// @Router /user-groups [post] func (ugc *UserGroupController) create(c *gin.Context) { var input dto.UserGroupCreateDto if err := c.ShouldBindJSON(&input); err != nil { @@ -101,6 +140,17 @@ func (ugc *UserGroupController) create(c *gin.Context) { c.JSON(http.StatusCreated, groupDto) } +// update godoc +// @Summary Update user group +// @Description Update an existing user group by ID +// @Tags User Groups +// @Accept json +// @Produce json +// @Param id path string true "User Group ID" +// @Param userGroup body dto.UserGroupCreateDto true "User group information" +// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group" +// @Security BearerAuth +// @Router /user-groups/{id} [put] func (ugc *UserGroupController) update(c *gin.Context) { var input dto.UserGroupCreateDto if err := c.ShouldBindJSON(&input); err != nil { @@ -123,6 +173,16 @@ func (ugc *UserGroupController) update(c *gin.Context) { c.JSON(http.StatusOK, groupDto) } +// delete godoc +// @Summary Delete user group +// @Description Delete a specific user group by ID +// @Tags User Groups +// @Accept json +// @Produce json +// @Param id path string true "User Group ID" +// @Success 204 "No Content" +// @Security BearerAuth +// @Router /user-groups/{id} [delete] func (ugc *UserGroupController) delete(c *gin.Context) { if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil { c.Error(err) @@ -132,6 +192,17 @@ func (ugc *UserGroupController) delete(c *gin.Context) { c.Status(http.StatusNoContent) } +// updateUsers godoc +// @Summary Update users in a group +// @Description Update the list of users belonging to a specific user group +// @Tags User Groups +// @Accept json +// @Produce json +// @Param id path string true "User Group ID" +// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group" +// @Success 200 {object} dto.UserGroupDtoWithUsers +// @Security BearerAuth +// @Router /user-groups/{id}/users [put] func (ugc *UserGroupController) updateUsers(c *gin.Context) { var input dto.UserGroupUpdateUsersDto if err := c.ShouldBindJSON(&input); err != nil { diff --git a/backend/internal/controller/webauthn_controller.go b/backend/internal/controller/webauthn_controller.go index 597ad519..ce7c49ea 100644 --- a/backend/internal/controller/webauthn_controller.go +++ b/backend/internal/controller/webauthn_controller.go @@ -16,19 +16,19 @@ import ( "golang.org/x/time/rate" ) -func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) { +func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) { wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService} - group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler) - group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler) + group.GET("/webauthn/register/start", authMiddleware.WithAdminNotRequired().Add(), wc.beginRegistrationHandler) + group.POST("/webauthn/register/finish", authMiddleware.WithAdminNotRequired().Add(), wc.verifyRegistrationHandler) group.GET("/webauthn/login/start", wc.beginLoginHandler) group.POST("/webauthn/login/finish", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.verifyLoginHandler) - group.POST("/webauthn/logout", jwtAuthMiddleware.Add(false), wc.logoutHandler) + group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler) - group.GET("/webauthn/credentials", jwtAuthMiddleware.Add(false), wc.listCredentialsHandler) - group.PATCH("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.updateCredentialHandler) - group.DELETE("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.deleteCredentialHandler) + group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler) + group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler) + group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler) } type WebauthnController struct { diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go index 4c7f7ae7..1debaf2f 100644 --- a/backend/internal/controller/well_known_controller.go +++ b/backend/internal/controller/well_known_controller.go @@ -8,6 +8,10 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/service" ) +// NewWellKnownController creates a new controller for OIDC discovery endpoints +// @Summary OIDC Discovery controller +// @Description Initializes OIDC discovery and JWKS endpoints +// @Tags Well Known func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) { wkc := &WellKnownController{jwtService: jwtService} group.GET("/.well-known/jwks.json", wkc.jwksHandler) @@ -18,6 +22,13 @@ type WellKnownController struct { jwtService *service.JwtService } +// jwksHandler godoc +// @Summary Get JSON Web Key Set (JWKS) +// @Description Returns the JSON Web Key Set used for token verification +// @Tags Well Known +// @Produce json +// @Success 200 {object} object "{ \"keys\": []interface{} }" +// @Router /.well-known/jwks.json [get] func (wkc *WellKnownController) jwksHandler(c *gin.Context) { jwk, err := wkc.jwtService.GetJWK() if err != nil { @@ -28,6 +39,12 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}}) } +// openIDConfigurationHandler godoc +// @Summary Get OpenID Connect discovery configuration +// @Description Returns the OpenID Connect discovery document with endpoints and capabilities +// @Tags Well Known +// @Success 200 {object} object "OpenID Connect configuration" +// @Router /.well-known/openid-configuration [get] func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) { appUrl := common.EnvConfig.AppURL config := map[string]interface{}{ diff --git a/backend/internal/dto/api_key_dto.go b/backend/internal/dto/api_key_dto.go new file mode 100644 index 00000000..989176e9 --- /dev/null +++ b/backend/internal/dto/api_key_dto.go @@ -0,0 +1,25 @@ +package dto + +import ( + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" +) + +type ApiKeyCreateDto struct { + Name string `json:"name" binding:"required,min=3,max=50"` + Description string `json:"description"` + ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"` +} + +type ApiKeyDto struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ExpiresAt datatype.DateTime `json:"expiresAt"` + LastUsedAt *datatype.DateTime `json:"lastUsedAt"` + CreatedAt datatype.DateTime `json:"createdAt"` +} + +type ApiKeyResponseDto struct { + ApiKey ApiKeyDto `json:"apiKey"` + Token string `json:"token"` +} diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index 78a1fcb7..ca21aa92 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -1,13 +1,13 @@ package dto -type PublicOidcClientDto struct { +type OidcClientMetaDataDto struct { ID string `json:"id"` Name string `json:"name"` HasLogo bool `json:"hasLogo"` } type OidcClientDto struct { - PublicOidcClientDto + OidcClientMetaDataDto CallbackURLs []string `json:"callbackURLs"` LogoutCallbackURLs []string `json:"logoutCallbackURLs"` IsPublic bool `json:"isPublic"` @@ -15,12 +15,8 @@ type OidcClientDto struct { } type OidcClientWithAllowedUserGroupsDto struct { - PublicOidcClientDto - CallbackURLs []string `json:"callbackURLs"` - LogoutCallbackURLs []string `json:"logoutCallbackURLs"` - IsPublic bool `json:"isPublic"` - PkceEnabled bool `json:"pkceEnabled"` - AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"` + OidcClientDto + AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"` } type OidcClientCreateDto struct { diff --git a/backend/internal/dto/pagination_dto.go b/backend/internal/dto/pagination_dto.go new file mode 100644 index 00000000..9ae33cbf --- /dev/null +++ b/backend/internal/dto/pagination_dto.go @@ -0,0 +1,10 @@ +package dto + +import "github.com/pocket-id/pocket-id/backend/internal/utils" + +type Pagination = utils.PaginationResponse + +type Paginated[T any] struct { + Data []T `json:"data"` + Pagination Pagination `json:"pagination"` +} diff --git a/backend/internal/middleware/api_key_auth.go b/backend/internal/middleware/api_key_auth.go new file mode 100644 index 00000000..79b32ee7 --- /dev/null +++ b/backend/internal/middleware/api_key_auth.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "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 +} + +func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, jwtService *service.JwtService) *ApiKeyAuthMiddleware { + return &ApiKeyAuthMiddleware{ + apiKeyService: apiKeyService, + jwtService: jwtService, + } +} + +func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc { + return func(c *gin.Context) { + userID, isAdmin, err := m.Verify(c, adminRequired) + if err != nil { + c.Abort() + c.Error(err) + return + } + + c.Set("userID", userID) + c.Set("userIsAdmin", isAdmin) + c.Next() + } +} + +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(apiKey) + if err != nil { + return "", false, &common.NotSignedInError{} + } + + // Check if the user is an admin + if adminRequired && !user.IsAdmin { + return "", false, &common.MissingPermissionError{} + } + + return user.ID, user.IsAdmin, nil +} diff --git a/backend/internal/middleware/auth_middleware.go b/backend/internal/middleware/auth_middleware.go new file mode 100644 index 00000000..29b8ea8f --- /dev/null +++ b/backend/internal/middleware/auth_middleware.go @@ -0,0 +1,89 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/service" +) + +// AuthMiddleware is a wrapper middleware that delegates to either API key or JWT authentication +type AuthMiddleware struct { + apiKeyMiddleware *ApiKeyAuthMiddleware + jwtMiddleware *JwtAuthMiddleware + options AuthOptions +} + +type AuthOptions struct { + AdminRequired bool + SuccessOptional bool +} + +func NewAuthMiddleware( + apiKeyService *service.ApiKeyService, + jwtService *service.JwtService, +) *AuthMiddleware { + return &AuthMiddleware{ + apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyService, jwtService), + jwtMiddleware: NewJwtAuthMiddleware(jwtService), + options: AuthOptions{ + AdminRequired: true, + SuccessOptional: false, + }, + } +} + +// WithAdminNotRequired allows the middleware to continue with the request even if the user is not an admin +func (m *AuthMiddleware) WithAdminNotRequired() *AuthMiddleware { + // Create a new instance to avoid modifying the original + clone := &AuthMiddleware{ + apiKeyMiddleware: m.apiKeyMiddleware, + jwtMiddleware: m.jwtMiddleware, + options: m.options, + } + clone.options.AdminRequired = false + return clone +} + +// WithSuccessOptional allows the middleware to continue with the request even if authentication fails +func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware { + // Create a new instance to avoid modifying the original + clone := &AuthMiddleware{ + apiKeyMiddleware: m.apiKeyMiddleware, + jwtMiddleware: m.jwtMiddleware, + options: m.options, + } + clone.options.SuccessOptional = true + return clone +} + +func (m *AuthMiddleware) Add() gin.HandlerFunc { + return func(c *gin.Context) { + // First try JWT auth + userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired) + if err == nil { + // JWT auth succeeded, continue with the request + c.Set("userID", userID) + c.Set("userIsAdmin", isAdmin) + c.Next() + return + } + + // JWT auth failed, try API key auth + userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired) + if err == nil { + // API key auth succeeded, continue with the request + c.Set("userID", userID) + c.Set("userIsAdmin", isAdmin) + c.Next() + return + } + + if m.options.SuccessOptional { + c.Next() + return + } + + // Both JWT and API key auth failed + c.Abort() + c.Error(err) + } +} diff --git a/backend/internal/middleware/jwt_auth.go b/backend/internal/middleware/jwt_auth.go index 36f7e9e8..572f14c1 100644 --- a/backend/internal/middleware/jwt_auth.go +++ b/backend/internal/middleware/jwt_auth.go @@ -10,51 +10,50 @@ import ( ) type JwtAuthMiddleware struct { - jwtService *service.JwtService - ignoreUnauthenticated bool + jwtService *service.JwtService } -func NewJwtAuthMiddleware(jwtService *service.JwtService, ignoreUnauthenticated bool) *JwtAuthMiddleware { - return &JwtAuthMiddleware{jwtService: jwtService, ignoreUnauthenticated: ignoreUnauthenticated} +func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware { + return &JwtAuthMiddleware{jwtService: jwtService} } -func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc { +func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc { return func(c *gin.Context) { - // Extract the token from the cookie or the Authorization header - token, err := c.Cookie(cookie.AccessTokenCookieName) + + userID, isAdmin, err := m.Verify(c, adminRequired) if err != nil { - authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ") - if len(authorizationHeaderSplitted) == 2 { - token = authorizationHeaderSplitted[1] - } else if m.ignoreUnauthenticated { - c.Next() - return - } else { - c.Error(&common.NotSignedInError{}) - c.Abort() - return - } - } - - claims, err := m.jwtService.VerifyAccessToken(token) - if err != nil && m.ignoreUnauthenticated { - c.Next() - return - } else if err != nil { - c.Error(&common.NotSignedInError{}) c.Abort() + c.Error(err) return } - // Check if the user is an admin - if adminOnly && !claims.IsAdmin { - c.Error(&common.MissingPermissionError{}) - c.Abort() - return - } - - c.Set("userID", claims.Subject) - c.Set("userIsAdmin", claims.IsAdmin) + c.Set("userID", userID) + c.Set("userIsAdmin", isAdmin) c.Next() } } + +func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) { + // Extract the token from the cookie + token, err := c.Cookie(cookie.AccessTokenCookieName) + if err != nil { + // Try to extract the token from the Authorization header if it's not in the cookie + authorizationHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ") + if len(authorizationHeaderSplit) != 2 { + return "", false, &common.NotSignedInError{} + } + token = authorizationHeaderSplit[1] + } + + claims, err := m.jwtService.VerifyAccessToken(token) + if err != nil { + return "", false, &common.NotSignedInError{} + } + + // Check if the user is an admin + if adminRequired && !claims.IsAdmin { + return "", false, &common.MissingPermissionError{} + } + + return claims.Subject, claims.IsAdmin, nil +} diff --git a/backend/internal/model/api_key.go b/backend/internal/model/api_key.go new file mode 100644 index 00000000..456bcb05 --- /dev/null +++ b/backend/internal/model/api_key.go @@ -0,0 +1,18 @@ +package model + +import ( + "github.com/pocket-id/pocket-id/backend/internal/model/types" +) + +type ApiKey struct { + Base + + Name string `sortable:"true"` + Key string + Description *string + ExpiresAt datatype.DateTime `sortable:"true"` + LastUsedAt *datatype.DateTime `sortable:"true"` + + UserID string + User User +} diff --git a/backend/internal/model/base.go b/backend/internal/model/base.go index e93e127e..012f0f36 100644 --- a/backend/internal/model/base.go +++ b/backend/internal/model/base.go @@ -4,20 +4,20 @@ import ( "time" "github.com/google/uuid" - model "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/model/types" "gorm.io/gorm" ) // Base contains common columns for all tables. type Base struct { - ID string `gorm:"primaryKey;not null"` - CreatedAt model.DateTime `sortable:"true"` + ID string `gorm:"primaryKey;not null"` + CreatedAt datatype.DateTime `sortable:"true"` } func (b *Base) BeforeCreate(_ *gorm.DB) (err error) { if b.ID == "" { b.ID = uuid.New().String() } - b.CreatedAt = model.DateTime(time.Now()) + b.CreatedAt = datatype.DateTime(time.Now()) return } diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go new file mode 100644 index 00000000..9db2c4e0 --- /dev/null +++ b/backend/internal/service/api_key_service.go @@ -0,0 +1,102 @@ +package service + +import ( + "errors" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "log" + "time" + + "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" +) + +type ApiKeyService struct { + db *gorm.DB +} + +func NewApiKeyService(db *gorm.DB) *ApiKeyService { + return &ApiKeyService{db: db} +} + +func (s *ApiKeyService) ListApiKeys(userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) { + query := s.db.Where("user_id = ?", userID).Model(&model.ApiKey{}) + + var apiKeys []model.ApiKey + pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &apiKeys) + if err != nil { + return nil, utils.PaginationResponse{}, err + } + + return apiKeys, pagination, nil +} + +func (s *ApiKeyService) CreateApiKey(userID string, input dto.ApiKeyCreateDto) (model.ApiKey, string, error) { + // Check if expiration is in the future + if !input.ExpiresAt.ToTime().After(time.Now()) { + return model.ApiKey{}, "", &common.APIKeyExpirationDateError{} + } + + // Generate a secure random API key + token, err := utils.GenerateRandomAlphanumericString(32) + if err != nil { + return model.ApiKey{}, "", err + } + + apiKey := model.ApiKey{ + Name: input.Name, + Key: utils.CreateSha256Hash(token), // Hash the token for storage + Description: &input.Description, + ExpiresAt: datatype.DateTime(input.ExpiresAt), + UserID: userID, + } + + if err := s.db.Create(&apiKey).Error; err != nil { + return model.ApiKey{}, "", err + } + + // Return the raw token only once - it cannot be retrieved later + return apiKey, token, nil +} + +func (s *ApiKeyService) RevokeApiKey(userID, apiKeyID string) error { + var apiKey model.ApiKey + if err := s.db.Where("id = ? AND user_id = ?", apiKeyID, userID).First(&apiKey).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.APIKeyNotFoundError{} + } + return err + } + + return s.db.Delete(&apiKey).Error +} + +func (s *ApiKeyService) ValidateApiKey(apiKey string) (model.User, error) { + if apiKey == "" { + return model.User{}, &common.NoAPIKeyProvidedError{} + } + + var key model.ApiKey + hashedKey := utils.CreateSha256Hash(apiKey) + + if err := s.db.Preload("User").Where("key = ? AND expires_at > ?", + hashedKey, time.Now()).Preload("User").First(&key).Error; err != nil { + + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.User{}, &common.InvalidAPIKeyError{} + } + + return model.User{}, err + } + + // Update last used time + now := datatype.DateTime(time.Now()) + key.LastUsedAt = &now + if err := s.db.Save(&key).Error; err != nil { + log.Printf("Failed to update last used time: %v", err) + } + + return key.User, nil +} diff --git a/backend/internal/service/test_service.go b/backend/internal/service/test_service.go index 89d208ff..1c5d3d3c 100644 --- a/backend/internal/service/test_service.go +++ b/backend/internal/service/test_service.go @@ -212,6 +212,18 @@ func (s *TestService) SeedDatabase() error { return err } + apiKey := model.ApiKey{ + Base: model.Base{ + ID: "5f1fa856-c164-4295-961e-175a0d22d725", + }, + Name: "Test API Key", + Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20", + UserID: users[0].ID, + } + if err := tx.Create(&apiKey).Error; err != nil { + return err + } + return nil }) } diff --git a/backend/internal/utils/hash_util.go b/backend/internal/utils/hash_util.go new file mode 100644 index 00000000..d80c41f1 --- /dev/null +++ b/backend/internal/utils/hash_util.go @@ -0,0 +1,11 @@ +package utils + +import ( + "crypto/sha256" + "encoding/hex" +) + +func CreateSha256Hash(input string) string { + hash := sha256.Sum256([]byte(input)) + return hex.EncodeToString(hash[:]) +} diff --git a/backend/resources/migrations/postgres/20250302220732_api_key_auth.down.sql b/backend/resources/migrations/postgres/20250302220732_api_key_auth.down.sql new file mode 100644 index 00000000..3669e0ac --- /dev/null +++ b/backend/resources/migrations/postgres/20250302220732_api_key_auth.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_api_keys_key; +DROP TABLE IF EXISTS api_keys; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20250302220732_api_key_auth.up.sql b/backend/resources/migrations/postgres/20250302220732_api_key_auth.up.sql new file mode 100644 index 00000000..9b64589f --- /dev/null +++ b/backend/resources/migrations/postgres/20250302220732_api_key_auth.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE api_keys ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + key VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + expires_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ, + user_id UUID REFERENCES users ON DELETE CASCADE +); + +CREATE INDEX idx_api_keys_key ON api_keys(key); \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250302220732_api_key_auth.down.sql b/backend/resources/migrations/sqlite/20250302220732_api_key_auth.down.sql new file mode 100644 index 00000000..3669e0ac --- /dev/null +++ b/backend/resources/migrations/sqlite/20250302220732_api_key_auth.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_api_keys_key; +DROP TABLE IF EXISTS api_keys; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250302220732_api_key_auth.up.sql b/backend/resources/migrations/sqlite/20250302220732_api_key_auth.up.sql new file mode 100644 index 00000000..3e27cb83 --- /dev/null +++ b/backend/resources/migrations/sqlite/20250302220732_api_key_auth.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + key TEXT NOT NULL UNIQUE, + description TEXT, + expires_at DATETIME NOT NULL, + last_used_at DATETIME, + created_at DATETIME, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_api_keys_key ON api_keys(key); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 52b5a101..9ff66405 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pocket-id-frontend", - "version": "0.37.0", + "version": "0.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pocket-id-frontend", - "version": "0.37.0", + "version": "0.38.0", "dependencies": { "@simplewebauthn/browser": "^13.1.0", "@tailwindcss/vite": "^4.0.0", @@ -16,7 +16,7 @@ "crypto": "^1.0.1", "formsnap": "^1.0.1", "jose": "^5.9.6", - "lucide-svelte": "^0.474.0", + "lucide-svelte": "^0.479.0", "mode-watcher": "^0.5.1", "svelte-sonner": "^0.3.28", "sveltekit-superforms": "^2.23.1", @@ -25,6 +25,7 @@ "zod": "^3.24.1" }, "devDependencies": { + "@internationalized/date": "^3.7.0", "@playwright/test": "^1.50.0", "@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-node": "^5.2.12", @@ -3300,9 +3301,9 @@ "dev": true }, "node_modules/lucide-svelte": { - "version": "0.474.0", - "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.474.0.tgz", - "integrity": "sha512-yOSqjXPoEDOXCceBIfDaed6RinOvhp03ShiTXH6O+vlXE/NsyjQpktL8gm2vGDxi9d81HMuPFN1dwhVURh6mGg==", + "version": "0.479.0", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.479.0.tgz", + "integrity": "sha512-epCj6WL86ykxg7oCQTmPEth5e11pwJUzIfG9ROUsWsTP+WPtb3qat+VmAjfx/r4TRW7memTFcbTPvMrZvKthqw==", "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } diff --git a/frontend/package.json b/frontend/package.json index f22ec98d..3e4b320f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,7 @@ "crypto": "^1.0.1", "formsnap": "^1.0.1", "jose": "^5.9.6", - "lucide-svelte": "^0.474.0", + "lucide-svelte": "^0.479.0", "mode-watcher": "^0.5.1", "svelte-sonner": "^0.3.28", "sveltekit-superforms": "^2.23.1", @@ -30,6 +30,7 @@ "zod": "^3.24.1" }, "devDependencies": { + "@internationalized/date": "^3.7.0", "@playwright/test": "^1.50.0", "@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-node": "^5.2.12", diff --git a/frontend/src/lib/components/form/date-picker.svelte b/frontend/src/lib/components/form/date-picker.svelte new file mode 100644 index 00000000..8d57df1c --- /dev/null +++ b/frontend/src/lib/components/form/date-picker.svelte @@ -0,0 +1,53 @@ + + + (open = o)}> + + + + + + + diff --git a/frontend/src/lib/components/form/form-input.svelte b/frontend/src/lib/components/form/form-input.svelte index 4b63e58c..4be4688a 100644 --- a/frontend/src/lib/components/form/form-input.svelte +++ b/frontend/src/lib/components/form/form-input.svelte @@ -1,9 +1,10 @@ + + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-day.svelte b/frontend/src/lib/components/ui/calendar/calendar-day.svelte new file mode 100644 index 00000000..8bcfb2cd --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-day.svelte @@ -0,0 +1,42 @@ + + + + + {date.day} + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-grid-body.svelte b/frontend/src/lib/components/ui/calendar/calendar-grid-body.svelte new file mode 100644 index 00000000..dcbb3c43 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-grid-body.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-grid-head.svelte b/frontend/src/lib/components/ui/calendar/calendar-grid-head.svelte new file mode 100644 index 00000000..e2fe4299 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-grid-head.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-grid-row.svelte b/frontend/src/lib/components/ui/calendar/calendar-grid-row.svelte new file mode 100644 index 00000000..c64a0a4f --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-grid-row.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-grid.svelte b/frontend/src/lib/components/ui/calendar/calendar-grid.svelte new file mode 100644 index 00000000..6118116e --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-grid.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-head-cell.svelte b/frontend/src/lib/components/ui/calendar/calendar-head-cell.svelte new file mode 100644 index 00000000..dfbe370a --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-head-cell.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-header.svelte b/frontend/src/lib/components/ui/calendar/calendar-header.svelte new file mode 100644 index 00000000..f69c8146 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-header.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-heading.svelte b/frontend/src/lib/components/ui/calendar/calendar-heading.svelte new file mode 100644 index 00000000..a11a4386 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-heading.svelte @@ -0,0 +1,19 @@ + + + + + {headingValue} + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-months.svelte b/frontend/src/lib/components/ui/calendar/calendar-months.svelte new file mode 100644 index 00000000..3ff4ea35 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-months.svelte @@ -0,0 +1,16 @@ + + +
+ +
diff --git a/frontend/src/lib/components/ui/calendar/calendar-next-button.svelte b/frontend/src/lib/components/ui/calendar/calendar-next-button.svelte new file mode 100644 index 00000000..d4abb337 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-next-button.svelte @@ -0,0 +1,27 @@ + + + + + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar-prev-button.svelte b/frontend/src/lib/components/ui/calendar/calendar-prev-button.svelte new file mode 100644 index 00000000..9d8346b9 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar-prev-button.svelte @@ -0,0 +1,27 @@ + + + + + + + diff --git a/frontend/src/lib/components/ui/calendar/calendar.svelte b/frontend/src/lib/components/ui/calendar/calendar.svelte new file mode 100644 index 00000000..8bff00e1 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/calendar.svelte @@ -0,0 +1,141 @@ + + + + + + { + if (!v || !placeholder) return; + if (v.value === placeholder?.month) return; + placeholder = placeholder.set({ month: v.value }); + }} + > + + + + + {#each monthOptions as { value, label }} + + {label} + + {/each} + + + { + if (!v || !placeholder) return; + if (v.value === placeholder?.year) return; + placeholder = placeholder.set({ year: v.value }); + }} + > + + + + + {#each yearOptions as { value, label }} + + {label} + + {/each} + + + + + + {#each months as month} + + + + {#each weekdays as weekday} + + {weekday.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates} + + {#each weekDates as date} + + + + {/each} + + {/each} + + + {/each} + + \ No newline at end of file diff --git a/frontend/src/lib/components/ui/calendar/index.ts b/frontend/src/lib/components/ui/calendar/index.ts new file mode 100644 index 00000000..ab257ab3 --- /dev/null +++ b/frontend/src/lib/components/ui/calendar/index.ts @@ -0,0 +1,30 @@ +import Root from "./calendar.svelte"; +import Cell from "./calendar-cell.svelte"; +import Day from "./calendar-day.svelte"; +import Grid from "./calendar-grid.svelte"; +import Header from "./calendar-header.svelte"; +import Months from "./calendar-months.svelte"; +import GridRow from "./calendar-grid-row.svelte"; +import Heading from "./calendar-heading.svelte"; +import GridBody from "./calendar-grid-body.svelte"; +import GridHead from "./calendar-grid-head.svelte"; +import HeadCell from "./calendar-head-cell.svelte"; +import NextButton from "./calendar-next-button.svelte"; +import PrevButton from "./calendar-prev-button.svelte"; + +export { + Day, + Cell, + Grid, + Header, + Months, + GridRow, + Heading, + GridBody, + GridHead, + HeadCell, + NextButton, + PrevButton, + // + Root as Calendar, +}; diff --git a/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/components/ui/dialog/dialog-content.svelte index d7023dcd..464dcb0c 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-content.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-content.svelte @@ -4,10 +4,13 @@ import * as Dialog from './index.js'; import { cn, flyAndScale } from '$lib/utils/style.js'; - type $$Props = DialogPrimitive.ContentProps; + type $$Props = DialogPrimitive.ContentProps & { + closeButton?: boolean; + } let className: $$Props['class'] = undefined; export let transition: $$Props['transition'] = flyAndScale; + export let closeButton : $$Props['closeButton'] = true; export let transitionConfig: $$Props['transitionConfig'] = { duration: 200 }; @@ -26,11 +29,13 @@ {...$$restProps} > + {#if closeButton} Close + {/if} diff --git a/frontend/src/lib/services/api-key-service.ts b/frontend/src/lib/services/api-key-service.ts new file mode 100644 index 00000000..75592c52 --- /dev/null +++ b/frontend/src/lib/services/api-key-service.ts @@ -0,0 +1,21 @@ +import type { ApiKey, ApiKeyCreate, ApiKeyResponse } from '$lib/types/api-key.type'; +import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; +import APIService from './api-service'; + +export default class ApiKeyService extends APIService { + async list(options?: SearchPaginationSortRequest) { + const res = await this.api.get('/api-keys', { + params: options + }); + return res.data as Paginated; + } + + async create(data: ApiKeyCreate): Promise { + const res = await this.api.post('/api-keys', data); + return res.data as ApiKeyResponse; + } + + async revoke(id: string): Promise { + await this.api.delete(`/api-keys/${id}`); + } +} diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index 7d1118bf..637d9f34 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -2,6 +2,7 @@ import type { AuthorizeResponse, OidcClient, OidcClientCreate, + OidcClientMetaData, OidcClientWithAllowedUserGroups } from '$lib/types/oidc.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; @@ -56,6 +57,10 @@ class OidcService extends APIService { return (await this.api.get(`/oidc/clients/${id}`)).data as OidcClientWithAllowedUserGroups; } + async getClientMetaData(id: string) { + return (await this.api.get(`/oidc/clients/${id}/meta`)).data as OidcClientMetaData; + } + async updateClient(id: string, client: OidcClientCreate) { return (await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient; } diff --git a/frontend/src/lib/types/api-key.type.ts b/frontend/src/lib/types/api-key.type.ts new file mode 100644 index 00000000..1adbb1a8 --- /dev/null +++ b/frontend/src/lib/types/api-key.type.ts @@ -0,0 +1,19 @@ +export type ApiKey = { + id: string; + name: string; + description?: string; + expiresAt: string; + lastUsedAt?: string; + createdAt: string; +}; + +export type ApiKeyCreate = { + name: string; + description?: string; + expiresAt: Date; +}; + +export type ApiKeyResponse = { + apiKey: ApiKey; + token: string; +}; diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index 85da1cb7..e9023be4 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -1,12 +1,14 @@ import type { UserGroup } from './user-group.type'; -export type OidcClient = { +export type OidcClientMetaData = { id: string; name: string; - logoURL: string; + hasLogo: boolean; +}; + +export type OidcClient = OidcClientMetaData & { callbackURLs: [string, ...string[]]; logoutCallbackURLs: string[]; - hasLogo: boolean; isPublic: boolean; pkceEnabled: boolean; }; diff --git a/frontend/src/routes/authorize/+page.server.ts b/frontend/src/routes/authorize/+page.server.ts index 191595e7..50319afb 100644 --- a/frontend/src/routes/authorize/+page.server.ts +++ b/frontend/src/routes/authorize/+page.server.ts @@ -6,7 +6,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => { const clientId = url.searchParams.get('client_id'); const oidcService = new OidcService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); - const client = await oidcService.getClient(clientId!); + const client = await oidcService.getClientMetaData(clientId!); return { scope: url.searchParams.get('scope')!, diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte index 36a10a13..f6cdc944 100644 --- a/frontend/src/routes/settings/+layout.svelte +++ b/frontend/src/routes/settings/+layout.svelte @@ -27,6 +27,7 @@ { href: '/settings/admin/users', label: 'Users' }, { href: '/settings/admin/user-groups', label: 'User Groups' }, { href: '/settings/admin/oidc-clients', label: 'OIDC Clients' }, + { href: '/settings/admin/api-keys', label: 'API Keys' }, { href: '/settings/admin/application-configuration', label: 'Application Configuration' } ]; } diff --git a/frontend/src/routes/settings/admin/api-keys/+page.server.ts b/frontend/src/routes/settings/admin/api-keys/+page.server.ts new file mode 100644 index 00000000..ba0d0aab --- /dev/null +++ b/frontend/src/routes/settings/admin/api-keys/+page.server.ts @@ -0,0 +1,16 @@ +import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; +import ApiKeyService from '$lib/services/api-key-service'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ cookies }) => { + const apiKeyService = new ApiKeyService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); + + const apiKeys = await apiKeyService.list({ + sort: { + column: 'lastUsedAt', + direction: 'desc' as const + } + }); + + return apiKeys; +}; diff --git a/frontend/src/routes/settings/admin/api-keys/+page.svelte b/frontend/src/routes/settings/admin/api-keys/+page.svelte new file mode 100644 index 00000000..783747f1 --- /dev/null +++ b/frontend/src/routes/settings/admin/api-keys/+page.svelte @@ -0,0 +1,89 @@ + + + + API Keys + + + + +
+
+ Create API Key + Add a new API key for programmatic access. +
+ {#if !expandAddApiKey} + + {:else} + + {/if} +
+
+ {#if expandAddApiKey} +
+ + + +
+ {/if} +
+ + + + Manage API Keys + + + + + + + diff --git a/frontend/src/routes/settings/admin/api-keys/api-key-dialog.svelte b/frontend/src/routes/settings/admin/api-keys/api-key-dialog.svelte new file mode 100644 index 00000000..b099ae92 --- /dev/null +++ b/frontend/src/routes/settings/admin/api-keys/api-key-dialog.svelte @@ -0,0 +1,46 @@ + + + + + + API Key Created + + For security reasons, this key will only be shown once. Please store it securely. + + + {#if apiKeyResponse} +
+
Name
+

{apiKeyResponse.apiKey.name}

+ + {#if apiKeyResponse.apiKey.description} +
Description
+

{apiKeyResponse.apiKey.description}

+ {/if} + +
API Key
+
+ + {apiKeyResponse.token} + +
+
+ {/if} + + + +
+
diff --git a/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte b/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte new file mode 100644 index 00000000..88f8ad5d --- /dev/null +++ b/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte @@ -0,0 +1,78 @@ + + +
+
+ + +
+ +
+
+
+ +
+
diff --git a/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte b/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte new file mode 100644 index 00000000..5cfd76e3 --- /dev/null +++ b/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte @@ -0,0 +1,89 @@ + + + (apiKeys = await apiKeyService.list(o))} + withoutSearch + defaultSort={{ column: 'lastUsedAt', direction: 'desc' }} + columns={[ + { label: 'Name', sortColumn: 'name' }, + { label: 'Description' }, + { label: 'Expires At', sortColumn: 'expiresAt' }, + { label: 'Last Used', sortColumn: 'lastUsedAt' }, + { label: 'Actions', hidden: true } + ]} +> + {#snippet rows({ item })} + {item.name} + {item.description || '-'} + {formatDate(item.expiresAt)} + {formatDate(item.lastUsedAt)} + + + + {/snippet} + diff --git a/frontend/tests/api-key.spec.ts b/frontend/tests/api-key.spec.ts new file mode 100644 index 00000000..5b69d015 --- /dev/null +++ b/frontend/tests/api-key.spec.ts @@ -0,0 +1,70 @@ +// frontend/tests/api-key.spec.ts +import { expect, test } from '@playwright/test'; +import { apiKeys } from './data'; +import { cleanupBackend } from './utils/cleanup.util'; + +test.describe('API Key Management', () => { + test.beforeEach(async ({ page }) => { + await cleanupBackend() + await page.goto('/settings/admin/api-keys'); + }); + + test('Create new API key', async ({ page }) => { + await page.getByRole('button', { name: 'Add API Key' }).click(); + + // Fill out the API key form + const name = 'New Test API Key'; + await page.getByLabel('Name').fill(name); + await page.getByLabel('Description').fill('Created by automated test'); + + // Choose the date + const currentDate = new Date(); + await page.getByLabel('Expires At').click(); + await page.getByLabel('Select year').click(); + // Select the next year + await page.getByText((currentDate.getFullYear() + 1).toString()).click(); + // Select the first day of the month + await page + .getByRole('button', { name: /([A-Z][a-z]+), ([A-Z][a-z]+) 1, (\d{4})/ }) + .first() + .click(); + + // Submit the form + await page.getByRole('button', { name: 'Save' }).click(); + + // Verify the success dialog appears + await expect(page.getByRole('heading', { name: 'API Key Created' })).toBeVisible(); + + // Verify the key details are shown + await expect(page.getByRole('cell', { name })).toBeVisible(); + + // Verify the token is displayed (should be 32 characters) + const token = await page.locator('.font-mono').textContent(); + expect(token?.length).toBe(32); + + // Close the dialog + await page.getByRole('button', { name: 'Close' }).click(); + + await page.reload(); + + // Verify the key appears in the list + await expect(page.getByRole('cell', { name }).first()).toContainText(name); + }); + + test('Revoke API key', async ({ page }) => { + const apiKey = apiKeys[0]; + + await page + .getByRole('row', { name: apiKey.name }) + .getByRole('button', { name: 'Revoke' }) + .click(); + + await page.getByText('Revoke', { exact: true }).click(); + + // Verify success message + await expect(page.getByRole('status')).toHaveText('API key revoked successfully'); + + // Verify key is no longer in the list + await expect(page.getByRole('cell', { name: apiKey.name })).not.toBeVisible(); + }); +}); diff --git a/frontend/tests/data.ts b/frontend/tests/data.ts index be70bdca..5beb1ccb 100644 --- a/frontend/tests/data.ts +++ b/frontend/tests/data.ts @@ -61,3 +61,11 @@ export const oneTimeAccessTokens = [ { token: 'HPe6k6uiDRRVuAQV', expired: false }, { token: 'YCGDtftvsvYWiXd0', expired: true } ]; + +export const apiKeys = [ + { + id: '5f1fa856-c164-4295-961e-175a0d22d725', + key: '6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20', + name: 'Test API Key' + } +];