mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-31 08:11:27 +02:00
refactor: standardize API error handling (#1635)
This commit is contained in:
@@ -64,4 +64,4 @@ formatters:
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- examples$
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
@@ -29,22 +30,20 @@ func newHandler(service *Service) *handler {
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[apiResponseDto]
|
||||
// @Router /api/apis [get]
|
||||
func (h *handler) list(c *gin.Context) {
|
||||
func (h *handler) list(c *gin.Context) error {
|
||||
search := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
apis, pagination, err := h.service.List(c.Request.Context(), search, listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
items := make([]apiResponseDto, len(apis))
|
||||
for i, api := range apis {
|
||||
var item apiResponseDto
|
||||
if err := dto.MapStruct(api, &item); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
item.Resource = api.Audience
|
||||
items[i] = item
|
||||
@@ -54,6 +53,7 @@ func (h *handler) list(c *gin.Context) {
|
||||
Data: items,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// get godoc
|
||||
@@ -64,14 +64,13 @@ func (h *handler) list(c *gin.Context) {
|
||||
// @Param id path string true "API ID"
|
||||
// @Success 200 {object} apiResponseDto
|
||||
// @Router /api/apis/{id} [get]
|
||||
func (h *handler) get(c *gin.Context) {
|
||||
func (h *handler) get(c *gin.Context) error {
|
||||
api, err := h.service.Get(c.Request.Context(), nil, c.Param("id"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
return h.respond(c, http.StatusOK, api)
|
||||
}
|
||||
|
||||
// create godoc
|
||||
@@ -83,20 +82,18 @@ func (h *handler) get(c *gin.Context) {
|
||||
// @Param api body apiCreateDto true "API information"
|
||||
// @Success 201 {object} apiResponseDto "Created API"
|
||||
// @Router /api/apis [post]
|
||||
func (h *handler) create(c *gin.Context) {
|
||||
func (h *handler) create(c *gin.Context) error {
|
||||
var input apiCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
api, err := h.service.Create(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusCreated, api)
|
||||
return h.respond(c, http.StatusCreated, api)
|
||||
}
|
||||
|
||||
// update godoc
|
||||
@@ -109,20 +106,18 @@ func (h *handler) create(c *gin.Context) {
|
||||
// @Param api body apiUpdateDto true "API information"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id} [put]
|
||||
func (h *handler) update(c *gin.Context) {
|
||||
func (h *handler) update(c *gin.Context) error {
|
||||
var input apiUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
api, err := h.service.Update(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
return h.respond(c, http.StatusOK, api)
|
||||
}
|
||||
|
||||
// delete godoc
|
||||
@@ -132,13 +127,13 @@ func (h *handler) update(c *gin.Context) {
|
||||
// @Param id path string true "API ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/apis/{id} [delete]
|
||||
func (h *handler) delete(c *gin.Context) {
|
||||
func (h *handler) delete(c *gin.Context) error {
|
||||
if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updatePermissions godoc
|
||||
@@ -151,20 +146,18 @@ func (h *handler) delete(c *gin.Context) {
|
||||
// @Param permissions body apiPermissionsUpdateDto true "Permissions to set"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id}/permissions [put]
|
||||
func (h *handler) updatePermissions(c *gin.Context) {
|
||||
func (h *handler) updatePermissions(c *gin.Context) error {
|
||||
var input apiPermissionsUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
api, err := h.service.UpdatePermissions(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
return h.respond(c, http.StatusOK, api)
|
||||
}
|
||||
|
||||
// getClientAccess godoc
|
||||
@@ -175,14 +168,14 @@ func (h *handler) updatePermissions(c *gin.Context) {
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Success 200 {object} clientApiAccessDto
|
||||
// @Router /api/api-access/{clientId} [get]
|
||||
func (h *handler) getClientAccess(c *gin.Context) {
|
||||
func (h *handler) getClientAccess(c *gin.Context) error {
|
||||
access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(access))
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateClientAccess godoc
|
||||
@@ -195,21 +188,20 @@ func (h *handler) getClientAccess(c *gin.Context) {
|
||||
// @Param access body clientApiAccessUpdateDto true "Allowed permission IDs per subject type"
|
||||
// @Success 200 {object} clientApiAccessDto
|
||||
// @Router /api/api-access/{clientId} [put]
|
||||
func (h *handler) updateClientAccess(c *gin.Context) {
|
||||
func (h *handler) updateClientAccess(c *gin.Context) error {
|
||||
var input clientApiAccessUpdateDto
|
||||
err := c.ShouldBindJSON(&input)
|
||||
err := httpserver.BindJSON(c, &input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(applied))
|
||||
return nil
|
||||
}
|
||||
|
||||
// newClientApiAccessDto always serializes both permission lists as arrays rather than null
|
||||
@@ -224,12 +216,12 @@ func newClientApiAccessDto(access ClientAPIAccess) clientApiAccessDto {
|
||||
return dto
|
||||
}
|
||||
|
||||
func (h *handler) respond(c *gin.Context, status int, api API) {
|
||||
func (h *handler) respond(c *gin.Context, status int, api API) error {
|
||||
var responseDto apiResponseDto
|
||||
if err := dto.MapStruct(api, &responseDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
responseDto.Resource = api.Audience
|
||||
c.JSON(status, responseDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
)
|
||||
|
||||
@@ -63,16 +64,16 @@ func (m *Module) DescribePermissions(ctx context.Context, audience string, keys
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth gin.HandlerFunc) {
|
||||
apis := apiGroup.Group("/apis")
|
||||
apis.Use(adminAuth)
|
||||
apis.GET("", m.handler.list)
|
||||
apis.POST("", m.handler.create)
|
||||
apis.GET("/:id", m.handler.get)
|
||||
apis.PUT("/:id", m.handler.update)
|
||||
apis.DELETE("/:id", m.handler.delete)
|
||||
apis.PUT("/:id/permissions", m.handler.updatePermissions)
|
||||
apis.GET("", httpserver.Handle(m.handler.list))
|
||||
apis.POST("", httpserver.Handle(m.handler.create))
|
||||
apis.GET("/:id", httpserver.Handle(m.handler.get))
|
||||
apis.PUT("/:id", httpserver.Handle(m.handler.update))
|
||||
apis.DELETE("/:id", httpserver.Handle(m.handler.delete))
|
||||
apis.PUT("/:id/permissions", httpserver.Handle(m.handler.updatePermissions))
|
||||
|
||||
// The per-client API-access allow-list lives on a separate path so it does not collide with the /apis/:id wildcard
|
||||
access := apiGroup.Group("/api-access")
|
||||
access.Use(adminAuth)
|
||||
access.GET("/:clientId", m.handler.getClientAccess)
|
||||
access.PUT("/:clientId", m.handler.updateClientAccess)
|
||||
access.GET("/:clientId", httpserver.Handle(m.handler.getClientAccess))
|
||||
access.PUT("/:clientId", httpserver.Handle(m.handler.updateClientAccess))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
@@ -81,13 +81,16 @@ func (s *Service) Get(ctx context.Context, tx *gorm.DB, id string) (api API, err
|
||||
Where("id = ?", id).
|
||||
First(&api).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return API{}, apperror.NotFound("API")
|
||||
}
|
||||
return api, err
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, input apiCreateDto) (api API, err error) {
|
||||
// Reject the issuer as an audience so a custom API cannot impersonate Pocket ID's own identity tokens
|
||||
if isIssuerAudience(input.Resource, s.issuer) {
|
||||
return API{}, &common.ValidationError{Message: "the resource is reserved by Pocket ID and cannot be used for a custom API"}
|
||||
return API{}, apperror.InvalidField("resource", "reserved", "is reserved by Pocket ID and cannot be used for a custom API")
|
||||
}
|
||||
|
||||
api = API{
|
||||
@@ -98,7 +101,7 @@ func (s *Service) Create(ctx context.Context, input apiCreateDto) (api API, err
|
||||
err = s.db.WithContext(ctx).Create(&api).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return API{}, &common.AlreadyInUseError{Property: "resource"}
|
||||
return API{}, apperror.AlreadyInUse("resource")
|
||||
}
|
||||
return API{}, err
|
||||
}
|
||||
@@ -170,16 +173,17 @@ func (s *Service) UpdatePermissions(ctx context.Context, id string, input apiPer
|
||||
// Reject keys with invalid characters, that collide with Pocket ID's reserved scopes and claims, or that repeat within the request before persisting anything
|
||||
// A duplicate key would otherwise be silently coalesced last-wins into the map below, dropping a row behind a 200
|
||||
seen := make(map[string]struct{}, len(input.Permissions))
|
||||
for _, permission := range input.Permissions {
|
||||
for index, permission := range input.Permissions {
|
||||
field := fmt.Sprintf("permissions[%d].key", index)
|
||||
if !isValidPermissionKey(permission.Key) {
|
||||
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q contains invalid characters", permission.Key)}
|
||||
return API{}, apperror.InvalidField(field, "invalid_format", "contains characters that are not valid in an OAuth scope")
|
||||
}
|
||||
if isPermissionKeyReserved(permission.Key) {
|
||||
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is reserved by Pocket ID", permission.Key)}
|
||||
return API{}, apperror.InvalidField(field, "reserved", "is reserved by Pocket ID")
|
||||
}
|
||||
_, ok := seen[permission.Key]
|
||||
if ok {
|
||||
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is listed more than once", permission.Key)}
|
||||
return API{}, apperror.InvalidField(field, "duplicate", "is listed more than once")
|
||||
}
|
||||
seen[permission.Key] = struct{}{}
|
||||
}
|
||||
@@ -261,8 +265,17 @@ type ClientAPIAccess struct {
|
||||
// GetClientAPIAccess returns the API permissions a client is allowed to request, split by subject type
|
||||
// Only custom-API permissions are tracked here because the identity scopes are freely requestable by every client
|
||||
func (s *Service) GetClientAPIAccess(ctx context.Context, clientID string) (access ClientAPIAccess, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
if err := ensureOIDCClientExists(ctx, tx, clientID); err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
var rows []OidcClientAllowedAPIPermission
|
||||
err = s.db.WithContext(ctx).
|
||||
err = tx.WithContext(ctx).
|
||||
Where("oidc_client_id = ?", clientID).
|
||||
Find(&rows).
|
||||
Error
|
||||
@@ -293,9 +306,7 @@ func (s *Service) SetClientAPIAccess(ctx context.Context, clientID string, acces
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
// Ensure the client exists so callers get a 404 for an unknown client
|
||||
var client model.OidcClient
|
||||
if err = tx.WithContext(ctx).Select("id").Where("id = ?", clientID).First(&client).Error; err != nil {
|
||||
if err = ensureOIDCClientExists(ctx, tx, clientID); err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
@@ -337,6 +348,20 @@ func (s *Service) SetClientAPIAccess(ctx context.Context, clientID string, acces
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func ensureOIDCClientExists(ctx context.Context, db *gorm.DB, clientID string) error {
|
||||
var client model.OidcClient
|
||||
err := db.WithContext(ctx).
|
||||
Select("id").
|
||||
Where("id = ?", clientID).
|
||||
First(&client).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperror.NotFound("OIDC client")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ClientAPIScopesAndAudiences returns the permission keys a client may request and the distinct audiences of the custom APIs those permissions belong to, across both subject types
|
||||
// The OIDC module uses this to widen fosite's scope and audience validation for the client; the per-flow subject-type enforcement happens when the resource is resolved
|
||||
func (s *Service) ClientAPIScopesAndAudiences(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
@@ -22,7 +22,7 @@ func TestAPICrudAndPermissionDiff(t *testing.T) {
|
||||
|
||||
// The resource is unique.
|
||||
_, err = svc.Create(t.Context(), apiCreateDto{Name: "Dup", Resource: "https://api.orders.example.com"})
|
||||
require.ErrorIs(t, err, &common.AlreadyInUseError{})
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeAlreadyInUse))
|
||||
|
||||
desc := "Read orders"
|
||||
updated, err := svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
@@ -59,7 +59,7 @@ func TestAPICrudAndPermissionDiff(t *testing.T) {
|
||||
|
||||
require.NoError(t, svc.Delete(t.Context(), created.ID))
|
||||
_, err = svc.Get(t.Context(), nil, created.ID)
|
||||
require.Error(t, err)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
}
|
||||
|
||||
func TestClientApiAccessAllowList(t *testing.T) {
|
||||
@@ -121,7 +121,10 @@ func TestClientApiAccessAllowList(t *testing.T) {
|
||||
|
||||
// An unknown client is rejected (surfaces as 404 at the HTTP layer).
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "nope", ClientAPIAccess{UserDelegatedPermissionIDs: []string{readID}})
|
||||
require.Error(t, err)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
|
||||
_, err = svc.GetClientAPIAccess(t.Context(), "nope")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
}
|
||||
|
||||
// TestAllowedScopesForAudienceFiltersBySubjectType guards that the scopes resolved for a flow
|
||||
@@ -177,8 +180,7 @@ func TestUpdatePermissionsRejectsReservedKeys(t *testing.T) {
|
||||
{Key: key, Name: "Reserved"},
|
||||
}})
|
||||
require.Error(t, err, "key %q must be rejected", key)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +197,7 @@ func TestUpdatePermissionsRejectsDuplicateKeys(t *testing.T) {
|
||||
{Key: "read:orders", Name: "Read again"},
|
||||
}})
|
||||
require.Error(t, err)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
|
||||
}
|
||||
|
||||
func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) {
|
||||
@@ -212,8 +213,7 @@ func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) {
|
||||
{Key: key, Name: "Invalid"},
|
||||
}})
|
||||
require.Error(t, err, "key %q must be rejected", key)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
|
||||
}
|
||||
|
||||
// A valid scope-token key is accepted
|
||||
@@ -232,8 +232,7 @@ func TestCreateRejectsIssuerResource(t *testing.T) {
|
||||
for _, resource := range []string{issuer, issuer + "/", "https://ID.example.com"} {
|
||||
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Reserved", Resource: resource})
|
||||
require.Error(t, err, "resource %q must be rejected", resource)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
|
||||
}
|
||||
|
||||
// A normal resource is accepted
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
@@ -27,27 +28,26 @@ func newHandler(service *Service) *handler {
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[apiKeyDto]
|
||||
// @Router /api/api-keys [get]
|
||||
func (h *handler) list(c *gin.Context) {
|
||||
func (h *handler) list(c *gin.Context) error {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
apiKeys, pagination, err := h.service.ListApiKeys(c.Request.Context(), userID, listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var apiKeysDto []apiKeyDto
|
||||
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[apiKeyDto]{
|
||||
Data: apiKeysDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// create godoc
|
||||
@@ -57,31 +57,29 @@ func (h *handler) list(c *gin.Context) {
|
||||
// @Param api_key body apiKeyCreateDto true "API key information"
|
||||
// @Success 201 {object} apiKeyResponseDto "Created API key with token"
|
||||
// @Router /api/api-keys [post]
|
||||
func (h *handler) create(c *gin.Context) {
|
||||
func (h *handler) create(c *gin.Context) error {
|
||||
userID := c.GetString("userID")
|
||||
|
||||
var input apiKeyCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiKey, token, err := h.service.CreateApiKey(c.Request.Context(), userID, input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var responseDto apiKeyDto
|
||||
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, apiKeyResponseDto{
|
||||
ApiKey: responseDto,
|
||||
Token: token,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// renew godoc
|
||||
@@ -91,32 +89,30 @@ func (h *handler) create(c *gin.Context) {
|
||||
// @Param id path string true "API Key ID"
|
||||
// @Success 200 {object} apiKeyResponseDto "Renewed API key with new token"
|
||||
// @Router /api/api-keys/{id}/renew [post]
|
||||
func (h *handler) renew(c *gin.Context) {
|
||||
func (h *handler) renew(c *gin.Context) error {
|
||||
userID := c.GetString("userID")
|
||||
apiKeyID := c.Param("id")
|
||||
|
||||
var input apiKeyRenewDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiKey, token, err := h.service.RenewApiKey(c.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var responseDto apiKeyDto
|
||||
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, apiKeyResponseDto{
|
||||
ApiKey: responseDto,
|
||||
Token: token,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// revoke godoc
|
||||
@@ -126,14 +122,14 @@ func (h *handler) renew(c *gin.Context) {
|
||||
// @Param id path string true "API Key ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/api-keys/{id} [delete]
|
||||
func (h *handler) revoke(c *gin.Context) {
|
||||
func (h *handler) revoke(c *gin.Context) error {
|
||||
userID := c.GetString("userID")
|
||||
apiKeyID := c.Param("id")
|
||||
|
||||
if err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
@@ -35,10 +36,10 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
|
||||
// authWithoutApiKey disables API key authentication so an API key cannot be used to mint or renew further API keys
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, authWithoutApiKey gin.HandlerFunc) {
|
||||
group := apiGroup.Group("/api-keys")
|
||||
group.GET("", auth, m.handler.list)
|
||||
group.POST("", authWithoutApiKey, m.handler.create)
|
||||
group.POST("/:id/renew", authWithoutApiKey, m.handler.renew)
|
||||
group.DELETE("/:id", auth, m.handler.revoke)
|
||||
group.GET("", auth, httpserver.Handle(m.handler.list))
|
||||
group.POST("", authWithoutApiKey, httpserver.Handle(m.handler.create))
|
||||
group.POST("/:id/renew", authWithoutApiKey, httpserver.Handle(m.handler.renew))
|
||||
group.DELETE("/:id", auth, httpserver.Handle(m.handler.revoke))
|
||||
}
|
||||
|
||||
// ValidateApiKey resolves the user that owns the given raw API key
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -54,7 +55,7 @@ func (s *Service) ListApiKeys(ctx context.Context, userID string, listRequestOpt
|
||||
func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyCreateDto) (ApiKey, string, error) {
|
||||
// Check if expiration is in the future
|
||||
if !input.ExpiresAt.ToTime().After(time.Now()) {
|
||||
return ApiKey{}, "", &common.APIKeyExpirationDateError{}
|
||||
return ApiKey{}, "", apperror.InvalidAPIKeyExpiration()
|
||||
}
|
||||
|
||||
// Generate a secure random API key
|
||||
@@ -77,7 +78,7 @@ func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyC
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return ApiKey{}, "", &common.AlreadyInUseError{Property: "API key name"}
|
||||
return ApiKey{}, "", apperror.AlreadyInUse("API key name")
|
||||
}
|
||||
return ApiKey{}, "", err
|
||||
}
|
||||
@@ -89,7 +90,7 @@ func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyC
|
||||
func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (ApiKey, string, error) {
|
||||
// Check if expiration is in the future
|
||||
if !expiration.After(time.Now()) {
|
||||
return ApiKey{}, "", &common.APIKeyExpirationDateError{}
|
||||
return ApiKey{}, "", apperror.InvalidAPIKeyExpiration()
|
||||
}
|
||||
|
||||
tx := s.db.Begin()
|
||||
@@ -105,14 +106,14 @@ func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expi
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ApiKey{}, "", &common.APIKeyNotFoundError{}
|
||||
return ApiKey{}, "", apperror.APIKeyNotFound()
|
||||
}
|
||||
return ApiKey{}, "", err
|
||||
}
|
||||
|
||||
// Only allow renewal if the key has already expired
|
||||
if apiKey.ExpiresAt.ToTime().After(time.Now()) {
|
||||
return ApiKey{}, "", &common.APIKeyNotExpiredError{}
|
||||
return ApiKey{}, "", apperror.APIKeyNotExpired()
|
||||
}
|
||||
|
||||
// Generate a secure random API key
|
||||
@@ -138,16 +139,15 @@ func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expi
|
||||
|
||||
func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
|
||||
var apiKey ApiKey
|
||||
err := s.db.
|
||||
result := s.db.
|
||||
WithContext(ctx).
|
||||
Where("id = ? AND user_id = ?", apiKeyID, userID).
|
||||
Delete(&apiKey).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &common.APIKeyNotFoundError{}
|
||||
}
|
||||
return err
|
||||
Delete(&apiKey)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return apperror.APIKeyNotFound()
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -155,7 +155,7 @@ func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) err
|
||||
|
||||
func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
|
||||
if apiKey == "" {
|
||||
return model.User{}, &common.NoAPIKeyProvidedError{}
|
||||
return model.User{}, apperror.NoAPIKeyProvided()
|
||||
}
|
||||
|
||||
if s.staticApiKey != "" && apiKey == s.staticApiKey {
|
||||
@@ -179,7 +179,7 @@ func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, &common.InvalidAPIKeyError{}
|
||||
return model.User{}, apperror.InvalidAPIKey()
|
||||
}
|
||||
|
||||
return model.User{}, err
|
||||
|
||||
21
backend/internal/apikey/service_test.go
Normal file
21
backend/internal/apikey/service_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMissingManagedAPIKeyReturnsNotFound(t *testing.T) {
|
||||
service, err := newService(t.Context(), testutils.NewDatabaseForTest(t), "")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.RevokeApiKey(t.Context(), "user-id", "missing-key")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeAPIKeyNotFound))
|
||||
|
||||
_, _, err = service.RenewApiKey(t.Context(), "user-id", "missing-key", time.Now().Add(time.Hour))
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeAPIKeyNotFound))
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"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/tracing"
|
||||
@@ -117,18 +118,18 @@ func (s *AppConfigService) GetCIMDURLAllowlist() []string {
|
||||
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]AppConfigVariable, error) {
|
||||
// If the UI config is disabled, we cannot continue
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return nil, &common.UiConfigDisabledError{}
|
||||
return nil, apperror.UIConfigDisabled()
|
||||
}
|
||||
|
||||
// Validate the CIMD URL allowlist patterns, if provided
|
||||
if input.CIMDURLAllowlist != "" {
|
||||
var patterns []string
|
||||
if err := json.Unmarshal([]byte(input.CIMDURLAllowlist), &patterns); err != nil {
|
||||
return nil, &common.InvalidCIMDURLPatternError{Pattern: input.CIMDURLAllowlist}
|
||||
return nil, apperror.InvalidCIMDURLPattern(input.CIMDURLAllowlist)
|
||||
}
|
||||
for _, p := range patterns {
|
||||
if err := utils.ValidateCallbackURLPattern(p); err != nil {
|
||||
return nil, &common.InvalidCIMDURLPatternError{Pattern: p}
|
||||
return nil, apperror.InvalidCIMDURLPattern(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,7 +155,7 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal
|
||||
|
||||
// If the UI config is disabled, we cannot continue
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return &common.UiConfigDisabledError{}
|
||||
return apperror.UIConfigDisabled()
|
||||
}
|
||||
|
||||
// Collect the key-value pairs into a map for the actor
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"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"
|
||||
@@ -199,14 +200,13 @@ func TestService_UpdateAppConfig(t *testing.T) {
|
||||
assert.Equal(t, getDefaultConfig().SmtpTls, cfg.SmtpTls)
|
||||
})
|
||||
|
||||
t.Run("returns UiConfigDisabledError when the UI config is disabled", func(t *testing.T) {
|
||||
t.Run("returns a UI-config-disabled error when the UI config is disabled", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, true)
|
||||
svc := NewTestAppConfigService(nil)
|
||||
|
||||
_, err := svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{AppName: "X"})
|
||||
require.Error(t, err)
|
||||
var target *common.UiConfigDisabledError
|
||||
assert.ErrorAs(t, err, &target)
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeUIConfigDisabled))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -269,15 +269,14 @@ func TestService_UpdateAppConfigValues(t *testing.T) {
|
||||
assert.Equal(t, *getDefaultConfig(), *cfg)
|
||||
})
|
||||
|
||||
t.Run("returns UiConfigDisabledError when the UI config is disabled", func(t *testing.T) {
|
||||
t.Run("returns a UI-config-disabled error when the UI config is disabled", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, true)
|
||||
svc := NewTestAppConfigService(nil)
|
||||
|
||||
// An even number of arguments so the count check passes and we reach the UI-config check
|
||||
err := svc.UpdateAppConfigValues(t.Context(), "appName", "X")
|
||||
require.Error(t, err)
|
||||
var target *common.UiConfigDisabledError
|
||||
assert.ErrorAs(t, err, &target)
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeUIConfigDisabled))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
289
backend/internal/apperror/constructors.go
Normal file
289
backend/internal/apperror/constructors.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package apperror
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// The constructors in this file keep public messages, statuses, and details in one place
|
||||
|
||||
func AlreadyInUse(property string) *Error {
|
||||
return New(CodeAlreadyInUse, http.StatusConflict, property+" is already in use").WithDetail("property", property)
|
||||
}
|
||||
|
||||
func NotFound(resource string) *Error {
|
||||
return New(CodeNotFound, http.StatusNotFound, resource+" not found").WithDetail("resource", resource)
|
||||
}
|
||||
|
||||
func InvalidRequestBody(cause error) *Error {
|
||||
return Wrap(cause, CodeInvalidRequestBody, http.StatusBadRequest, "Request body is invalid")
|
||||
}
|
||||
|
||||
func MissingField(field string) *Error {
|
||||
return InvalidField(field, "required", "is required")
|
||||
}
|
||||
|
||||
func InvalidField(field, code, message string) *Error {
|
||||
return New(CodeValidationFailed, http.StatusBadRequest, fmt.Sprintf("%s %s", field, message)).WithFields([]FieldError{{
|
||||
Field: field,
|
||||
Code: code,
|
||||
Message: message,
|
||||
}})
|
||||
}
|
||||
|
||||
func SetupNotAvailable() *Error {
|
||||
return New(CodeSetupNotAvailable, http.StatusNotFound, "Not found")
|
||||
}
|
||||
|
||||
func SetupAlreadyCompleted() *Error {
|
||||
return New(CodeSetupAlreadyCompleted, http.StatusConflict, "Initial setup has already been completed")
|
||||
}
|
||||
|
||||
func TokenInvalidOrExpired() *Error {
|
||||
return New(CodeTokenInvalidOrExpired, http.StatusUnauthorized, "Token is invalid or expired")
|
||||
}
|
||||
|
||||
func DeviceCodeInvalid() *Error {
|
||||
return New(CodeDeviceCodeInvalid, http.StatusUnauthorized, "One-time access code must be used on the device it was generated for")
|
||||
}
|
||||
|
||||
func TokenInvalid() *Error {
|
||||
return New(CodeInvalidToken, http.StatusUnauthorized, "Token is invalid")
|
||||
}
|
||||
|
||||
func OidcMissingAuthorization() *Error {
|
||||
return New(CodeOidcMissingAuthorization, http.StatusForbidden, "Authorization is missing")
|
||||
}
|
||||
|
||||
func OidcInvalidCallbackURL() *Error {
|
||||
return New(CodeOidcInvalidCallbackURL, http.StatusBadRequest, "Callback URL is invalid and may need to be corrected by an administrator")
|
||||
}
|
||||
|
||||
func InvalidCIMDURLPattern(pattern string) *Error {
|
||||
return New(CodeValidationFailed, http.StatusBadRequest, "Metadata document URL pattern is invalid").
|
||||
WithDetail("pattern", pattern).
|
||||
WithFields([]FieldError{{
|
||||
Field: "cimdUrlAllowlist",
|
||||
Code: "invalid_value",
|
||||
Message: "contains an invalid URL pattern",
|
||||
}})
|
||||
}
|
||||
|
||||
func UnsupportedFileType(expected string) *Error {
|
||||
if expected == "" {
|
||||
return New(CodeFileTypeNotSupported, http.StatusUnsupportedMediaType, "File type is not supported")
|
||||
}
|
||||
|
||||
return New(CodeFileTypeNotSupported, http.StatusUnsupportedMediaType, "File must be of type "+expected).
|
||||
WithDetail("expected_file_type", expected)
|
||||
}
|
||||
|
||||
func FileTooLarge(maxSize string) *Error {
|
||||
return New(CodeFileTooLarge, http.StatusRequestEntityTooLarge, fmt.Sprintf("File must not exceed %s", maxSize)).WithDetail("max_size", maxSize)
|
||||
}
|
||||
|
||||
func NotSignedIn() *Error {
|
||||
return New(CodeNotSignedIn, http.StatusUnauthorized, "You are not signed in")
|
||||
}
|
||||
|
||||
func MissingPermission() *Error {
|
||||
return New(CodeForbidden, http.StatusForbidden, "You don't have permission to perform this action")
|
||||
}
|
||||
|
||||
func TooManyRequests() *Error {
|
||||
return New(CodeRateLimited, http.StatusTooManyRequests, "Too many requests")
|
||||
}
|
||||
|
||||
func UserNotFound() *Error {
|
||||
return New(CodeUserNotFound, http.StatusNotFound, "User not found")
|
||||
}
|
||||
|
||||
func InvalidImage(cause error) *Error {
|
||||
return Wrap(cause, CodeInvalidImage, http.StatusBadRequest, "File is not a valid image")
|
||||
}
|
||||
|
||||
func MissingSessionID() *Error {
|
||||
return New(CodeMissingSessionID, http.StatusBadRequest, "Session ID is missing")
|
||||
}
|
||||
|
||||
func InvalidWebAuthnSession() *Error {
|
||||
return New(CodeInvalidWebAuthnSession, http.StatusBadRequest, "Your passkey request has expired")
|
||||
}
|
||||
|
||||
func InvalidWebAuthnResponse(cause error) *Error {
|
||||
return Wrap(cause, CodeInvalidWebAuthnResponse, http.StatusBadRequest, "We couldn't process the response from your passkey")
|
||||
}
|
||||
|
||||
func WebAuthnAuthenticationFailed(cause error) *Error {
|
||||
return Wrap(cause, CodeWebAuthnAuthenticationFailed, http.StatusUnauthorized, "We couldn't verify your passkey")
|
||||
}
|
||||
|
||||
func PasskeyUserVerificationRequired(cause error) *Error {
|
||||
return Wrap(cause, CodePasskeyUserVerificationRequired, http.StatusBadRequest, "Your passkey couldn't verify you. If you're using a security key, configure a FIDO2 PIN and try again")
|
||||
}
|
||||
|
||||
func ReservedClaim(key string) *Error {
|
||||
return New(CodeReservedClaim, http.StatusBadRequest, fmt.Sprintf("Claim %s is reserved and can't be used", key)).
|
||||
WithDetail("key", key).
|
||||
WithFields([]FieldError{{
|
||||
Field: "key",
|
||||
Code: "reserved",
|
||||
Message: "is reserved",
|
||||
}})
|
||||
}
|
||||
|
||||
func DuplicateClaim(key string) *Error {
|
||||
return New(CodeDuplicateClaim, http.StatusBadRequest, fmt.Sprintf("Claim %s is already defined", key)).
|
||||
WithDetail("key", key).
|
||||
WithFields([]FieldError{{
|
||||
Field: "key",
|
||||
Code: "duplicate",
|
||||
Message: "is listed more than once",
|
||||
}})
|
||||
}
|
||||
|
||||
func LdapDisabled() *Error {
|
||||
return New(CodeLdapDisabled, http.StatusConflict, "LDAP is not enabled")
|
||||
}
|
||||
|
||||
func LdapUserUpdate() *Error {
|
||||
return New(CodeLdapUserUpdate, http.StatusForbidden, "LDAP users can't be updated")
|
||||
}
|
||||
|
||||
func LdapUserGroupUpdate() *Error {
|
||||
return New(CodeLdapUserGroupUpdate, http.StatusForbidden, "LDAP user groups can't be updated")
|
||||
}
|
||||
|
||||
func OidcAccessDenied() *Error {
|
||||
return New(CodeOidcAccessDenied, http.StatusForbidden, "You're not allowed to access this service")
|
||||
}
|
||||
|
||||
func OidcInteractionNotFound() *Error {
|
||||
return New(CodeNotFound, http.StatusNotFound, "OIDC interaction not found or expired").
|
||||
WithDetail("resource", "OIDC interaction")
|
||||
}
|
||||
|
||||
func OidcClientIDNotMatching() *Error {
|
||||
return New(CodeOidcClientIDNotMatching, http.StatusBadRequest, "Client ID in request doesn't match client ID in token")
|
||||
}
|
||||
|
||||
func UIConfigDisabled() *Error {
|
||||
return New(CodeUIConfigDisabled, http.StatusForbidden, "The configuration can't be changed since the UI configuration is disabled")
|
||||
}
|
||||
|
||||
func InvalidUserID() *Error {
|
||||
return Validation([]FieldError{{
|
||||
Field: "userId",
|
||||
Code: "invalid_format",
|
||||
Message: "must be a valid UUID",
|
||||
}})
|
||||
}
|
||||
|
||||
func OneTimeAccessDisabled() *Error {
|
||||
return New(CodeOneTimeAccessDisabled, http.StatusForbidden, "One-time access is disabled")
|
||||
}
|
||||
|
||||
func DeviceLoginRequestInvalidOrExpired() *Error {
|
||||
return New(CodeDeviceLoginExpired, http.StatusUnauthorized, "Device login request is invalid or expired")
|
||||
}
|
||||
|
||||
func DeviceLoginDenied() *Error {
|
||||
return New(CodeDeviceLoginDenied, http.StatusForbidden, "Device login request was denied")
|
||||
}
|
||||
|
||||
func InvalidAPIKey() *Error {
|
||||
return New(CodeInvalidAPIKey, http.StatusUnauthorized, "API key is invalid")
|
||||
}
|
||||
|
||||
func NoAPIKeyProvided() *Error {
|
||||
return New(CodeNoAPIKeyProvided, http.StatusUnauthorized, "API key is missing")
|
||||
}
|
||||
|
||||
func APIKeyNotFound() *Error {
|
||||
return New(CodeAPIKeyNotFound, http.StatusNotFound, "API key not found")
|
||||
}
|
||||
|
||||
func APIKeyNotExpired() *Error {
|
||||
return New(CodeAPIKeyNotExpired, http.StatusConflict, "API key is not expired yet")
|
||||
}
|
||||
|
||||
func InvalidAPIKeyExpiration() *Error {
|
||||
return New(CodeInvalidAPIKeyExpiration, http.StatusBadRequest, "API key expiration time must be in the future").WithFields([]FieldError{{
|
||||
Field: "expiresAt",
|
||||
Code: "invalid_value",
|
||||
Message: "must be in the future",
|
||||
}})
|
||||
}
|
||||
|
||||
func APIKeyAuthNotAllowed() *Error {
|
||||
return New(CodeAPIKeyAuthNotAllowed, http.StatusForbidden, "API key authentication is not allowed for this endpoint")
|
||||
}
|
||||
|
||||
func UserDisabled() *Error {
|
||||
return New(CodeUserDisabled, http.StatusForbidden, "User account is disabled")
|
||||
}
|
||||
|
||||
func ValidationMessage(message string) *Error {
|
||||
return New(CodeValidationFailed, http.StatusBadRequest, message)
|
||||
}
|
||||
|
||||
func OidcDeviceCodeExpired() *Error {
|
||||
return New(CodeOidcDeviceCodeExpired, http.StatusBadRequest, "Device code has expired")
|
||||
}
|
||||
|
||||
func OidcInvalidDeviceCode() *Error {
|
||||
return New(CodeOidcInvalidDeviceCode, http.StatusBadRequest, "Device code is invalid")
|
||||
}
|
||||
|
||||
func ReauthenticationRequired() *Error {
|
||||
return New(CodeReauthenticationRequired, http.StatusUnauthorized, "Reauthentication is required")
|
||||
}
|
||||
|
||||
func ReauthenticationRequiredWithCause(cause error) *Error {
|
||||
return Wrap(cause, CodeReauthenticationRequired, http.StatusUnauthorized, "Reauthentication is required")
|
||||
}
|
||||
|
||||
func OpenSignupDisabled() *Error {
|
||||
return New(CodeOpenSignupDisabled, http.StatusForbidden, "Open user signup is not enabled")
|
||||
}
|
||||
|
||||
func ClientIDAlreadyExists() *Error {
|
||||
return New(CodeClientIDAlreadyExists, http.StatusConflict, "Client ID is already in use")
|
||||
}
|
||||
|
||||
func UserEmailNotSet() *Error {
|
||||
return New(CodeUserEmailNotSet, http.StatusConflict, "The user does not have an email address set")
|
||||
}
|
||||
|
||||
func ImageNotFound() *Error {
|
||||
return New(CodeImageNotFound, http.StatusNotFound, "Image not found")
|
||||
}
|
||||
|
||||
func LogoDownloadFailed(cause error) *Error {
|
||||
return Wrap(cause, CodeLogoDownloadFailed, http.StatusUnprocessableEntity, "Logo could not be downloaded")
|
||||
}
|
||||
|
||||
func LogoTypeNotSupported() *Error {
|
||||
return New(CodeLogoTypeNotSupported, http.StatusUnprocessableEntity, "Downloaded logo has an unsupported file type")
|
||||
}
|
||||
|
||||
func LogoTooLarge(maxSize string) *Error {
|
||||
return New(CodeLogoTooLarge, http.StatusUnprocessableEntity, fmt.Sprintf("Downloaded logo must not exceed %s", maxSize)).
|
||||
WithDetail("max_size", maxSize)
|
||||
}
|
||||
|
||||
func InvalidLogoURL(cause error) *Error {
|
||||
return Wrap(cause, CodeValidationFailed, http.StatusBadRequest, "Logo URL is not allowed").WithFields([]FieldError{{
|
||||
Field: "logoUrl",
|
||||
Code: "invalid_value",
|
||||
Message: "must be a public HTTP or HTTPS URL",
|
||||
}})
|
||||
}
|
||||
|
||||
func OidcPARRequired() *Error {
|
||||
return New(CodeOidcPARRequired, http.StatusBadRequest, "This client requires pushed authorization requests")
|
||||
}
|
||||
|
||||
func InvalidEmailVerificationToken() *Error {
|
||||
return New(CodeEmailVerificationTokenInvalid, http.StatusBadRequest, "Email verification token is invalid")
|
||||
}
|
||||
234
backend/internal/apperror/error.go
Normal file
234
backend/internal/apperror/error.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package apperror
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Code string
|
||||
|
||||
// #nosec G101 -- these are client-visible error codes, not credentials
|
||||
const (
|
||||
CodeInternal Code = "internal_error"
|
||||
CodeValidationFailed Code = "validation_failed"
|
||||
CodeInvalidRequestBody Code = "invalid_request_body"
|
||||
CodeRequestTimeout Code = "request_timeout"
|
||||
CodeNotFound Code = "not_found"
|
||||
CodeAlreadyInUse Code = "already_in_use"
|
||||
CodeForbidden Code = "forbidden"
|
||||
CodeInvalidToken Code = "invalid_token"
|
||||
CodeRateLimited Code = "rate_limited"
|
||||
CodeFileTooLarge Code = "file_too_large"
|
||||
CodeInvalidImage Code = "invalid_image"
|
||||
CodeInvalidWebAuthnResponse Code = "invalid_webauthn_response"
|
||||
CodeWebAuthnAuthenticationFailed Code = "webauthn_authentication_failed"
|
||||
CodePasskeyUserVerificationRequired Code = "passkey_user_verification_required"
|
||||
CodeInvalidWebAuthnSession Code = "invalid_webauthn_session"
|
||||
CodeUserNotFound Code = "user_not_found"
|
||||
CodeUserDisabled Code = "user_disabled"
|
||||
CodeAPIKeyNotFound Code = "api_key_not_found"
|
||||
CodeInvalidAPIKey Code = "invalid_api_key"
|
||||
CodeDeviceLoginExpired Code = "device_login_expired"
|
||||
CodeReauthenticationRequired Code = "reauthentication_required"
|
||||
CodeEmailVerificationTokenInvalid Code = "invalid_email_verification_token"
|
||||
CodeSetupNotAvailable Code = "setup_not_available"
|
||||
CodeSetupAlreadyCompleted Code = "setup_already_completed"
|
||||
CodeTokenInvalidOrExpired Code = "token_invalid_or_expired"
|
||||
CodeDeviceCodeInvalid Code = "device_code_invalid"
|
||||
CodeOidcMissingAuthorization Code = "oidc_missing_authorization"
|
||||
CodeOidcInvalidCallbackURL Code = "oidc_invalid_callback_url"
|
||||
CodeFileTypeNotSupported Code = "file_type_not_supported"
|
||||
CodeNotSignedIn Code = "not_signed_in"
|
||||
CodeMissingSessionID Code = "missing_session_id"
|
||||
CodeReservedClaim Code = "reserved_claim"
|
||||
CodeDuplicateClaim Code = "duplicate_claim"
|
||||
CodeLdapDisabled Code = "ldap_disabled"
|
||||
CodeLdapUserUpdate Code = "ldap_user_update"
|
||||
CodeLdapUserGroupUpdate Code = "ldap_user_group_update"
|
||||
CodeOidcAccessDenied Code = "oidc_access_denied"
|
||||
CodeOidcClientIDNotMatching Code = "oidc_client_id_not_matching"
|
||||
CodeUIConfigDisabled Code = "ui_config_disabled"
|
||||
CodeOneTimeAccessDisabled Code = "one_time_access_disabled"
|
||||
CodeDeviceLoginDenied Code = "device_login_denied"
|
||||
CodeNoAPIKeyProvided Code = "no_api_key_provided"
|
||||
CodeAPIKeyNotExpired Code = "api_key_not_expired"
|
||||
CodeInvalidAPIKeyExpiration Code = "invalid_api_key_expiration"
|
||||
CodeAPIKeyAuthNotAllowed Code = "api_key_auth_not_allowed"
|
||||
CodeOidcDeviceCodeExpired Code = "oidc_device_code_expired"
|
||||
CodeOidcInvalidDeviceCode Code = "oidc_invalid_device_code"
|
||||
CodeOpenSignupDisabled Code = "open_signup_disabled"
|
||||
CodeClientIDAlreadyExists Code = "client_id_already_exists"
|
||||
CodeUserEmailNotSet Code = "user_email_not_set"
|
||||
CodeImageNotFound Code = "image_not_found"
|
||||
CodeLogoDownloadFailed Code = "logo_download_failed"
|
||||
CodeLogoTypeNotSupported Code = "logo_type_not_supported"
|
||||
CodeLogoTooLarge Code = "logo_too_large"
|
||||
CodeOidcPARRequired Code = "oidc_par_required"
|
||||
)
|
||||
|
||||
// FieldError describes one safe, client-actionable validation failure
|
||||
type FieldError struct {
|
||||
Field string `json:"field"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error is an application error with a stable code, an HTTP status, and an optional internal cause
|
||||
type Error struct {
|
||||
code Code
|
||||
status int
|
||||
message string
|
||||
details map[string]string
|
||||
fields []FieldError
|
||||
retryAfter time.Duration
|
||||
cause error
|
||||
}
|
||||
|
||||
// New creates a client-safe application error
|
||||
func New(code Code, status int, message string) *Error {
|
||||
return &Error{code: code, status: status, message: message}
|
||||
}
|
||||
|
||||
// Wrap creates a client-safe application error that retains an internal cause
|
||||
func Wrap(cause error, code Code, status int, message string) *Error {
|
||||
if cause == nil {
|
||||
return New(code, status, message)
|
||||
}
|
||||
|
||||
return &Error{code: code, status: status, message: message, cause: cause}
|
||||
}
|
||||
|
||||
// Internal creates a generic server error that retains a diagnostic cause without exposing it to clients
|
||||
func Internal(cause error) *Error {
|
||||
return Wrap(cause, CodeInternal, http.StatusInternalServerError, "Something went wrong")
|
||||
}
|
||||
|
||||
// Validation creates a structured validation error
|
||||
func Validation(fields []FieldError) *Error {
|
||||
return New(CodeValidationFailed, http.StatusBadRequest, "Request validation failed").WithFields(fields)
|
||||
}
|
||||
|
||||
// Error returns diagnostic text including the internal cause when one exists
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
if e.cause == nil {
|
||||
return e.message
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s: %v", e.message, e.cause)
|
||||
}
|
||||
|
||||
// Unwrap exposes the internal cause to errors.Is and errors.As
|
||||
func (e *Error) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.cause
|
||||
}
|
||||
|
||||
// Is matches application errors by stable code while ignoring message and detail differences
|
||||
func (e *Error) Is(target error) bool {
|
||||
targetError, ok := target.(*Error)
|
||||
return ok && e != nil && targetError != nil && e.code == targetError.code
|
||||
}
|
||||
|
||||
// IsCode reports whether an error or one of its wrapped causes has the given application code
|
||||
func IsCode(err error, code Code) bool {
|
||||
return errors.Is(err, &Error{code: code})
|
||||
}
|
||||
|
||||
// Code returns the stable application error code
|
||||
func (e *Error) Code() Code {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return e.code
|
||||
}
|
||||
|
||||
// HTTPStatus returns the HTTP status associated with the error
|
||||
func (e *Error) HTTPStatus() int {
|
||||
if e == nil {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return e.status
|
||||
}
|
||||
|
||||
// ClientMessage returns the message that is safe to include in an HTTP response
|
||||
func (e *Error) ClientMessage() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return e.message
|
||||
}
|
||||
|
||||
// Details returns a copy of the additional client-safe details
|
||||
func (e *Error) Details() map[string]string {
|
||||
if e == nil || len(e.details) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
details := make(map[string]string, len(e.details))
|
||||
maps.Copy(details, e.details)
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
// Fields returns a copy of the structured validation details
|
||||
func (e *Error) Fields() []FieldError {
|
||||
if e == nil || len(e.fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return append([]FieldError(nil), e.fields...)
|
||||
}
|
||||
|
||||
// RetryAfter returns the duration a client should wait before retrying
|
||||
func (e *Error) RetryAfter() time.Duration {
|
||||
if e == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return e.retryAfter
|
||||
}
|
||||
|
||||
// WithFields attaches structured validation details without exposing the cause
|
||||
func (e *Error) WithFields(fields []FieldError) *Error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
e.fields = append([]FieldError(nil), fields...)
|
||||
return e
|
||||
}
|
||||
|
||||
// WithDetail attaches one client-safe string detail
|
||||
func (e *Error) WithDetail(key, value string) *Error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if e.details == nil {
|
||||
e.details = make(map[string]string)
|
||||
}
|
||||
e.details[key] = value
|
||||
return e
|
||||
}
|
||||
|
||||
// WithRetryAfter attaches a retry delay to the error
|
||||
func (e *Error) WithRetryAfter(retryAfter time.Duration) *Error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
e.retryAfter = retryAfter
|
||||
return e
|
||||
}
|
||||
45
backend/internal/apperror/error_test.go
Normal file
45
backend/internal/apperror/error_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package apperror
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWrapPreservesCauseWithoutChangingClientMessage(t *testing.T) {
|
||||
cause := errors.New("database connection details")
|
||||
err := Wrap(cause, CodeNotFound, http.StatusNotFound, "User not found")
|
||||
|
||||
require.ErrorIs(t, err, cause)
|
||||
require.Equal(t, "User not found", err.ClientMessage())
|
||||
require.Contains(t, err.Error(), "database connection details")
|
||||
}
|
||||
|
||||
func TestErrorsMatchByCode(t *testing.T) {
|
||||
err := Wrap(errors.New("database connection details"), CodeAlreadyInUse, http.StatusBadRequest, "email is already in use")
|
||||
wrapped := Wrap(err, CodeInternal, http.StatusInternalServerError, "request failed")
|
||||
|
||||
require.True(t, IsCode(err, CodeAlreadyInUse))
|
||||
require.True(t, IsCode(wrapped, CodeAlreadyInUse))
|
||||
require.ErrorIs(t, err, New(CodeAlreadyInUse, http.StatusBadRequest, "username is already in use"))
|
||||
require.NotErrorIs(t, err, errors.Join(New(CodeAlreadyInUse, http.StatusBadRequest, "username is already in use")))
|
||||
require.False(t, IsCode(wrapped, CodeNotFound))
|
||||
}
|
||||
|
||||
func TestErrorCopiesFields(t *testing.T) {
|
||||
fields := []FieldError{{Field: "email", Code: "required", Message: "is required"}}
|
||||
err := New(CodeValidationFailed, http.StatusBadRequest, "Request validation failed").WithFields(fields)
|
||||
fields[0].Message = "changed"
|
||||
|
||||
require.Equal(t, "is required", err.Fields()[0].Message)
|
||||
}
|
||||
|
||||
func TestErrorCopiesDetails(t *testing.T) {
|
||||
err := New(CodeAlreadyInUse, http.StatusBadRequest, "Already in use").WithDetail("property", "email")
|
||||
details := err.Details()
|
||||
details["property"] = "password"
|
||||
|
||||
require.Equal(t, map[string]string{"property": "email"}, err.Details())
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/italypaleale/francis/builtin/ratelimit"
|
||||
"github.com/italypaleale/go-kit/servicerunner"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/frontend"
|
||||
@@ -404,6 +405,12 @@ func initLogger(r *gin.Engine) {
|
||||
sloggin.WithLogger(func(_ *gin.Context, _ *slog.Logger) *slog.Logger {
|
||||
return slog.Default()
|
||||
}),
|
||||
sloggin.WithClientErrorLevel(slog.LevelInfo),
|
||||
sloggin.WithSpecificLogLevelByStatusCode(map[int]slog.Level{
|
||||
http.StatusTooManyRequests: slog.LevelWarn,
|
||||
}),
|
||||
sloggin.WithContext(enrichRequestLog),
|
||||
// Skip logging for certain paths to reduce noise in the logs
|
||||
sloggin.WithSkipper(func(c *gin.Context) bool {
|
||||
for _, prefix := range loggerSkipPathsPrefix {
|
||||
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
|
||||
@@ -415,6 +422,33 @@ func initLogger(r *gin.Engine) {
|
||||
))
|
||||
}
|
||||
|
||||
// enrichRequestLog enriches the slog.Record with additional attributes from the request context, such as request ID, error code, and trace/span IDs.
|
||||
func enrichRequestLog(c *gin.Context, record *slog.Record) *slog.Record {
|
||||
enriched := slog.NewRecord(record.Time, record.Level, "HTTP request completed", record.PC)
|
||||
// Add request ID if present in the context
|
||||
if requestID := middleware.RequestID(c); requestID != "" {
|
||||
enriched.AddAttrs(slog.String("request_id", requestID))
|
||||
}
|
||||
// Add error code if present in the context
|
||||
if errorCode := middleware.RequestErrorCode(c); errorCode != "" {
|
||||
enriched.AddAttrs(slog.String("error_code", string(errorCode)))
|
||||
}
|
||||
|
||||
// Add trace and span IDs if present in the context
|
||||
if spanContext := trace.SpanFromContext(c.Request.Context()).SpanContext(); spanContext.IsValid() {
|
||||
enriched.AddAttrs(
|
||||
slog.String("trace_id", spanContext.TraceID().String()),
|
||||
slog.String("span_id", spanContext.SpanID().String()),
|
||||
)
|
||||
}
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
enriched.AddAttrs(attr)
|
||||
return true
|
||||
})
|
||||
|
||||
return &enriched
|
||||
}
|
||||
|
||||
// tlsCertProvider holds certificates that can be dynamically reloaded
|
||||
type tlsCertProvider struct {
|
||||
certMutex sync.RWMutex
|
||||
|
||||
80
backend/internal/bootstrap/router_bootstrap_test.go
Normal file
80
backend/internal/bootstrap/router_bootstrap_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRequestLoggerUsesStructuredErrorMetadata(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var output bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil)))
|
||||
t.Cleanup(func() {
|
||||
slog.SetDefault(previousLogger)
|
||||
})
|
||||
|
||||
router := gin.New()
|
||||
initLogger(router)
|
||||
router.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
router.GET("/api/users/me", func(c *gin.Context) {
|
||||
_ = c.Error(apperror.NotSignedIn())
|
||||
c.Abort()
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/users/me", nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
var body struct {
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
|
||||
require.NotEmpty(t, body.RequestID)
|
||||
|
||||
logLine := output.String()
|
||||
require.Contains(t, logLine, "level=INFO")
|
||||
require.Contains(t, logLine, `msg="HTTP request completed"`)
|
||||
require.Contains(t, logLine, "request_id="+body.RequestID)
|
||||
require.Contains(t, logLine, "error_code=not_signed_in")
|
||||
require.Contains(t, logLine, "status=401")
|
||||
require.NotContains(t, logLine, "Request with errors")
|
||||
require.NotContains(t, logLine, "Error #01")
|
||||
}
|
||||
|
||||
func TestRequestLoggerKeepsRateLimitsAtWarningLevel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var output bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil)))
|
||||
t.Cleanup(func() {
|
||||
slog.SetDefault(previousLogger)
|
||||
})
|
||||
|
||||
router := gin.New()
|
||||
initLogger(router)
|
||||
router.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
router.GET("/api/limited", func(c *gin.Context) {
|
||||
_ = c.Error(apperror.TooManyRequests())
|
||||
c.Abort()
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/limited", nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, recorder.Code)
|
||||
require.Contains(t, output.String(), "level=WARN")
|
||||
require.Contains(t, output.String(), "error_code=rate_limited")
|
||||
}
|
||||
@@ -328,7 +328,7 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
dbConnFile := tempDir + "/db_connection.txt"
|
||||
dbConnContent := "postgres://user:pass@localhost/testdb" // #nosec G101 - test credential
|
||||
dbConnContent := "postgres://user:pass@localhost/testdb" // #nosec G101
|
||||
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type AppError interface {
|
||||
error
|
||||
|
||||
HttpStatusCode() int
|
||||
}
|
||||
|
||||
type AppErrorDescription interface {
|
||||
AppError
|
||||
|
||||
Description() string
|
||||
}
|
||||
|
||||
// Custom error types for various conditions
|
||||
|
||||
type AlreadyInUseError struct {
|
||||
Property string
|
||||
}
|
||||
|
||||
func (e AlreadyInUseError) Error() string {
|
||||
return e.Property + " is already in use"
|
||||
}
|
||||
func (e AlreadyInUseError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
func (e AlreadyInUseError) Is(target error) bool {
|
||||
// Ignore the field property when checking if an error is of the type AlreadyInUseError
|
||||
x := &AlreadyInUseError{}
|
||||
return errors.As(target, &x)
|
||||
}
|
||||
|
||||
type SetupNotAvailableError struct{}
|
||||
|
||||
func (e SetupNotAvailableError) Error() string { return "not found" }
|
||||
func (e SetupNotAvailableError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
|
||||
type TokenInvalidOrExpiredError struct{}
|
||||
|
||||
func (e TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||
func (e TokenInvalidOrExpiredError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type DeviceCodeInvalid struct{}
|
||||
|
||||
func (e DeviceCodeInvalid) Error() string {
|
||||
return "one time access code must be used on the device it was generated for"
|
||||
}
|
||||
func (e DeviceCodeInvalid) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type TokenInvalidError struct{}
|
||||
|
||||
func (e TokenInvalidError) Error() string { return "Token is invalid" }
|
||||
func (e TokenInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type OidcMissingAuthorizationError struct{}
|
||||
|
||||
func (e OidcMissingAuthorizationError) Error() string { return "missing authorization" }
|
||||
func (e OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcInvalidCallbackURLError struct{}
|
||||
|
||||
func (e OidcInvalidCallbackURLError) Error() string {
|
||||
return "invalid callback URL, it might be necessary for an admin to fix this"
|
||||
}
|
||||
func (e OidcInvalidCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type InvalidCIMDURLPatternError struct {
|
||||
Pattern string
|
||||
}
|
||||
|
||||
func (e InvalidCIMDURLPatternError) Error() string {
|
||||
return "invalid metadata document URL pattern: " + e.Pattern
|
||||
}
|
||||
func (e InvalidCIMDURLPatternError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type FileTypeNotSupportedError struct{}
|
||||
|
||||
func (e FileTypeNotSupportedError) Error() string { return "file type not supported" }
|
||||
func (e FileTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type FileTooLargeError struct {
|
||||
MaxSize string
|
||||
}
|
||||
|
||||
func (e FileTooLargeError) Error() string {
|
||||
return fmt.Sprintf("The file can't be larger than %s", e.MaxSize)
|
||||
}
|
||||
func (e FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
|
||||
|
||||
type NotSignedInError struct{}
|
||||
|
||||
func (e NotSignedInError) Error() string { return "You are not signed in" }
|
||||
func (e NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type MissingPermissionError struct{}
|
||||
|
||||
func (e MissingPermissionError) Error() string {
|
||||
return "You don't have permission to perform this action"
|
||||
}
|
||||
func (e MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type TooManyRequestsError struct{}
|
||||
|
||||
func (e TooManyRequestsError) Error() string { return "Too many requests" }
|
||||
func (e TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
|
||||
|
||||
type UserIdNotProvidedError struct{}
|
||||
|
||||
func (e UserIdNotProvidedError) Error() string { return "User id not provided" }
|
||||
func (e UserIdNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type UserNotFoundError struct{}
|
||||
|
||||
func (e UserNotFoundError) Error() string { return "User not found" }
|
||||
func (e UserNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
|
||||
type WrongFileTypeError struct {
|
||||
ExpectedFileType string
|
||||
}
|
||||
|
||||
func (e WrongFileTypeError) Error() string {
|
||||
return fmt.Sprintf("File must be of type %s", e.ExpectedFileType)
|
||||
}
|
||||
func (e WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type MissingSessionIdError struct{}
|
||||
|
||||
func (e MissingSessionIdError) Error() string { return "Missing session id" }
|
||||
func (e MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type InvalidWebauthnSessionError struct{}
|
||||
|
||||
func (e InvalidWebauthnSessionError) Error() string {
|
||||
return "WebAuthn session is invalid or has expired"
|
||||
}
|
||||
func (e InvalidWebauthnSessionError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type ReservedClaimError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e ReservedClaimError) Error() string {
|
||||
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
|
||||
}
|
||||
func (e ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type DuplicateClaimError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e DuplicateClaimError) Error() string {
|
||||
return fmt.Sprintf("Claim %s is already defined", e.Key)
|
||||
}
|
||||
func (e DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type LdapUserUpdateError struct{}
|
||||
|
||||
func (e LdapUserUpdateError) Error() string { return "LDAP users can't be updated" }
|
||||
func (e LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type LdapUserGroupUpdateError struct{}
|
||||
|
||||
func (e LdapUserGroupUpdateError) Error() string { return "LDAP user groups can't be updated" }
|
||||
func (e LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcAccessDeniedError struct{}
|
||||
|
||||
func (e OidcAccessDeniedError) Error() string { return "You're not allowed to access this service" }
|
||||
func (e OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcClientIdNotMatchingError struct{}
|
||||
|
||||
func (e OidcClientIdNotMatchingError) Error() string {
|
||||
return "Client id in request doesn't match client id in token"
|
||||
}
|
||||
func (e OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type UiConfigDisabledError struct{}
|
||||
|
||||
func (e UiConfigDisabledError) Error() string {
|
||||
return "The configuration can't be changed since the UI configuration is disabled"
|
||||
}
|
||||
func (e UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type InvalidUUIDError struct{}
|
||||
|
||||
func (e InvalidUUIDError) Error() string { return "Invalid UUID" }
|
||||
func (e InvalidUUIDError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OneTimeAccessDisabledError struct{}
|
||||
|
||||
func (e OneTimeAccessDisabledError) Error() string { return "One-time access is disabled" }
|
||||
func (e OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type DeviceLoginRequestInvalidOrExpiredError struct{}
|
||||
|
||||
func (e DeviceLoginRequestInvalidOrExpiredError) Error() string {
|
||||
return "Device login request is invalid or expired"
|
||||
}
|
||||
func (e DeviceLoginRequestInvalidOrExpiredError) HttpStatusCode() int {
|
||||
return http.StatusUnauthorized
|
||||
}
|
||||
|
||||
type DeviceLoginDeniedError struct{}
|
||||
|
||||
func (e DeviceLoginDeniedError) Error() string { return "Device login request was denied" }
|
||||
func (e DeviceLoginDeniedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type InvalidAPIKeyError struct{}
|
||||
|
||||
func (e InvalidAPIKeyError) Error() string { return "Invalid Api Key" }
|
||||
func (e InvalidAPIKeyError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type NoAPIKeyProvidedError struct{}
|
||||
|
||||
func (e NoAPIKeyProvidedError) Error() string { return "No API Key Provided" }
|
||||
func (e NoAPIKeyProvidedError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type APIKeyNotFoundError struct{}
|
||||
|
||||
func (e APIKeyNotFoundError) Error() string { return "API Key Not Found" }
|
||||
func (e APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type APIKeyNotExpiredError struct{}
|
||||
|
||||
func (e APIKeyNotExpiredError) Error() string { return "API Key is not expired yet" }
|
||||
func (e APIKeyNotExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type APIKeyExpirationDateError struct{}
|
||||
|
||||
func (e APIKeyExpirationDateError) Error() string {
|
||||
return "API Key expiration time must be in the future"
|
||||
}
|
||||
func (e APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type APIKeyAuthNotAllowedError struct{}
|
||||
|
||||
func (e APIKeyAuthNotAllowedError) Error() string {
|
||||
return "API key authentication is not allowed for this endpoint"
|
||||
}
|
||||
func (e APIKeyAuthNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type UserDisabledError struct{}
|
||||
|
||||
func (e UserDisabledError) Error() string { return "User account is disabled" }
|
||||
func (e UserDisabledError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type ValidationError struct{ Message string }
|
||||
|
||||
func (e ValidationError) Error() string { return e.Message }
|
||||
|
||||
func (e ValidationError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcDeviceCodeExpiredError struct{}
|
||||
|
||||
func (e OidcDeviceCodeExpiredError) Error() string { return "device code has expired" }
|
||||
func (e OidcDeviceCodeExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcInvalidDeviceCodeError struct{}
|
||||
|
||||
func (e OidcInvalidDeviceCodeError) Error() string { return "invalid device code" }
|
||||
func (e OidcInvalidDeviceCodeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type ReauthenticationRequiredError struct{}
|
||||
|
||||
func (e ReauthenticationRequiredError) Error() string { return "reauthentication required" }
|
||||
func (e ReauthenticationRequiredError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type OpenSignupDisabledError struct{}
|
||||
|
||||
func (e OpenSignupDisabledError) Error() string { return "Open user signup is not enabled" }
|
||||
|
||||
func (e OpenSignupDisabledError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type ClientIdAlreadyExistsError struct{}
|
||||
|
||||
func (e ClientIdAlreadyExistsError) Error() string { return "Client ID already in use" }
|
||||
|
||||
func (e ClientIdAlreadyExistsError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type UserEmailNotSetError struct{}
|
||||
|
||||
func (e UserEmailNotSetError) Error() string { return "The user does not have an email address set" }
|
||||
|
||||
func (e UserEmailNotSetError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type ImageNotFoundError struct{}
|
||||
|
||||
func (e ImageNotFoundError) Error() string { return "Image not found" }
|
||||
|
||||
func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
|
||||
type OidcPARRequiredError struct{}
|
||||
|
||||
func (e OidcPARRequiredError) Error() string {
|
||||
return "this client requires pushed authorization requests"
|
||||
}
|
||||
func (e OidcPARRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type InvalidEmailVerificationTokenError struct{}
|
||||
|
||||
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
|
||||
|
||||
func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"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/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/tracing"
|
||||
@@ -35,12 +36,12 @@ func NewAppConfigController(
|
||||
emailSender: emailSender,
|
||||
ldapService: ldapService,
|
||||
}
|
||||
group.GET("/application-configuration", acc.listAppConfigHandler)
|
||||
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
|
||||
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
|
||||
group.GET("/application-configuration", httpserver.Handle(acc.listAppConfigHandler))
|
||||
group.GET("/application-configuration/all", authMiddleware.Add(), httpserver.Handle(acc.listAllAppConfigHandler))
|
||||
group.PUT("/application-configuration", authMiddleware.Add(), httpserver.Handle(acc.updateAppConfigHandler))
|
||||
|
||||
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
|
||||
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
|
||||
group.POST("/application-configuration/test-email", authMiddleware.Add(), httpserver.Handle(acc.testEmailHandler))
|
||||
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), httpserver.Handle(acc.syncLdapHandler))
|
||||
}
|
||||
|
||||
type AppConfigController struct {
|
||||
@@ -57,18 +58,16 @@ type AppConfigController struct {
|
||||
// @Produce json
|
||||
// @Success 200 {array} dto.PublicAppConfigVariableDto
|
||||
// @Router /api/application-configuration [get]
|
||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) error {
|
||||
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
configuration := dbConfig.ToAppConfigVariableSlice(false, true)
|
||||
|
||||
var configVariablesDto []dto.PublicAppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Manually add uiConfigDisabled which isn't in the database but defined with an environment variable
|
||||
@@ -85,6 +84,7 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, configVariablesDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listAllAppConfigHandler godoc
|
||||
@@ -95,21 +95,20 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Success 200 {array} dto.AppConfigVariableDto
|
||||
// @Router /api/application-configuration/all [get]
|
||||
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) error {
|
||||
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
configuration := dbConfig.ToAppConfigVariableSlice(true, true)
|
||||
|
||||
var configVariablesDto []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, configVariablesDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateAppConfigHandler godoc
|
||||
@@ -121,26 +120,24 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
|
||||
// @Success 200 {array} dto.AppConfigVariableDto
|
||||
// @Router /api/application-configuration [put]
|
||||
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) error {
|
||||
var input dto.AppConfigUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var configVariablesDto []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, configVariablesDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncLdapHandler godoc
|
||||
@@ -149,20 +146,19 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||
// @Tags Application Configuration
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-configuration/sync-ldap [post]
|
||||
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) error {
|
||||
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
err = acc.ldapService.SyncAll(c.Request.Context(), dbConfig)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// testEmailHandler godoc
|
||||
@@ -171,20 +167,19 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
||||
// @Tags Application Configuration
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-configuration/test-email [post]
|
||||
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) testEmailHandler(c *gin.Context) error {
|
||||
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
err = acc.emailSender.SendTestEmail(c.Request.Context(), dbConfig, userID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
@@ -24,20 +25,20 @@ func NewAppImagesController(
|
||||
appImagesService: appImagesService,
|
||||
}
|
||||
|
||||
group.GET("/application-images/logo", controller.getLogoHandler)
|
||||
group.GET("/application-images/email", controller.getEmailLogoHandler)
|
||||
group.GET("/application-images/background", controller.getBackgroundImageHandler)
|
||||
group.GET("/application-images/favicon", controller.getFaviconHandler)
|
||||
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
|
||||
group.GET("/application-images/logo", httpserver.Handle(controller.getLogoHandler))
|
||||
group.GET("/application-images/email", httpserver.Handle(controller.getEmailLogoHandler))
|
||||
group.GET("/application-images/background", httpserver.Handle(controller.getBackgroundImageHandler))
|
||||
group.GET("/application-images/favicon", httpserver.Handle(controller.getFaviconHandler))
|
||||
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.getDefaultProfilePicture))
|
||||
|
||||
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
|
||||
group.PUT("/application-images/email", authMiddleware.Add(), controller.updateEmailLogoHandler)
|
||||
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
|
||||
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
||||
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
|
||||
group.PUT("/application-images/logo", authMiddleware.Add(), httpserver.Handle(controller.updateLogoHandler))
|
||||
group.PUT("/application-images/email", authMiddleware.Add(), httpserver.Handle(controller.updateEmailLogoHandler))
|
||||
group.PUT("/application-images/background", authMiddleware.Add(), httpserver.Handle(controller.updateBackgroundImageHandler))
|
||||
group.PUT("/application-images/favicon", authMiddleware.Add(), httpserver.Handle(controller.updateFaviconHandler))
|
||||
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.updateDefaultProfilePicture))
|
||||
|
||||
group.DELETE("/application-images/background", authMiddleware.Add(), controller.deleteBackgroundImageHandler)
|
||||
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
|
||||
group.DELETE("/application-images/background", authMiddleware.Add(), httpserver.Handle(controller.deleteBackgroundImageHandler))
|
||||
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.deleteDefaultProfilePicture))
|
||||
}
|
||||
|
||||
type AppImagesController struct {
|
||||
@@ -54,14 +55,14 @@ type AppImagesController struct {
|
||||
// @Produce image/svg+xml
|
||||
// @Success 200 {file} binary "Logo image"
|
||||
// @Router /api/application-images/logo [get]
|
||||
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
|
||||
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) error {
|
||||
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
|
||||
imageName := "logoLight"
|
||||
if !lightLogo {
|
||||
imageName = "logoDark"
|
||||
}
|
||||
|
||||
c.getImage(ctx, imageName)
|
||||
return c.getImage(ctx, imageName)
|
||||
}
|
||||
|
||||
// getEmailLogoHandler godoc
|
||||
@@ -72,8 +73,8 @@ func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary "Email logo image"
|
||||
// @Router /api/application-images/email [get]
|
||||
func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) {
|
||||
c.getImage(ctx, "logoEmail")
|
||||
func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) error {
|
||||
return c.getImage(ctx, "logoEmail")
|
||||
}
|
||||
|
||||
// getBackgroundImageHandler godoc
|
||||
@@ -84,8 +85,8 @@ func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) {
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary "Background image"
|
||||
// @Router /api/application-images/background [get]
|
||||
func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) {
|
||||
c.getImage(ctx, "background")
|
||||
func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) error {
|
||||
return c.getImage(ctx, "background")
|
||||
}
|
||||
|
||||
// getFaviconHandler godoc
|
||||
@@ -95,8 +96,8 @@ func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) {
|
||||
// @Produce image/x-icon
|
||||
// @Success 200 {file} binary "Favicon image"
|
||||
// @Router /api/application-images/favicon [get]
|
||||
func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
|
||||
c.getImage(ctx, "favicon")
|
||||
func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) error {
|
||||
return c.getImage(ctx, "favicon")
|
||||
}
|
||||
|
||||
// getDefaultProfilePicture godoc
|
||||
@@ -107,8 +108,8 @@ func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary "Default profile picture image"
|
||||
// @Router /api/application-images/default-profile-picture [get]
|
||||
func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) {
|
||||
c.getImage(ctx, "default-profile-picture")
|
||||
func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) error {
|
||||
return c.getImage(ctx, "default-profile-picture")
|
||||
}
|
||||
|
||||
// updateLogoHandler godoc
|
||||
@@ -120,11 +121,10 @@ func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) {
|
||||
// @Param file formData file true "Logo image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/logo [put]
|
||||
func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
|
||||
file, err := ctx.FormFile("file")
|
||||
func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) error {
|
||||
file, err := httpserver.FormFile(ctx, "file")
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
|
||||
@@ -134,11 +134,11 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, imageName); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateEmailLogoHandler godoc
|
||||
@@ -149,27 +149,25 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
|
||||
// @Param file formData file true "Email logo image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/email [put]
|
||||
func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) {
|
||||
file, err := ctx.FormFile("file")
|
||||
func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) error {
|
||||
file, err := httpserver.FormFile(ctx, "file")
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
mimeType := utils.GetImageMimeType(fileType)
|
||||
|
||||
if mimeType != "image/png" && mimeType != "image/jpeg" {
|
||||
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"})
|
||||
return
|
||||
return apperror.UnsupportedFileType("PNG or JPEG")
|
||||
}
|
||||
|
||||
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateBackgroundImageHandler godoc
|
||||
@@ -180,19 +178,18 @@ func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) {
|
||||
// @Param file formData file true "Background image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/background [put]
|
||||
func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
|
||||
file, err := ctx.FormFile("file")
|
||||
func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) error {
|
||||
file, err := httpserver.FormFile(ctx, "file")
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "background"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteBackgroundImageHandler godoc
|
||||
@@ -201,13 +198,13 @@ func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
|
||||
// @Tags Application Images
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/background [delete]
|
||||
func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) {
|
||||
func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) error {
|
||||
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "background"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateFaviconHandler godoc
|
||||
@@ -218,39 +215,37 @@ func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) {
|
||||
// @Param file formData file true "Favicon file (.svg/.png/.ico)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/favicon [put]
|
||||
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
|
||||
file, err := ctx.FormFile("file")
|
||||
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) error {
|
||||
file, err := httpserver.FormFile(ctx, "file")
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
mimeType := utils.GetImageMimeType(strings.ToLower(fileType))
|
||||
if !slices.Contains([]string{"image/svg+xml", "image/png", "image/x-icon"}, mimeType) {
|
||||
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"})
|
||||
return
|
||||
return apperror.UnsupportedFileType("SVG, PNG, or ICO")
|
||||
}
|
||||
|
||||
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "favicon"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
|
||||
func (c *AppImagesController) getImage(ctx *gin.Context, name string) error {
|
||||
reader, size, mimeType, err := c.appImagesService.GetImage(ctx.Request.Context(), name)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
ctx.Header("Content-Type", mimeType)
|
||||
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
|
||||
ctx.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateDefaultProfilePicture godoc
|
||||
@@ -261,19 +256,18 @@ func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
|
||||
// @Param file formData file true "Profile picture image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/default-profile-picture [put]
|
||||
func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) {
|
||||
file, err := ctx.FormFile("file")
|
||||
func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) error {
|
||||
file, err := httpserver.FormFile(ctx, "file")
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "default-profile-picture"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteDefaultProfilePicture godoc
|
||||
@@ -282,11 +276,11 @@ func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) {
|
||||
// @Tags Application Images
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/default-profile-picture [delete]
|
||||
func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) {
|
||||
func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) error {
|
||||
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "default-profile-picture"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
@@ -20,10 +21,10 @@ func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.Audi
|
||||
auditLogService: auditLogService,
|
||||
}
|
||||
|
||||
group.GET("/audit-logs/all", authMiddleware.Add(), alc.listAllAuditLogsHandler)
|
||||
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler)
|
||||
group.GET("/audit-logs/filters/client-names", authMiddleware.Add(), alc.listClientNamesHandler)
|
||||
group.GET("/audit-logs/filters/users", authMiddleware.Add(), alc.listUserNamesWithIdsHandler)
|
||||
group.GET("/audit-logs/all", authMiddleware.Add(), httpserver.Handle(alc.listAllAuditLogsHandler))
|
||||
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(alc.listAuditLogsForUserHandler))
|
||||
group.GET("/audit-logs/filters/client-names", authMiddleware.Add(), httpserver.Handle(alc.listClientNamesHandler))
|
||||
group.GET("/audit-logs/filters/users", authMiddleware.Add(), httpserver.Handle(alc.listUserNamesWithIdsHandler))
|
||||
}
|
||||
|
||||
type AuditLogController struct {
|
||||
@@ -40,7 +41,7 @@ type AuditLogController struct {
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||
// @Router /api/audit-logs [get]
|
||||
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) error {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
userID := c.GetString("userID")
|
||||
@@ -48,16 +49,14 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
// Fetch audit logs for the user
|
||||
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Map the audit logs to DTOs
|
||||
var logsDtos []dto.AuditLogDto
|
||||
err = dto.MapStructList(logs, &logsDtos)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Add device information to the logs
|
||||
@@ -71,6 +70,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
Data: logsDtos,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// listAllAuditLogsHandler godoc
|
||||
@@ -83,20 +83,18 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||
// @Router /api/audit-logs/all [get]
|
||||
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
||||
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) error {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var logsDtos []dto.AuditLogDto
|
||||
err = dto.MapStructList(logs, &logsDtos)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
for i, logsDto := range logsDtos {
|
||||
@@ -110,6 +108,7 @@ func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
||||
Data: logsDtos,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// listClientNamesHandler godoc
|
||||
@@ -118,14 +117,14 @@ func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
||||
// @Tags Audit Logs
|
||||
// @Success 200 {array} string "List of client names"
|
||||
// @Router /api/audit-logs/filters/client-names [get]
|
||||
func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) {
|
||||
func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) error {
|
||||
names, err := alc.auditLogService.ListClientNames(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, names)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listUserNamesWithIdsHandler godoc
|
||||
@@ -134,12 +133,12 @@ func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) {
|
||||
// @Tags Audit Logs
|
||||
// @Success 200 {object} map[string]string "Map of user IDs to usernames"
|
||||
// @Router /api/audit-logs/filters/users [get]
|
||||
func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) {
|
||||
func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) error {
|
||||
users, err := alc.auditLogService.ListUsernamesWithIds(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, users)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
@@ -19,9 +20,9 @@ func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware
|
||||
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)
|
||||
customClaimsGroup.GET("/suggestions", httpserver.Handle(wkc.getSuggestionsHandler))
|
||||
customClaimsGroup.PUT("/user/:userId", httpserver.Handle(wkc.UpdateCustomClaimsForUserHandler))
|
||||
customClaimsGroup.PUT("/user-group/:userGroupId", httpserver.Handle(wkc.UpdateCustomClaimsForUserGroupHandler))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,14 +37,14 @@ type CustomClaimController struct {
|
||||
// @Produce json
|
||||
// @Success 200 {array} string "List of suggested custom claim names"
|
||||
// @Router /api/custom-claims/suggestions [get]
|
||||
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) error {
|
||||
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, claims)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCustomClaimsForUserHandler godoc
|
||||
@@ -56,28 +57,26 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user"
|
||||
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||
// @Router /api/custom-claims/user/{userId} [put]
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) error {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userId := c.Param("userId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var customClaimsDto []dto.CustomClaimDto
|
||||
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, customClaimsDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCustomClaimsForUserGroupHandler godoc
|
||||
@@ -90,26 +89,24 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
|
||||
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
|
||||
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||
// @Router /api/custom-claims/user-group/{userGroupId} [put]
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) error {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userGroupId := c.Param("userGroupId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var customClaimsDto []dto.CustomClaimDto
|
||||
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, customClaimsDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,25 +7,26 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
|
||||
testController := &TestController{TestService: testService}
|
||||
|
||||
group.POST("/test/reset", testController.resetAndSeedHandler)
|
||||
group.POST("/test/accesstoken", testController.signAccessToken)
|
||||
group.POST("/test/refreshtoken", testController.signRefreshToken)
|
||||
group.POST("/test/reset", httpserver.Handle(testController.resetAndSeedHandler))
|
||||
group.POST("/test/accesstoken", httpserver.Handle(testController.signAccessToken))
|
||||
group.POST("/test/refreshtoken", httpserver.Handle(testController.signRefreshToken))
|
||||
|
||||
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
|
||||
group.POST("/externalidp/sign", testController.externalIdPSignToken)
|
||||
group.GET("/externalidp/jwks.json", httpserver.Handle(testController.externalIdPJWKS))
|
||||
group.POST("/externalidp/sign", httpserver.Handle(testController.externalIdPSignToken))
|
||||
}
|
||||
|
||||
type TestController struct {
|
||||
TestService *service.TestService
|
||||
}
|
||||
|
||||
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
func (tc *TestController) resetAndSeedHandler(c *gin.Context) error {
|
||||
var baseURL string
|
||||
if c.Request.TLS != nil {
|
||||
baseURL = "https://" + c.Request.Host
|
||||
@@ -37,111 +38,103 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
skipSeed := c.Query("skip-seed") == "true"
|
||||
|
||||
if err := tc.TestService.ResetDatabase(); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
if !skipSeed {
|
||||
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
if !skipLdap {
|
||||
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TestController) externalIdPJWKS(c *gin.Context) {
|
||||
func (tc *TestController) externalIdPJWKS(c *gin.Context) error {
|
||||
jwks, err := tc.TestService.GetExternalIdPJWKS()
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, jwks)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TestController) externalIdPSignToken(c *gin.Context) {
|
||||
func (tc *TestController) externalIdPSignToken(c *gin.Context) error {
|
||||
var input struct {
|
||||
Aud string `json:"aud"`
|
||||
Iss string `json:"iss"`
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
err := c.ShouldBindJSON(&input)
|
||||
err := httpserver.BindJSON(c, &input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := tc.TestService.SignExternalIdPToken(input.Iss, input.Sub, input.Aud)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Writer.WriteString(token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TestController) signAccessToken(c *gin.Context) {
|
||||
func (tc *TestController) signAccessToken(c *gin.Context) error {
|
||||
var input struct {
|
||||
UserID string `json:"user"`
|
||||
ClientID string `json:"client"`
|
||||
Expired bool `json:"expired"`
|
||||
}
|
||||
err := c.ShouldBindJSON(&input)
|
||||
err := httpserver.BindJSON(c, &input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := tc.TestService.SignAccessToken(c.Request.Context(), input.UserID, input.ClientID, input.Expired)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Writer.WriteString(token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TestController) signRefreshToken(c *gin.Context) {
|
||||
func (tc *TestController) signRefreshToken(c *gin.Context) error {
|
||||
var input struct {
|
||||
UserID string `json:"user"`
|
||||
ClientID string `json:"client"`
|
||||
RefreshToken string `json:"rt"`
|
||||
}
|
||||
err := c.ShouldBindJSON(&input)
|
||||
err := httpserver.BindJSON(c, &input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := tc.TestService.SignRefreshToken(c.Request.Context(), input.UserID, input.ClientID, input.RefreshToken)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Writer.WriteString(token)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -10,8 +8,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
@@ -26,31 +25,31 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
oidcService: oidcService,
|
||||
}
|
||||
|
||||
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.POST("/oidc/clients/:id/refresh", authMiddleware.Add(), oc.refreshClientMetadataHandler)
|
||||
group.DELETE("/oidc/clients/:id", authMiddleware.Add(), oc.deleteClientHandler)
|
||||
group.GET("/oidc/clients", authMiddleware.Add(), httpserver.Handle(oc.listClientsHandler))
|
||||
group.POST("/oidc/clients", authMiddleware.Add(), httpserver.Handle(oc.createClientHandler))
|
||||
group.GET("/oidc/clients/:id", authMiddleware.Add(), httpserver.Handle(oc.getClientHandler))
|
||||
group.GET("/oidc/clients/:id/meta", httpserver.Handle(oc.getClientMetaDataHandler))
|
||||
group.PUT("/oidc/clients/:id", authMiddleware.Add(), httpserver.Handle(oc.updateClientHandler))
|
||||
group.POST("/oidc/clients/:id/refresh", authMiddleware.Add(), httpserver.Handle(oc.refreshClientMetadataHandler))
|
||||
group.DELETE("/oidc/clients/:id", authMiddleware.Add(), httpserver.Handle(oc.deleteClientHandler))
|
||||
|
||||
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler)
|
||||
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
|
||||
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), httpserver.Handle(oc.updateAllowedUserGroupsHandler))
|
||||
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), httpserver.Handle(oc.createClientSecretHandler))
|
||||
|
||||
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
|
||||
group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), oc.deleteClientLogoHandler)
|
||||
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
||||
group.GET("/oidc/clients/:id/logo", httpserver.Handle(oc.getClientLogoHandler))
|
||||
group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), httpserver.Handle(oc.deleteClientLogoHandler))
|
||||
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), httpserver.Handle(oc.updateClientLogoHandler))
|
||||
|
||||
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
|
||||
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), httpserver.Handle(oc.getClientPreviewHandler))
|
||||
|
||||
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
|
||||
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
|
||||
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(oc.listOwnAuthorizedClientsHandler))
|
||||
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), httpserver.Handle(oc.listAuthorizedClientsHandler))
|
||||
|
||||
group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
|
||||
group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(oc.revokeOwnClientAuthorizationHandler))
|
||||
|
||||
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
|
||||
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(oc.listOwnAccessibleClientsHandler))
|
||||
|
||||
group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), oc.getClientScimServiceProviderHandler)
|
||||
group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), httpserver.Handle(oc.getClientScimServiceProviderHandler))
|
||||
|
||||
}
|
||||
|
||||
@@ -66,22 +65,21 @@ type OidcController struct {
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} dto.OidcClientMetaDataDto "Client metadata"
|
||||
// @Router /api/oidc/clients/{id}/meta [get]
|
||||
func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
|
||||
func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) error {
|
||||
clientId := c.Param("id")
|
||||
client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
clientDto := dto.OidcClientMetaDataDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = c.Error(err)
|
||||
clientDto.HasDarkLogo = client.HasDarkLogo()
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClientHandler godoc
|
||||
@@ -92,22 +90,21 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
|
||||
// @Router /api/oidc/clients/{id} [get]
|
||||
func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||
func (oc *OidcController) getClientHandler(c *gin.Context) error {
|
||||
clientId := c.Param("id")
|
||||
client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listClientsHandler godoc
|
||||
@@ -121,14 +118,13 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
|
||||
// @Router /api/oidc/clients [get]
|
||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
func (oc *OidcController) listClientsHandler(c *gin.Context) error {
|
||||
searchTerm := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Map the user groups to DTOs
|
||||
@@ -136,14 +132,13 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
for i, client := range clients {
|
||||
var clientDto dto.OidcClientWithAllowedGroupsCountDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
clientDto.HasDarkLogo = client.HasDarkLogo()
|
||||
|
||||
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
clientsDto[i] = clientDto
|
||||
}
|
||||
@@ -152,6 +147,7 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
Data: clientsDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// createClientHandler godoc
|
||||
@@ -163,26 +159,24 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
// @Param client body dto.OidcClientCreateDto true "Client information"
|
||||
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
|
||||
// @Router /api/oidc/clients [post]
|
||||
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||
func (oc *OidcController) createClientHandler(c *gin.Context) error {
|
||||
var input dto.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := oc.oidcService.CreateClient(c.Request.Context(), input, c.GetString("userID"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, clientDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteClientHandler godoc
|
||||
@@ -192,14 +186,14 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/oidc/clients/{id} [delete]
|
||||
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
func (oc *OidcController) deleteClientHandler(c *gin.Context) error {
|
||||
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateClientHandler godoc
|
||||
@@ -212,26 +206,24 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
// @Param client body dto.OidcClientUpdateDto true "Client information"
|
||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
|
||||
// @Router /api/oidc/clients/{id} [put]
|
||||
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
func (oc *OidcController) updateClientHandler(c *gin.Context) error {
|
||||
var input dto.OidcClientUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := oc.oidcService.UpdateClient(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshClientMetadataHandler godoc
|
||||
@@ -242,20 +234,20 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Refreshed client"
|
||||
// @Router /api/oidc/clients/{id}/refresh [post]
|
||||
func (oc *OidcController) refreshClientMetadataHandler(c *gin.Context) {
|
||||
func (oc *OidcController) refreshClientMetadataHandler(c *gin.Context) error {
|
||||
client, err := oc.oidcService.RefreshClientMetadata(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
clientDto.HasDarkLogo = client.HasDarkLogo()
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createClientSecretHandler godoc
|
||||
@@ -268,20 +260,19 @@ func (oc *OidcController) refreshClientMetadataHandler(c *gin.Context) {
|
||||
// @Param payload body dto.OidcClientSecretDto false "Client secret"
|
||||
// @Success 200 {object} object "{ \"secret\": \"string\" }"
|
||||
// @Router /api/oidc/clients/{id}/secret [post]
|
||||
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||
func (oc *OidcController) createClientSecretHandler(c *gin.Context) error {
|
||||
var input dto.OidcClientSecretDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil && !errors.Is(err, io.EOF) {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindOptionalJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"secret": secret})
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClientLogoHandler godoc
|
||||
@@ -295,13 +286,12 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||
// @Success 200 {file} binary "Logo image"
|
||||
// @Router /api/oidc/clients/{id}/logo [get]
|
||||
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||
func (oc *OidcController) getClientLogoHandler(c *gin.Context) error {
|
||||
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
|
||||
|
||||
reader, size, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"), lightLogo)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
@@ -309,6 +299,7 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||
|
||||
c.Header("Content-Type", mimeType)
|
||||
c.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateClientLogoHandler godoc
|
||||
@@ -321,22 +312,21 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/oidc/clients/{id}/logo [post]
|
||||
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) error {
|
||||
file, err := httpserver.FormFile(c, "file")
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
|
||||
|
||||
err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file, lightLogo)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteClientLogoHandler godoc
|
||||
@@ -347,7 +337,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/oidc/clients/{id}/logo [delete]
|
||||
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) error {
|
||||
var err error
|
||||
|
||||
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
|
||||
@@ -358,11 +348,11 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateAllowedUserGroupsHandler godoc
|
||||
@@ -375,27 +365,25 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
|
||||
// @Success 200 {object} dto.OidcClientDto "Updated client"
|
||||
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
|
||||
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
||||
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) error {
|
||||
var input dto.OidcUpdateAllowedUserGroupsDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var oidcClientDto dto.OidcClientDto
|
||||
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
oidcClientDto.HasDarkLogo = oidcClient.HasDarkLogo()
|
||||
|
||||
c.JSON(http.StatusOK, oidcClientDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listOwnAuthorizedClientsHandler godoc
|
||||
@@ -408,9 +396,9 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
|
||||
// @Router /api/oidc/users/me/authorized-clients [get]
|
||||
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
|
||||
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) error {
|
||||
userID := c.GetString("userID")
|
||||
oc.listAuthorizedClients(c, userID)
|
||||
return oc.listAuthorizedClients(c, userID)
|
||||
}
|
||||
|
||||
// listAuthorizedClientsHandler godoc
|
||||
@@ -424,31 +412,30 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
|
||||
// @Router /api/oidc/users/{id}/authorized-clients [get]
|
||||
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
|
||||
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) error {
|
||||
userID := c.Param("id")
|
||||
oc.listAuthorizedClients(c, userID)
|
||||
return oc.listAuthorizedClients(c, userID)
|
||||
}
|
||||
|
||||
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
|
||||
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) error {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Map the clients to DTOs
|
||||
var authorizedClientsDto []dto.AuthorizedOidcClientDto
|
||||
if err := dto.MapStructList(authorizedClients, &authorizedClientsDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.AuthorizedOidcClientDto]{
|
||||
Data: authorizedClientsDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// revokeOwnClientAuthorizationHandler godoc
|
||||
@@ -458,18 +445,18 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
|
||||
// @Param clientId path string true "Client ID to revoke authorization for"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/oidc/users/me/authorized-clients/{clientId} [delete]
|
||||
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
|
||||
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) error {
|
||||
clientID := c.Param("clientId")
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listOwnAccessibleClientsHandler godoc
|
||||
@@ -482,21 +469,21 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
|
||||
// @Router /api/oidc/users/me/clients [get]
|
||||
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
|
||||
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) error {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{
|
||||
Data: clients,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClientPreviewHandler godoc
|
||||
@@ -510,24 +497,21 @@ func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
|
||||
// @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/clients/{id}/preview/{userId} [get]
|
||||
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
|
||||
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) error {
|
||||
clientID := c.Param("id")
|
||||
userID := c.Param("userId")
|
||||
scopes := c.Query("scopes")
|
||||
|
||||
if clientID == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "client ID is required"})
|
||||
return
|
||||
return apperror.MissingField("clientId")
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "user ID is required"})
|
||||
return
|
||||
return apperror.MissingField("userId")
|
||||
}
|
||||
|
||||
if scopes == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "scopes are required"})
|
||||
return
|
||||
return apperror.MissingField("scopes")
|
||||
}
|
||||
|
||||
preview, err := oc.oidcService.GetClientPreview(
|
||||
@@ -538,11 +522,11 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
|
||||
c.GetString("authenticationMethod"))
|
||||
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, preview)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClientScimServiceProviderHandler godoc
|
||||
@@ -553,20 +537,19 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} dto.ScimServiceProviderDTO "SCIM service provider configuration"
|
||||
// @Router /api/oidc/clients/{id}/scim-service-provider [get]
|
||||
func (oc *OidcController) getClientScimServiceProviderHandler(c *gin.Context) {
|
||||
func (oc *OidcController) getClientScimServiceProviderHandler(c *gin.Context) error {
|
||||
clientID := c.Param("id")
|
||||
|
||||
provider, err := oc.oidcService.GetClientScimServiceProvider(c.Request.Context(), clientID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var providerDto dto.ScimServiceProviderDTO
|
||||
if err := dto.MapStruct(provider, &providerDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, providerDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
@@ -14,10 +15,10 @@ func NewScimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
scimService: scimService,
|
||||
}
|
||||
|
||||
group.POST("/scim/service-provider", authMiddleware.Add(), ugc.createServiceProviderHandler)
|
||||
group.POST("/scim/service-provider/:id/sync", authMiddleware.Add(), ugc.syncServiceProviderHandler)
|
||||
group.PUT("/scim/service-provider/:id", authMiddleware.Add(), ugc.updateServiceProviderHandler)
|
||||
group.DELETE("/scim/service-provider/:id", authMiddleware.Add(), ugc.deleteServiceProviderHandler)
|
||||
group.POST("/scim/service-provider", authMiddleware.Add(), httpserver.Handle(ugc.createServiceProviderHandler))
|
||||
group.POST("/scim/service-provider/:id/sync", authMiddleware.Add(), httpserver.Handle(ugc.syncServiceProviderHandler))
|
||||
group.PUT("/scim/service-provider/:id", authMiddleware.Add(), httpserver.Handle(ugc.updateServiceProviderHandler))
|
||||
group.DELETE("/scim/service-provider/:id", authMiddleware.Add(), httpserver.Handle(ugc.deleteServiceProviderHandler))
|
||||
}
|
||||
|
||||
type ScimController struct {
|
||||
@@ -31,14 +32,14 @@ type ScimController struct {
|
||||
// @Param id path string true "Service Provider ID"
|
||||
// @Success 200 "OK"
|
||||
// @Router /api/scim/service-provider/{id}/sync [post]
|
||||
func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) {
|
||||
func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) error {
|
||||
err := c.scimService.SyncServiceProvider(ctx.Request.Context(), ctx.Param("id"))
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createServiceProviderHandler godoc
|
||||
@@ -50,26 +51,24 @@ func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) {
|
||||
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
|
||||
// @Success 201 {object} dto.ScimServiceProviderDTO "Created SCIM service provider"
|
||||
// @Router /api/scim/service-provider [post]
|
||||
func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) {
|
||||
func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) error {
|
||||
var input dto.ScimServiceProviderCreateDTO
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(ctx, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
provider, err := c.scimService.CreateServiceProvider(ctx.Request.Context(), &input)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var providerDTO dto.ScimServiceProviderDTO
|
||||
if err := dto.MapStruct(provider, &providerDTO); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, providerDTO)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateServiceProviderHandler godoc
|
||||
@@ -82,26 +81,24 @@ func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) {
|
||||
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
|
||||
// @Success 200 {object} dto.ScimServiceProviderDTO "Updated SCIM service provider"
|
||||
// @Router /api/scim/service-provider/{id} [put]
|
||||
func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) {
|
||||
func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) error {
|
||||
var input dto.ScimServiceProviderCreateDTO
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(ctx, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
provider, err := c.scimService.UpdateServiceProvider(ctx.Request.Context(), ctx.Param("id"), &input)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var providerDTO dto.ScimServiceProviderDTO
|
||||
if err := dto.MapStruct(provider, &providerDTO); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, providerDTO)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteServiceProviderHandler godoc
|
||||
@@ -111,12 +108,12 @@ func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) {
|
||||
// @Param id path string true "Service Provider ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/scim/service-provider/{id} [delete]
|
||||
func (c *ScimController) deleteServiceProviderHandler(ctx *gin.Context) {
|
||||
func (c *ScimController) deleteServiceProviderHandler(ctx *gin.Context) error {
|
||||
err := c.scimService.DeleteServiceProvider(ctx.Request.Context(), ctx.Param("id"))
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
@@ -26,26 +27,26 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
webAuthnService: webAuthnService,
|
||||
}
|
||||
|
||||
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.GET("/users/:id/webauthn-credentials", authMiddleware.Add(), uc.listUserWebauthnCredentialsHandler)
|
||||
group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler)
|
||||
group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler)
|
||||
group.DELETE("/users/:id/webauthn-credentials/:credentialId", authMiddleware.Add(), uc.deleteUserWebauthnCredentialHandler)
|
||||
group.GET("/users", authMiddleware.Add(), httpserver.Handle(uc.listUsersHandler))
|
||||
group.GET("/users/me", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(uc.getCurrentUserHandler))
|
||||
group.GET("/users/:id", authMiddleware.Add(), httpserver.Handle(uc.getUserHandler))
|
||||
group.POST("/users", authMiddleware.Add(), httpserver.Handle(uc.createUserHandler))
|
||||
group.PUT("/users/:id", authMiddleware.Add(), httpserver.Handle(uc.updateUserHandler))
|
||||
group.GET("/users/:id/groups", authMiddleware.Add(), httpserver.Handle(uc.getUserGroupsHandler))
|
||||
group.GET("/users/:id/webauthn-credentials", authMiddleware.Add(), httpserver.Handle(uc.listUserWebauthnCredentialsHandler))
|
||||
group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(uc.updateCurrentUserHandler))
|
||||
group.DELETE("/users/:id", authMiddleware.Add(), httpserver.Handle(uc.deleteUserHandler))
|
||||
group.DELETE("/users/:id/webauthn-credentials/:credentialId", authMiddleware.Add(), httpserver.Handle(uc.deleteUserWebauthnCredentialHandler))
|
||||
|
||||
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
|
||||
group.PUT("/users/:id/user-groups", authMiddleware.Add(), httpserver.Handle(uc.updateUserGroups))
|
||||
|
||||
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
|
||||
group.GET("/users/:id/profile-picture.png", httpserver.Handle(uc.getUserProfilePictureHandler))
|
||||
|
||||
group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler)
|
||||
group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler)
|
||||
group.PUT("/users/:id/profile-picture", authMiddleware.Add(), httpserver.Handle(uc.updateUserProfilePictureHandler))
|
||||
group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(uc.updateCurrentUserProfilePictureHandler))
|
||||
|
||||
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
||||
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
||||
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), httpserver.Handle(uc.resetUserProfilePictureHandler))
|
||||
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(uc.resetCurrentUserProfilePictureHandler))
|
||||
}
|
||||
|
||||
type UserController struct {
|
||||
@@ -61,21 +62,20 @@ type UserController struct {
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {array} dto.UserGroupDto
|
||||
// @Router /api/users/{id}/groups [get]
|
||||
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
||||
func (uc *UserController) getUserGroupsHandler(c *gin.Context) error {
|
||||
userID := c.Param("id")
|
||||
groups, err := uc.userService.GetUserGroups(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var groupsDto []dto.UserGroupDto
|
||||
if err := dto.MapStructList(groups, &groupsDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, groupsDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listUserWebauthnCredentialsHandler godoc
|
||||
@@ -85,27 +85,25 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {array} dto.WebauthnCredentialDto
|
||||
// @Router /api/users/{id}/webauthn-credentials [get]
|
||||
func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) {
|
||||
func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) error {
|
||||
userID := c.Param("id")
|
||||
|
||||
if _, err := uc.userService.GetUser(c.Request.Context(), userID); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
credentials, err := uc.webAuthnService.ListCredentials(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var credentialDtos []dto.WebauthnCredentialDto
|
||||
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, credentialDtos)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listUsersHandler godoc
|
||||
@@ -119,26 +117,25 @@ func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) {
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.UserDto]
|
||||
// @Router /api/users [get]
|
||||
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||
func (uc *UserController) listUsersHandler(c *gin.Context) error {
|
||||
searchTerm := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var usersDto []dto.UserDto
|
||||
if err := dto.MapStructList(users, &usersDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.UserDto]{
|
||||
Data: usersDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// getUserHandler godoc
|
||||
@@ -148,20 +145,19 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/users/{id} [get]
|
||||
func (uc *UserController) getUserHandler(c *gin.Context) {
|
||||
func (uc *UserController) getUserHandler(c *gin.Context) error {
|
||||
user, err := uc.userService.GetUser(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentUserHandler godoc
|
||||
@@ -170,20 +166,19 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
|
||||
// @Tags Users
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/users/me [get]
|
||||
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
||||
func (uc *UserController) getCurrentUserHandler(c *gin.Context) error {
|
||||
user, err := uc.userService.GetUser(c.Request.Context(), c.GetString("userID"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteUserHandler godoc
|
||||
@@ -193,19 +188,18 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/{id} [delete]
|
||||
func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||
func (uc *UserController) deleteUserHandler(c *gin.Context) error {
|
||||
dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := uc.userService.DeleteUser(c.Request.Context(), dbConfig, c.Param("id"), false); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteUserWebauthnCredentialHandler godoc
|
||||
@@ -216,7 +210,7 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||
// @Param credentialId path string true "Credential ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/{id}/webauthn-credentials/{credentialId} [delete]
|
||||
func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) {
|
||||
func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) error {
|
||||
err := uc.webAuthnService.DeleteCredential(
|
||||
c.Request.Context(),
|
||||
c.Param("id"),
|
||||
@@ -226,11 +220,11 @@ func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) {
|
||||
c.GetString("userID"),
|
||||
)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createUserHandler godoc
|
||||
@@ -240,32 +234,29 @@ func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) {
|
||||
// @Param user body dto.UserCreateDto true "User information"
|
||||
// @Success 201 {object} dto.UserDto
|
||||
// @Router /api/users [post]
|
||||
func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||
func (uc *UserController) createUserHandler(c *gin.Context) error {
|
||||
dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
var input dto.UserCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := uc.userService.CreateUser(c.Request.Context(), dbConfig, input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, userDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateUserHandler godoc
|
||||
@@ -276,8 +267,8 @@ func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||
// @Param user body dto.UserCreateDto true "User information"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/users/{id} [put]
|
||||
func (uc *UserController) updateUserHandler(c *gin.Context) {
|
||||
uc.updateUser(c, false)
|
||||
func (uc *UserController) updateUserHandler(c *gin.Context) error {
|
||||
return uc.updateUser(c, false)
|
||||
}
|
||||
|
||||
// updateCurrentUserHandler godoc
|
||||
@@ -287,8 +278,8 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
|
||||
// @Param user body dto.UserCreateDto true "User information"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/users/me [put]
|
||||
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
||||
uc.updateUser(c, true)
|
||||
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) error {
|
||||
return uc.updateUser(c, true)
|
||||
}
|
||||
|
||||
// getUserProfilePictureHandler godoc
|
||||
@@ -299,13 +290,12 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {file} binary "PNG image"
|
||||
// @Router /api/users/{id}/profile-picture.png [get]
|
||||
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
|
||||
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) error {
|
||||
userID := c.Param("id")
|
||||
|
||||
picture, size, err := uc.userService.GetProfilePicture(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
if picture != nil {
|
||||
defer picture.Close()
|
||||
@@ -314,6 +304,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
|
||||
utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour)
|
||||
|
||||
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateUserProfilePictureHandler godoc
|
||||
@@ -326,26 +317,24 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
|
||||
// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/{id}/profile-picture [put]
|
||||
func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
|
||||
func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) error {
|
||||
userID := c.Param("id")
|
||||
fileHeader, err := c.FormFile("file")
|
||||
fileHeader, err := httpserver.FormFile(c, "file")
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateCurrentUserProfilePictureHandler godoc
|
||||
@@ -357,26 +346,24 @@ func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
|
||||
// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/me/profile-picture [put]
|
||||
func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) {
|
||||
func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) error {
|
||||
userID := c.GetString("userID")
|
||||
fileHeader, err := c.FormFile("file")
|
||||
fileHeader, err := httpserver.FormFile(c, "file")
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateUserGroups godoc
|
||||
@@ -387,40 +374,36 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
|
||||
// @Param groups body dto.UserUpdateUserGroupDto true "User group IDs"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/users/{id}/user-groups [put]
|
||||
func (uc *UserController) updateUserGroups(c *gin.Context) {
|
||||
func (uc *UserController) updateUserGroups(c *gin.Context) error {
|
||||
var input dto.UserUpdateUserGroupDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := uc.userService.UpdateUserGroups(c.Request.Context(), c.Param("id"), input.UserGroupIds)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateUser is an internal helper method, not exposed as an API endpoint
|
||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) error {
|
||||
dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
var input dto.UserCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var userID string
|
||||
@@ -432,17 +415,16 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
|
||||
user, err := uc.userService.UpdateUser(c.Request.Context(), dbConfig, userID, input, updateOwnUser, false)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetUserProfilePictureHandler godoc
|
||||
@@ -453,15 +435,15 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/{id}/profile-picture [delete]
|
||||
func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
|
||||
func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) error {
|
||||
userID := c.Param("id")
|
||||
|
||||
if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetCurrentUserProfilePictureHandler godoc
|
||||
@@ -471,13 +453,13 @@ func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/me/profile-picture [delete]
|
||||
func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) {
|
||||
func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) error {
|
||||
userID := c.GetString("userID")
|
||||
|
||||
if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
@@ -25,13 +26,13 @@ func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.A
|
||||
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)
|
||||
userGroupsGroup.PUT("/:id/allowed-oidc-clients", ugc.updateAllowedOidcClients)
|
||||
userGroupsGroup.GET("", httpserver.Handle(ugc.list))
|
||||
userGroupsGroup.GET("/:id", httpserver.Handle(ugc.get))
|
||||
userGroupsGroup.POST("", httpserver.Handle(ugc.create))
|
||||
userGroupsGroup.PUT("/:id", httpserver.Handle(ugc.update))
|
||||
userGroupsGroup.DELETE("/:id", httpserver.Handle(ugc.delete))
|
||||
userGroupsGroup.PUT("/:id/users", httpserver.Handle(ugc.updateUsers))
|
||||
userGroupsGroup.PUT("/:id/allowed-oidc-clients", httpserver.Handle(ugc.updateAllowedOidcClients))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,14 +52,13 @@ type UserGroupController struct {
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.UserGroupMinimalDto]
|
||||
// @Router /api/user-groups [get]
|
||||
func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
func (ugc *UserGroupController) list(c *gin.Context) error {
|
||||
searchTerm := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
groups, pagination, err := ugc.UserGroupService.List(c, searchTerm, listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Map the user groups to DTOs
|
||||
@@ -66,13 +66,11 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
for i, group := range groups {
|
||||
var groupDto dto.UserGroupMinimalDto
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(c.Request.Context(), group.ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
groupsDto[i] = groupDto
|
||||
}
|
||||
@@ -81,6 +79,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
Data: groupsDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// get godoc
|
||||
@@ -92,20 +91,19 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Success 200 {object} dto.UserGroupDto
|
||||
// @Router /api/user-groups/{id} [get]
|
||||
func (ugc *UserGroupController) get(c *gin.Context) {
|
||||
func (ugc *UserGroupController) get(c *gin.Context) error {
|
||||
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDto
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, groupDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// create godoc
|
||||
@@ -117,26 +115,24 @@ func (ugc *UserGroupController) get(c *gin.Context) {
|
||||
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||
// @Success 201 {object} dto.UserGroupDto "Created user group"
|
||||
// @Router /api/user-groups [post]
|
||||
func (ugc *UserGroupController) create(c *gin.Context) {
|
||||
func (ugc *UserGroupController) create(c *gin.Context) error {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.Create(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDto
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, groupDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// update godoc
|
||||
@@ -149,32 +145,29 @@ func (ugc *UserGroupController) create(c *gin.Context) {
|
||||
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||
// @Success 200 {object} dto.UserGroupDto "Updated user group"
|
||||
// @Router /api/user-groups/{id} [put]
|
||||
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||
func (ugc *UserGroupController) update(c *gin.Context) error {
|
||||
dbConfig, err := ugc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.Update(c.Request.Context(), dbConfig, c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDto
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, groupDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// delete godoc
|
||||
@@ -186,19 +179,18 @@ func (ugc *UserGroupController) update(c *gin.Context) {
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/user-groups/{id} [delete]
|
||||
func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||
func (ugc *UserGroupController) delete(c *gin.Context) error {
|
||||
dbConfig, err := ugc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := ugc.UserGroupService.Delete(c.Request.Context(), dbConfig, c.Param("id")); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateUsers godoc
|
||||
@@ -211,26 +203,24 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
|
||||
// @Success 200 {object} dto.UserGroupDto
|
||||
// @Router /api/user-groups/{id}/users [put]
|
||||
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||
func (ugc *UserGroupController) updateUsers(c *gin.Context) error {
|
||||
var input dto.UserGroupUpdateUsersDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.UpdateUsers(c.Request.Context(), c.Param("id"), input.UserIDs)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDto
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, groupDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateAllowedOidcClients godoc
|
||||
@@ -243,24 +233,22 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||
// @Param groups body dto.UserGroupUpdateAllowedOidcClientsDto true "OIDC client IDs to allow"
|
||||
// @Success 200 {object} dto.UserGroupDto "Updated user group"
|
||||
// @Router /api/user-groups/{id}/allowed-oidc-clients [put]
|
||||
func (ugc *UserGroupController) updateAllowedOidcClients(c *gin.Context) {
|
||||
func (ugc *UserGroupController) updateAllowedOidcClients(c *gin.Context) error {
|
||||
var input dto.UserGroupUpdateAllowedOidcClientsDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userGroup, err := ugc.UserGroupService.UpdateAllowedOidcClient(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var userGroupDto dto.UserGroupDto
|
||||
if err := dto.MapStruct(userGroup, &userGroupDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userGroupDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
@@ -14,8 +15,8 @@ import (
|
||||
// NewVersionController registers version-related routes.
|
||||
func NewVersionController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) {
|
||||
vc := &VersionController{versionService: versionService}
|
||||
group.GET("/version/latest", vc.getLatestVersionHandler)
|
||||
group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), vc.getCurrentVersionHandler)
|
||||
group.GET("/version/latest", httpserver.Handle(vc.getLatestVersionHandler))
|
||||
group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(vc.getCurrentVersionHandler))
|
||||
}
|
||||
|
||||
type VersionController struct {
|
||||
@@ -28,11 +29,10 @@ type VersionController struct {
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string "Latest version information"
|
||||
// @Router /api/version/latest [get]
|
||||
func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
|
||||
func (vc *VersionController) getLatestVersionHandler(c *gin.Context) error {
|
||||
tag, err := vc.versionService.GetLatestVersion(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute)
|
||||
@@ -40,6 +40,7 @@ func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"latestVersion": tag,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentVersionHandler godoc
|
||||
@@ -48,9 +49,9 @@ func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string "Current version information"
|
||||
// @Router /api/version/current [get]
|
||||
func (vc *VersionController) getCurrentVersionHandler(c *gin.Context) {
|
||||
|
||||
func (vc *VersionController) getCurrentVersionHandler(c *gin.Context) error {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"currentVersion": common.Version,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
@@ -21,8 +22,8 @@ func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtServi
|
||||
getCIMDURLAllowlist: getCIMDURLAllowlist,
|
||||
}
|
||||
|
||||
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
||||
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
|
||||
group.GET("/.well-known/jwks.json", httpserver.Handle(wkc.jwksHandler))
|
||||
group.GET("/.well-known/openid-configuration", httpserver.Handle(wkc.openIDConfigurationHandler))
|
||||
}
|
||||
|
||||
type WellKnownController struct {
|
||||
@@ -37,14 +38,14 @@ type WellKnownController struct {
|
||||
// @Produce json
|
||||
// @Success 200 {object} object "{ \"keys\": []interface{} }"
|
||||
// @Router /.well-known/jwks.json [get]
|
||||
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
||||
func (wkc *WellKnownController) jwksHandler(c *gin.Context) error {
|
||||
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", jwks)
|
||||
return nil
|
||||
}
|
||||
|
||||
// openIDConfigurationHandler godoc
|
||||
@@ -53,13 +54,13 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
||||
// @Tags Well Known
|
||||
// @Success 200 {object} object "OpenID Connect configuration"
|
||||
// @Router /.well-known/openid-configuration [get]
|
||||
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
||||
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) error {
|
||||
oidcConfig, err := wkc.computeOIDCConfiguration()
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", oidcConfig)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
@@ -32,11 +33,10 @@ func newHandler(service *Service, baseURL string, appConfig AppConfigProvider) *
|
||||
// @Produce json
|
||||
// @Success 201 {object} requestCreateDto "Created device login request"
|
||||
// @Router /api/device-login/requests [post]
|
||||
func (h *handler) createRequest(c *gin.Context) {
|
||||
func (h *handler) createRequest(c *gin.Context) error {
|
||||
request, deviceToken, err := h.service.Create(c.Request.Context(), c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
verificationURI := h.baseURL + "/device"
|
||||
@@ -50,6 +50,7 @@ func (h *handler) createRequest(c *gin.Context) {
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
Interval: PollingInterval,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// exchangeRequest godoc
|
||||
@@ -61,11 +62,10 @@ func (h *handler) createRequest(c *gin.Context) {
|
||||
// @Success 200 {object} dto.UserDto "Approved request exchanged for a user session"
|
||||
// @Success 202 "Authorization pending"
|
||||
// @Router /api/device-login/requests/{id}/exchange [post]
|
||||
func (h *handler) exchangeRequest(c *gin.Context) {
|
||||
func (h *handler) exchangeRequest(c *gin.Context) error {
|
||||
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
requestID := c.Param("id")
|
||||
@@ -76,20 +76,20 @@ func (h *handler) exchangeRequest(c *gin.Context) {
|
||||
if c.Request.Context().Err() != nil {
|
||||
// Context canceled = the client stopped the request
|
||||
// Nothing to do here
|
||||
return
|
||||
return c.Request.Context().Err()
|
||||
}
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
if status == RequestStatusPending {
|
||||
// Request is pending, so respond with a 202
|
||||
c.Status(http.StatusAccepted)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
maxAge := int(sessionDuration.Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
|
||||
c.JSON(http.StatusOK, dto.UserDto(user))
|
||||
return nil
|
||||
}
|
||||
|
||||
// inspectRequest godoc
|
||||
@@ -101,21 +101,20 @@ func (h *handler) exchangeRequest(c *gin.Context) {
|
||||
// @Param request body verificationDto true "Device login code"
|
||||
// @Success 200 {object} verificationInfoDto "Device login request details"
|
||||
// @Router /api/device-login/verification [post]
|
||||
func (h *handler) inspectRequest(c *gin.Context) {
|
||||
func (h *handler) inspectRequest(c *gin.Context) error {
|
||||
var input verificationDto
|
||||
err := c.ShouldBindJSON(&input)
|
||||
err := httpserver.BindJSON(c, &input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := h.service.Inspect(c.Request.Context(), input.Code)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, verificationInfoDto(info))
|
||||
return nil
|
||||
}
|
||||
|
||||
// decideRequest godoc
|
||||
@@ -126,20 +125,19 @@ func (h *handler) inspectRequest(c *gin.Context) {
|
||||
// @Param decision body decisionDto true "Device login decision"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/device-login/verification/decision [post]
|
||||
func (h *handler) decideRequest(c *gin.Context) {
|
||||
func (h *handler) decideRequest(c *gin.Context) error {
|
||||
var input decisionDto
|
||||
err := c.ShouldBindJSON(&input)
|
||||
err := httpserver.BindJSON(c, &input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
|
||||
err = h.service.Decide(c.Request.Context(), input.Code, input.Decision, c.GetString("userID"), reauthenticationToken)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
@@ -72,8 +73,8 @@ func New(deps Dependencies) (*Module, error) {
|
||||
|
||||
// RegisterRoutes mounts the public exchange and authenticated verification endpoints
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, browserAuth, createRateLimit, exchangeRateLimit, verificationRateLimit gin.HandlerFunc) {
|
||||
apiGroup.POST("/device-login/requests", createRateLimit, m.handler.createRequest)
|
||||
apiGroup.POST("/device-login/requests/:id/exchange", exchangeRateLimit, m.handler.exchangeRequest)
|
||||
apiGroup.POST("/device-login/verification", verificationRateLimit, browserAuth, m.handler.inspectRequest)
|
||||
apiGroup.POST("/device-login/verification/decision", verificationRateLimit, browserAuth, m.handler.decideRequest)
|
||||
apiGroup.POST("/device-login/requests", createRateLimit, httpserver.Handle(m.handler.createRequest))
|
||||
apiGroup.POST("/device-login/requests/:id/exchange", exchangeRateLimit, httpserver.Handle(m.handler.exchangeRequest))
|
||||
apiGroup.POST("/device-login/verification", verificationRateLimit, browserAuth, httpserver.Handle(m.handler.inspectRequest))
|
||||
apiGroup.POST("/device-login/verification/decision", verificationRateLimit, browserAuth, httpserver.Handle(m.handler.decideRequest))
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -154,7 +154,7 @@ func (s *Service) Decide(ctx context.Context, code, decision, userID, reauthenti
|
||||
|
||||
func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddress, userAgent string, sessionDuration time.Duration) (dto.UserDto, string, RequestStatus, error) {
|
||||
if requestID == "" || deviceToken == "" || sessionDuration <= 0 {
|
||||
return dto.UserDto{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
return dto.UserDto{}, "", "", apperror.DeviceLoginRequestInvalidOrExpired()
|
||||
}
|
||||
|
||||
deviceTokenHash := utils.CreateSha256Hash(deviceToken)
|
||||
@@ -212,9 +212,9 @@ func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddres
|
||||
case RequestStatusPending:
|
||||
// no-op
|
||||
case RequestStatusDenied:
|
||||
return dto.UserDto{}, "", result.Status, &common.DeviceLoginDeniedError{}
|
||||
return dto.UserDto{}, "", result.Status, apperror.DeviceLoginDenied()
|
||||
default:
|
||||
return dto.UserDto{}, "", "", &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
return dto.UserDto{}, "", "", apperror.DeviceLoginRequestInvalidOrExpired()
|
||||
}
|
||||
|
||||
select {
|
||||
@@ -230,7 +230,7 @@ func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddres
|
||||
|
||||
func (s *Service) consumeReauthenticationProof(ctx context.Context, token, userID string) error {
|
||||
if token == "" {
|
||||
return &common.ReauthenticationRequiredError{}
|
||||
return apperror.ReauthenticationRequired()
|
||||
}
|
||||
|
||||
tx := s.db.WithContext(ctx).Begin()
|
||||
@@ -241,14 +241,13 @@ func (s *Service) consumeReauthenticationProof(ctx context.Context, token, userI
|
||||
|
||||
reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, tx, token, userID)
|
||||
if err != nil {
|
||||
var reauthenticationRequiredError *common.ReauthenticationRequiredError
|
||||
if errors.As(err, &reauthenticationRequiredError) {
|
||||
return &common.ReauthenticationRequiredError{}
|
||||
if apperror.IsCode(err, apperror.CodeReauthenticationRequired) {
|
||||
return apperror.ReauthenticationRequired()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if time.Since(reauthenticatedAt) > reauthenticationMaxAge {
|
||||
return &common.ReauthenticationRequiredError{}
|
||||
return apperror.ReauthenticationRequired()
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
@@ -262,11 +261,11 @@ func (s *Service) loadExchangeUser(ctx context.Context, userID string) (model.Us
|
||||
err := s.db.WithContext(ctx).First(&user, "id = ?", userID).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return model.User{}, dto.UserDto{}, &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
return model.User{}, dto.UserDto{}, apperror.DeviceLoginRequestInvalidOrExpired()
|
||||
case err != nil:
|
||||
return model.User{}, dto.UserDto{}, err
|
||||
case user.Disabled:
|
||||
return model.User{}, dto.UserDto{}, &common.UserDisabledError{}
|
||||
return model.User{}, dto.UserDto{}, apperror.UserDisabled()
|
||||
}
|
||||
|
||||
var userDTO dto.UserDto
|
||||
@@ -320,9 +319,9 @@ func actorResultError(code requestActorResultCode) error {
|
||||
case requestActorResultCollision:
|
||||
return errors.New("unexpected live device login actor collision")
|
||||
case requestActorResultInvalid:
|
||||
return &common.DeviceLoginRequestInvalidOrExpiredError{}
|
||||
return apperror.DeviceLoginRequestInvalidOrExpired()
|
||||
case requestActorResultDenied:
|
||||
return &common.DeviceLoginDeniedError{}
|
||||
return apperror.DeviceLoginDenied()
|
||||
default:
|
||||
return fmt.Errorf("unsupported device login actor result %q", code)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
@@ -33,7 +33,7 @@ type fakeReauthenticationTokenConsumer struct {
|
||||
|
||||
func (f *fakeReauthenticationTokenConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, _ string) (time.Time, error) {
|
||||
if token != f.expectedValue {
|
||||
return time.Time{}, &common.ReauthenticationRequiredError{}
|
||||
return time.Time{}, apperror.ReauthenticationRequired()
|
||||
}
|
||||
if !f.createdAt.IsZero() {
|
||||
return f.createdAt, nil
|
||||
@@ -200,8 +200,7 @@ func TestPendingAndDeniedRequests(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
user, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
var deniedError *common.DeviceLoginDeniedError
|
||||
require.ErrorAs(t, err, &deniedError)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginDenied))
|
||||
require.Equal(t, RequestStatusDenied, status)
|
||||
require.Empty(t, user.ID)
|
||||
require.Empty(t, accessToken)
|
||||
@@ -228,8 +227,7 @@ func TestPendingExchangeObservesDecisionDuringLongPoll(t *testing.T) {
|
||||
|
||||
select {
|
||||
case outcome := <-result:
|
||||
var deniedError *common.DeviceLoginDeniedError
|
||||
require.ErrorAs(t, outcome.err, &deniedError)
|
||||
require.True(t, apperror.IsCode(outcome.err, apperror.CodeDeviceLoginDenied))
|
||||
require.Equal(t, RequestStatusDenied, outcome.status)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("exchange did not observe the actor decision")
|
||||
@@ -282,8 +280,7 @@ func TestRejectsDisabledUserAtExchange(t *testing.T) {
|
||||
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
|
||||
|
||||
_, accessToken, _, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
var disabledError *common.UserDisabledError
|
||||
require.ErrorAs(t, err, &disabledError)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeUserDisabled))
|
||||
require.Empty(t, accessToken)
|
||||
require.Equal(t, RequestStatusApproved, getRequestActorState(t, fixture.actors, request.ID).Status)
|
||||
}
|
||||
@@ -322,13 +319,12 @@ func TestApprovalRejectsMissingAndStaleReauthentication(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "")
|
||||
var reauthenticationError *common.ReauthenticationRequiredError
|
||||
require.ErrorAs(t, err, &reauthenticationError)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
|
||||
|
||||
fixture.reauth.expectedValue = "stale-proof"
|
||||
fixture.reauth.createdAt = time.Now().Add(-2 * time.Minute)
|
||||
err = fixture.service.Decide(t.Context(), request.Code, "approve", "device-login-user", "stale-proof")
|
||||
require.ErrorAs(t, err, &reauthenticationError)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
|
||||
require.Equal(t, RequestStatusPending, getRequestActorState(t, fixture.actors, request.ID).Status)
|
||||
}
|
||||
|
||||
@@ -374,8 +370,7 @@ func TestConcurrentExchangeAllowsOnlyOneSuccess(t *testing.T) {
|
||||
successfulTokens = append(successfulTokens, result.token)
|
||||
continue
|
||||
}
|
||||
var invalidRequestError *common.DeviceLoginRequestInvalidOrExpiredError
|
||||
require.ErrorAs(t, result.err, &invalidRequestError)
|
||||
require.True(t, apperror.IsCode(result.err, apperror.CodeDeviceLoginExpired))
|
||||
invalidExchanges++
|
||||
}
|
||||
require.Equal(t, []string{"device-login-access-token"}, successfulTokens)
|
||||
@@ -422,8 +417,7 @@ func TestRequestStateSurvivesActorHostRestart(t *testing.T) {
|
||||
require.Equal(t, "persistent-agent", strings.TrimPrefix(info.Device, "Parsed "))
|
||||
require.NoError(t, secondModule.service.Decide(t.Context(), request.Code, "deny", "device-login-user", ""))
|
||||
_, _, status, err := secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
|
||||
var deniedError *common.DeviceLoginDeniedError
|
||||
require.ErrorAs(t, err, &deniedError)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginDenied))
|
||||
require.Equal(t, RequestStatusDenied, status)
|
||||
}
|
||||
|
||||
@@ -527,8 +521,7 @@ func requireRequestActorStateDeleted(t *testing.T, actors *actor.Service, actorI
|
||||
|
||||
func assertInvalidRequestError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var invalidError *common.DeviceLoginRequestInvalidOrExpiredError
|
||||
require.ErrorAs(t, err, &invalidError)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginExpired))
|
||||
}
|
||||
|
||||
func persistentTestDependencies(db *gorm.DB) Dependencies {
|
||||
|
||||
@@ -3,72 +3,83 @@ package dto
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag.
|
||||
// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag
|
||||
func Normalize(obj any) {
|
||||
v := reflect.ValueOf(obj)
|
||||
if v.Kind() != reflect.Pointer || v.IsNil() {
|
||||
normalizeValue(reflect.ValueOf(obj))
|
||||
}
|
||||
|
||||
func normalizeValue(value reflect.Value) {
|
||||
if !value.IsValid() {
|
||||
return
|
||||
}
|
||||
v = v.Elem()
|
||||
|
||||
// Handle case where obj is a slice of models
|
||||
if v.Kind() == reflect.Slice {
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
elem := v.Index(i)
|
||||
if elem.Kind() == reflect.Pointer && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
|
||||
Normalize(elem.Interface())
|
||||
} else if elem.Kind() == reflect.Struct && elem.CanAddr() {
|
||||
Normalize(elem.Addr().Interface())
|
||||
}
|
||||
// Unwrap interfaces and pointers so nested DTOs share the same traversal
|
||||
kind := value.Kind()
|
||||
if kind == reflect.Interface || kind == reflect.Pointer {
|
||||
if !value.IsNil() {
|
||||
normalizeValue(value.Elem())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Struct {
|
||||
// Walk collections because request DTOs may contain nested slices or arrays
|
||||
if kind == reflect.Slice || kind == reflect.Array {
|
||||
for i := range value.Len() {
|
||||
normalizeValue(value.Index(i))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Iterate through all fields looking for those with the "unorm" tag
|
||||
t := v.Type()
|
||||
loop:
|
||||
for i := range t.NumField() {
|
||||
field := t.Field(i)
|
||||
// Ignore scalar values because normalization is opt-in through struct field tags
|
||||
if kind != reflect.Struct {
|
||||
return
|
||||
}
|
||||
|
||||
unormTag := field.Tag.Get("unorm")
|
||||
if unormTag == "" {
|
||||
// Normalize tagged fields directly and recursively inspect untagged nested values
|
||||
valueType := value.Type()
|
||||
for i := range value.NumField() {
|
||||
field := value.Field(i)
|
||||
form, tagged := normalizationForm(valueType.Field(i).Tag.Get("unorm"))
|
||||
if tagged {
|
||||
normalizeString(field, form)
|
||||
continue
|
||||
}
|
||||
|
||||
fv := v.Field(i)
|
||||
if !fv.CanSet() || fv.Kind() != reflect.String {
|
||||
continue
|
||||
}
|
||||
|
||||
var form norm.Form
|
||||
switch unormTag {
|
||||
case "nfc":
|
||||
form = norm.NFC
|
||||
case "nfkc":
|
||||
form = norm.NFKC
|
||||
case "nfd":
|
||||
form = norm.NFD
|
||||
case "nfkd":
|
||||
form = norm.NFKD
|
||||
default:
|
||||
continue loop
|
||||
}
|
||||
|
||||
val := fv.String()
|
||||
val = form.String(val)
|
||||
fv.SetString(val)
|
||||
normalizeValue(field)
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error {
|
||||
return ctx.ShouldBindWith(obj, binding.JSON)
|
||||
func normalizationForm(tag string) (norm.Form, bool) {
|
||||
switch tag {
|
||||
case "nfc":
|
||||
return norm.NFC, true
|
||||
case "nfkc":
|
||||
return norm.NFKC, true
|
||||
case "nfd":
|
||||
return norm.NFD, true
|
||||
case "nfkd":
|
||||
return norm.NFKD, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeString(value reflect.Value, form norm.Form) {
|
||||
// Dereference optional string fields while leaving nil values unchanged
|
||||
if value.Kind() == reflect.Pointer {
|
||||
if value.IsNil() {
|
||||
return
|
||||
}
|
||||
value = value.Elem()
|
||||
}
|
||||
|
||||
// Ignore incompatible or read-only fields because reflection cannot safely update them
|
||||
if value.Kind() != reflect.String || !value.CanSet() {
|
||||
return
|
||||
}
|
||||
|
||||
value.SetString(form.String(value.String()))
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'Username' failed on the 'required' tag",
|
||||
wantErr: "Field validation for 'username' failed on the 'required' tag",
|
||||
},
|
||||
{
|
||||
name: "missing first name",
|
||||
@@ -73,7 +73,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'Username' failed on the 'username' tag",
|
||||
wantErr: "Field validation for 'username' failed on the 'username' tag",
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
@@ -84,7 +84,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'Email' failed on the 'email' tag",
|
||||
wantErr: "Field validation for 'email' failed on the 'email' tag",
|
||||
},
|
||||
{
|
||||
name: "first name too short",
|
||||
@@ -106,7 +106,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'LastName' failed on the 'max' tag",
|
||||
wantErr: "Field validation for 'lastName' failed on the 'max' tag",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package dto
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -24,6 +25,15 @@ var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
|
||||
func init() {
|
||||
engine := binding.Validator.Engine().(*validator.Validate)
|
||||
|
||||
// Use JSON tags to keep client-visible validation field names stable
|
||||
engine.RegisterTagNameFunc(func(field reflect.StructField) string {
|
||||
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
|
||||
if name == "" || name == "-" {
|
||||
return field.Name
|
||||
}
|
||||
return name
|
||||
})
|
||||
|
||||
// Maximum allowed value for TTLs
|
||||
const maxTTL = 31 * 24 * time.Hour
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
@@ -69,12 +70,15 @@ func (m *Module) SendTestEmail(ctx context.Context, dbConfig *appconfig.AppConfi
|
||||
WithContext(ctx).
|
||||
First(&user, "id = ?", recipientUserID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperror.UserNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.Email == nil {
|
||||
return &common.UserEmailNotSetError{}
|
||||
return apperror.UserEmailNotSet()
|
||||
}
|
||||
|
||||
return send(ctx, m, dbConfig, address{
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
@@ -134,8 +134,16 @@ func TestSendTestEmailRequiresUserEmail(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
err = module.SendTestEmail(t.Context(), &appconfig.AppConfigModel{}, user.ID)
|
||||
var emailNotSetError *common.UserEmailNotSetError
|
||||
require.ErrorAs(t, err, &emailNotSetError)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeUserEmailNotSet))
|
||||
}
|
||||
|
||||
func TestSendTestEmailRejectsMissingUser(t *testing.T) {
|
||||
module, err := New(testutils.NewDatabaseForTest(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = module.SendTestEmail(t.Context(), &appconfig.AppConfigModel{}, "missing-user")
|
||||
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
|
||||
}
|
||||
|
||||
func TestSMTPConnStringPreservesConfiguration(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
@@ -25,20 +26,19 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler {
|
||||
// @Produce json
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/me/send-email-verification [post]
|
||||
func (h *handler) send(c *gin.Context) {
|
||||
func (h *handler) send(c *gin.Context) error {
|
||||
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
err = h.service.Send(c.Request.Context(), dbConfig, c.GetString("userID"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// verify godoc
|
||||
@@ -48,18 +48,17 @@ func (h *handler) send(c *gin.Context) {
|
||||
// @Param body body dto.EmailVerificationDto true "Email verification token"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/me/verify-email [post]
|
||||
func (h *handler) verify(c *gin.Context) {
|
||||
func (h *handler) verify(c *gin.Context) error {
|
||||
var input dto.EmailVerificationDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := h.service.Verify(c.Request.Context(), c.GetString("userID"), input.Token)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
)
|
||||
|
||||
type AppConfigResolver interface {
|
||||
@@ -45,6 +46,6 @@ func New(deps Dependencies) (*Module, error) {
|
||||
|
||||
// RegisterRoutes mounts the email verification endpoints
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, userAuth, sendRateLimit, verifyRateLimit gin.HandlerFunc) {
|
||||
apiGroup.POST("/users/me/send-email-verification", sendRateLimit, userAuth, m.handler.send)
|
||||
apiGroup.POST("/users/me/verify-email", verifyRateLimit, userAuth, m.handler.verify)
|
||||
apiGroup.POST("/users/me/send-email-verification", sendRateLimit, userAuth, httpserver.Handle(m.handler.send))
|
||||
apiGroup.POST("/users/me/verify-email", verifyRateLimit, userAuth, httpserver.Handle(m.handler.verify))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
@@ -50,7 +50,7 @@ func (s *Service) Send(ctx context.Context, dbConfig *appconfig.AppConfigModel,
|
||||
return err
|
||||
}
|
||||
if user.Email == nil {
|
||||
return &common.UserEmailNotSetError{}
|
||||
return apperror.UserEmailNotSet()
|
||||
}
|
||||
|
||||
token, err := utils.GenerateRandomAlphanumericString(32)
|
||||
@@ -104,7 +104,7 @@ func (s *Service) Verify(ctx context.Context, userID, token string) error {
|
||||
return fmt.Errorf("error decoding email verification actor response: %w", err)
|
||||
}
|
||||
if result.Status != consumeOK {
|
||||
return &common.InvalidEmailVerificationTokenError{}
|
||||
return apperror.InvalidEmailVerificationToken()
|
||||
}
|
||||
|
||||
// Update the user's email_verified field in the database
|
||||
@@ -123,7 +123,7 @@ func (s *Service) Verify(ctx context.Context, userID, token string) error {
|
||||
return update.Error
|
||||
}
|
||||
if update.RowsAffected != 1 {
|
||||
return &common.InvalidEmailVerificationTokenError{}
|
||||
return apperror.InvalidEmailVerificationToken()
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
@@ -142,8 +142,7 @@ func TestVerifyRejectsTokenAfterAddressChanges(t *testing.T) {
|
||||
}).Error)
|
||||
|
||||
err := service.Verify(t.Context(), user.ID, token)
|
||||
var invalidTokenError *common.InvalidEmailVerificationTokenError
|
||||
require.ErrorAs(t, err, &invalidTokenError)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid))
|
||||
|
||||
var updated model.User
|
||||
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)
|
||||
@@ -162,8 +161,7 @@ func TestVerifyDoesNotConsumeStateForWrongToken(t *testing.T) {
|
||||
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
|
||||
|
||||
err := service.Verify(t.Context(), user.ID, "wrong-verification-code")
|
||||
var invalidTokenError *common.InvalidEmailVerificationTokenError
|
||||
require.ErrorAs(t, err, &invalidTokenError)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid))
|
||||
|
||||
var state State
|
||||
require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state))
|
||||
@@ -187,8 +185,7 @@ func TestVerifyRejectsExpiredToken(t *testing.T) {
|
||||
}, time.Second, time.Millisecond)
|
||||
|
||||
err := service.Verify(t.Context(), user.ID, token)
|
||||
var invalidTokenError *common.InvalidEmailVerificationTokenError
|
||||
require.ErrorAs(t, err, &invalidTokenError)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid))
|
||||
|
||||
var updated model.User
|
||||
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)
|
||||
|
||||
69
backend/internal/httpserver/binding.go
Normal file
69
backend/internal/httpserver/binding.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
)
|
||||
|
||||
// BindJSON binds and normalizes a JSON request while distinguishing invalid input from internal failures
|
||||
func BindJSON(c *gin.Context, value any) error {
|
||||
err := classifyBindingError(c.ShouldBindJSON(value))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dto.Normalize(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// BindOptionalJSON accepts an empty body while normalizing valid input and classifying malformed JSON as invalid input
|
||||
func BindOptionalJSON(c *gin.Context, value any) error {
|
||||
err := c.ShouldBindJSON(value)
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err = classifyBindingError(err); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dto.Normalize(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormFile returns an uploaded file while classifying a missing field as request validation
|
||||
func FormFile(c *gin.Context, field string) (*multipart.FileHeader, error) {
|
||||
file, err := c.FormFile(field)
|
||||
if errors.Is(err, http.ErrMissingFile) {
|
||||
return nil, apperror.MissingField(field)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, apperror.InvalidRequestBody(err)
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func classifyBindingError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, ok := errors.AsType[binding.SliceValidationError](err); ok {
|
||||
return err
|
||||
}
|
||||
|
||||
return apperror.InvalidRequestBody(err)
|
||||
}
|
||||
85
backend/internal/httpserver/binding_test.go
Normal file
85
backend/internal/httpserver/binding_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
func TestBindJSONClassifiesMalformedBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(`{"name":`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
err := BindJSON(c, &input)
|
||||
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeInvalidRequestBody))
|
||||
require.Contains(t, err.Error(), "unexpected EOF")
|
||||
var appErr *apperror.Error
|
||||
require.ErrorAs(t, err, &appErr)
|
||||
require.NotContains(t, appErr.ClientMessage(), "unexpected end")
|
||||
}
|
||||
|
||||
func TestBindJSONNormalizesTaggedFieldsRecursively(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/",
|
||||
strings.NewReader(`{"name":"Cafe\u0301","email":"user@cafe\u0301.example","items":[{"label":"Re\u0301sume\u0301"}]}`),
|
||||
)
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
type embeddedInput struct {
|
||||
Name string `json:"name" unorm:"nfc"`
|
||||
}
|
||||
type itemInput struct {
|
||||
Label string `json:"label" unorm:"nfc"`
|
||||
}
|
||||
var input struct {
|
||||
embeddedInput
|
||||
Email *string `json:"email" unorm:"nfc"`
|
||||
Items []itemInput `json:"items"`
|
||||
}
|
||||
err := BindJSON(c, &input)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, norm.NFC.String("Café"), input.Name)
|
||||
require.NotNil(t, input.Email)
|
||||
require.Equal(t, norm.NFC.String("user@café.example"), *input.Email)
|
||||
require.Equal(t, norm.NFC.String("Résumé"), input.Items[0].Label)
|
||||
}
|
||||
|
||||
func TestFormFileClassifiesMissingField(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.Close())
|
||||
c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", &body)
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
_, err := FormFile(c, "file")
|
||||
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
|
||||
var appErr *apperror.Error
|
||||
require.ErrorAs(t, err, &appErr)
|
||||
require.Equal(t, []apperror.FieldError{{
|
||||
Field: "file",
|
||||
Code: "required",
|
||||
Message: "is required",
|
||||
}}, appErr.Fields())
|
||||
}
|
||||
46
backend/internal/httpserver/handler.go
Normal file
46
backend/internal/httpserver/handler.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HandlerFunc is an application HTTP handler that returns failures to the shared error middleware
|
||||
type HandlerFunc func(*gin.Context) error
|
||||
|
||||
// Handle adapts an error-returning application handler to Gin
|
||||
func Handle(handler HandlerFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve net/http's intentional request-abort behavior without logging it as an application panic
|
||||
if err, ok := recovered.(error); ok && errors.Is(err, http.ErrAbortHandler) {
|
||||
panic(recovered)
|
||||
}
|
||||
|
||||
// Keep recovered errors out of the unwrap chain so every panic is reported as an internal failure
|
||||
err := fmt.Errorf("panic in HTTP handler (%T): %v\n%s", recovered, recovered, debug.Stack())
|
||||
_ = c.Error(err)
|
||||
c.Abort()
|
||||
}()
|
||||
|
||||
if err := handler(c); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
_ = c.Error(err)
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
64
backend/internal/httpserver/handler_test.go
Normal file
64
backend/internal/httpserver/handler_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHandleAttachesAndAbortsOnError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
expected := errors.New("request failed")
|
||||
|
||||
Handle(func(*gin.Context) error {
|
||||
return expected
|
||||
})(c)
|
||||
|
||||
require.True(t, c.IsAborted())
|
||||
require.Len(t, c.Errors, 1)
|
||||
require.ErrorIs(t, c.Errors[0], expected)
|
||||
}
|
||||
|
||||
func TestHandleIgnoresCanceledRequests(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
Handle(func(*gin.Context) error {
|
||||
return context.Canceled
|
||||
})(c)
|
||||
|
||||
require.True(t, c.IsAborted())
|
||||
require.Empty(t, c.Errors)
|
||||
}
|
||||
|
||||
func TestHandleRecoversPanicsWithStack(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
Handle(func(*gin.Context) error {
|
||||
panic("private panic details")
|
||||
})(c)
|
||||
|
||||
require.True(t, c.IsAborted())
|
||||
require.Len(t, c.Errors, 1)
|
||||
require.ErrorContains(t, c.Errors[0], "panic in HTTP handler (string): private panic details")
|
||||
require.ErrorContains(t, c.Errors[0], "TestHandleRecoversPanicsWithStack")
|
||||
}
|
||||
|
||||
func TestHandlePreservesAbortHandlerPanic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
require.PanicsWithValue(t, http.ErrAbortHandler, func() {
|
||||
Handle(func(*gin.Context) error {
|
||||
panic(http.ErrAbortHandler)
|
||||
})(c)
|
||||
})
|
||||
require.Empty(t, c.Errors)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package middleware
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
@@ -39,15 +39,15 @@ func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userI
|
||||
|
||||
user, err := m.apiKeyModule.ValidateApiKey(c.Request.Context(), apiKey)
|
||||
if err != nil {
|
||||
return "", false, &common.NotSignedInError{}
|
||||
return "", false, apperror.NotSignedIn()
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
return "", false, &common.UserDisabledError{}
|
||||
return "", false, apperror.UserDisabled()
|
||||
}
|
||||
|
||||
if adminRequired && !user.IsAdmin {
|
||||
return "", false, &common.MissingPermissionError{}
|
||||
return "", false, apperror.MissingPermission()
|
||||
}
|
||||
|
||||
return user.ID, user.IsAdmin, nil
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
@@ -88,8 +86,8 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// If JWT auth failed and the error is not a NotSignedInError, abort the request
|
||||
if !errors.Is(err, &common.NotSignedInError{}) {
|
||||
// If JWT auth failed for a reason other than missing credentials, abort the request
|
||||
if !apperror.IsCode(err, apperror.CodeNotSignedIn) {
|
||||
c.Abort()
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -103,7 +101,7 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
||||
|
||||
c.Abort()
|
||||
if c.GetHeader("X-API-Key") != "" {
|
||||
_ = c.Error(&common.APIKeyAuthNotAllowedError{})
|
||||
_ = c.Error(apperror.APIKeyAuthNotAllowed())
|
||||
return
|
||||
}
|
||||
_ = c.Error(err)
|
||||
|
||||
@@ -1,125 +1,278 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"gorm.io/gorm"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const requestIDHeader = "X-Request-ID"
|
||||
|
||||
type requestIDContextKey struct{}
|
||||
type requestErrorCodeContextKey struct{}
|
||||
|
||||
// RequestID returns the identifier assigned to the current request
|
||||
func RequestID(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
requestID, exists := c.Get(requestIDContextKey{})
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, ok := requestID.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// RequestErrorCode returns the stable code of the error handled for the current request
|
||||
func RequestErrorCode(c *gin.Context) apperror.Code {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
code, exists := c.Get(requestErrorCodeContextKey{})
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, ok := code.(apperror.Code)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
type ErrorHandlerMiddleware struct{}
|
||||
|
||||
func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware {
|
||||
return &ErrorHandlerMiddleware{}
|
||||
}
|
||||
|
||||
type classifiedError struct {
|
||||
code apperror.Code
|
||||
status int
|
||||
message string
|
||||
details map[string]string
|
||||
fields []apperror.FieldError
|
||||
retryAfter time.Duration
|
||||
}
|
||||
|
||||
// Add records a request ID before executing the request and serializes the first returned error afterward
|
||||
func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := uuid.NewString()
|
||||
c.Set(requestIDContextKey{}, requestID)
|
||||
c.Header(requestIDHeader, requestID)
|
||||
|
||||
c.Next()
|
||||
for _, err := range c.Errors {
|
||||
// Check for record not found errors
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
errorResponse(c, http.StatusNotFound, "Record not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for validation errors
|
||||
var validationErrors validator.ValidationErrors
|
||||
if errors.As(err, &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
errorResponse(c, http.StatusBadRequest, message)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for slice validation errors
|
||||
svErr, ok := errors.AsType[binding.SliceValidationError](err)
|
||||
if ok {
|
||||
if errors.As(svErr[0], &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
errorResponse(c, http.StatusBadRequest, message)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// AppError with description
|
||||
appDescErr, ok := errors.AsType[common.AppErrorDescription](err)
|
||||
if ok {
|
||||
errorResponseWithDescription(c, appDescErr.HttpStatusCode(), appDescErr.Error(), appDescErr.Description())
|
||||
return
|
||||
}
|
||||
|
||||
// AppError (without description)
|
||||
appErr, ok := errors.AsType[common.AppError](err)
|
||||
if ok {
|
||||
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, errorResponseBody{
|
||||
Error: "Something went wrong",
|
||||
})
|
||||
if len(c.Errors) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := c.Errors[0].Err
|
||||
classified := classifyError(err)
|
||||
c.Set(requestErrorCodeContextKey{}, classified.code)
|
||||
logRequestError(c, err, classified, requestID)
|
||||
|
||||
if c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if classified.retryAfter > 0 {
|
||||
c.Header("Retry-After", formatRetryAfter(classified.retryAfter))
|
||||
}
|
||||
writeErrorResponse(c, classified, requestID)
|
||||
}
|
||||
}
|
||||
|
||||
type errorResponseBody struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
Error string `json:"error"`
|
||||
Code apperror.Code `json:"code"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
func errorResponse(c *gin.Context, statusCode int, message string) {
|
||||
// Capitalize the first letter of the message
|
||||
message = strings.ToUpper(message[:1]) + message[1:]
|
||||
c.JSON(statusCode, errorResponseBody{
|
||||
Error: message,
|
||||
})
|
||||
}
|
||||
|
||||
func errorResponseWithDescription(c *gin.Context, statusCode int, message string, description string) {
|
||||
// Capitalize the first letter of the message
|
||||
message = strings.ToUpper(message[:1]) + message[1:]
|
||||
c.JSON(statusCode, errorResponseBody{
|
||||
Error: message,
|
||||
ErrorDescription: description,
|
||||
})
|
||||
}
|
||||
|
||||
func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||
var errorMessages []string
|
||||
|
||||
for _, ve := range validationErrors {
|
||||
fieldName := ve.Field()
|
||||
var errorMessage string
|
||||
switch ve.Tag() {
|
||||
case "required":
|
||||
errorMessage = fmt.Sprintf("%s is required", fieldName)
|
||||
case "email":
|
||||
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
|
||||
case "username":
|
||||
errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
|
||||
case "url":
|
||||
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
|
||||
case "resource_uri":
|
||||
errorMessage = fmt.Sprintf("%s must be an absolute URI without whitespace or a fragment", fieldName)
|
||||
case "min":
|
||||
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
|
||||
case "max":
|
||||
errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param())
|
||||
default:
|
||||
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
|
||||
func classifyError(err error) classifiedError {
|
||||
var structuredErr *apperror.Error
|
||||
if errors.As(err, &structuredErr) && structuredErr != nil {
|
||||
return classifiedError{
|
||||
code: structuredErr.Code(),
|
||||
status: normalizeStatus(structuredErr.HTTPStatus()),
|
||||
message: capitalizeFirst(structuredErr.ClientMessage()),
|
||||
details: structuredErr.Details(),
|
||||
fields: structuredErr.Fields(),
|
||||
retryAfter: structuredErr.RetryAfter(),
|
||||
}
|
||||
|
||||
errorMessages = append(errorMessages, errorMessage)
|
||||
}
|
||||
|
||||
// Join all the error messages into a single string
|
||||
combinedErrors := strings.Join(errorMessages, ", ")
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return classifiedError{
|
||||
code: apperror.CodeRequestTimeout,
|
||||
status: http.StatusGatewayTimeout,
|
||||
message: "Request timed out",
|
||||
}
|
||||
}
|
||||
|
||||
return combinedErrors
|
||||
var validationErrors validator.ValidationErrors
|
||||
if errors.As(err, &validationErrors) && len(validationErrors) > 0 {
|
||||
return classifiedValidationError(validationErrors)
|
||||
}
|
||||
|
||||
var sliceValidationErrors binding.SliceValidationError
|
||||
if errors.As(err, &sliceValidationErrors) && len(sliceValidationErrors) > 0 {
|
||||
if errors.As(sliceValidationErrors[0], &validationErrors) {
|
||||
return classifiedValidationError(validationErrors)
|
||||
}
|
||||
}
|
||||
|
||||
return classifiedError{
|
||||
code: apperror.CodeInternal,
|
||||
status: http.StatusInternalServerError,
|
||||
message: "Something went wrong",
|
||||
}
|
||||
}
|
||||
|
||||
func classifiedValidationError(validationErrors validator.ValidationErrors) classifiedError {
|
||||
fields := make([]apperror.FieldError, 0, len(validationErrors))
|
||||
messages := make([]string, 0, len(validationErrors))
|
||||
|
||||
for _, validationError := range validationErrors {
|
||||
fieldName := validationError.Field()
|
||||
code, message := validationFieldError(validationError)
|
||||
fields = append(fields, apperror.FieldError{
|
||||
Field: fieldName,
|
||||
Code: code,
|
||||
Message: message,
|
||||
})
|
||||
messages = append(messages, fieldName+" "+message)
|
||||
}
|
||||
|
||||
return classifiedError{
|
||||
code: apperror.CodeValidationFailed,
|
||||
status: http.StatusBadRequest,
|
||||
message: capitalizeFirst(strings.Join(messages, ", ")),
|
||||
fields: fields,
|
||||
}
|
||||
}
|
||||
|
||||
func validationFieldError(validationError validator.FieldError) (string, string) {
|
||||
switch validationError.Tag() {
|
||||
case "required":
|
||||
return "required", "is required"
|
||||
case "email":
|
||||
return "invalid_format", "must be a valid email address"
|
||||
case "username":
|
||||
return "invalid_format", "must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character"
|
||||
case "url":
|
||||
return "invalid_format", "must be a valid URL"
|
||||
case "resource_uri":
|
||||
return "invalid_format", "must be an absolute URI without whitespace or a fragment"
|
||||
case "min":
|
||||
return "too_short", fmt.Sprintf("must be at least %s characters long", validationError.Param())
|
||||
case "max":
|
||||
return "too_long", fmt.Sprintf("must be at most %s characters long", validationError.Param())
|
||||
default:
|
||||
return validationError.Tag(), "is invalid"
|
||||
}
|
||||
}
|
||||
|
||||
func writeErrorResponse(c *gin.Context, classified classifiedError, requestID string) {
|
||||
details := make(map[string]any, len(classified.details)+1)
|
||||
for key, value := range classified.details {
|
||||
details[key] = value
|
||||
}
|
||||
if len(classified.fields) > 0 {
|
||||
details["fields"] = classified.fields
|
||||
}
|
||||
if len(details) == 0 {
|
||||
details = nil
|
||||
}
|
||||
|
||||
response := errorResponseBody{
|
||||
Error: classified.message,
|
||||
Code: classified.code,
|
||||
Details: details,
|
||||
RequestID: requestID,
|
||||
}
|
||||
|
||||
c.JSON(classified.status, response)
|
||||
}
|
||||
|
||||
func logRequestError(c *gin.Context, err error, classified classifiedError, requestID string) {
|
||||
if classified.status < http.StatusInternalServerError {
|
||||
return
|
||||
}
|
||||
|
||||
attrs := []any{
|
||||
slog.String("error_code", string(classified.code)),
|
||||
slog.String("error_type", errorTypeName(err)),
|
||||
slog.Int("http_status", classified.status),
|
||||
slog.String("request_id", requestID),
|
||||
slog.String("http_method", c.Request.Method),
|
||||
slog.String("http_path", c.Request.URL.Path),
|
||||
slog.Any("error", err),
|
||||
}
|
||||
if spanContext := trace.SpanFromContext(c.Request.Context()).SpanContext(); spanContext.IsValid() {
|
||||
attrs = append(attrs, slog.String("trace_id", spanContext.TraceID().String()))
|
||||
}
|
||||
|
||||
slog.ErrorContext(c.Request.Context(), "Request failed", attrs...)
|
||||
}
|
||||
|
||||
func errorTypeName(err error) string {
|
||||
if err == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
|
||||
return reflect.TypeOf(err).String()
|
||||
}
|
||||
|
||||
func normalizeStatus(status int) int {
|
||||
if status < http.StatusBadRequest || status > 599 {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func formatRetryAfter(retryAfter time.Duration) string {
|
||||
seconds := int(retryAfter / time.Second)
|
||||
if retryAfter%time.Second != 0 {
|
||||
seconds++
|
||||
}
|
||||
if seconds < 1 {
|
||||
seconds = 1
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d", seconds)
|
||||
}
|
||||
|
||||
func capitalizeFirst(message string) string {
|
||||
runes := []rune(message)
|
||||
if len(runes) == 0 {
|
||||
return message
|
||||
}
|
||||
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
180
backend/internal/middleware/error_handler_test.go
Normal file
180
backend/internal/middleware/error_handler_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestErrorHandlerMiddlewareStructuredError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(NewErrorHandlerMiddleware().Add())
|
||||
var handlerRequestID string
|
||||
router.GET("/users/1", httpserver.Handle(func(c *gin.Context) error {
|
||||
handlerRequestID = RequestID(c)
|
||||
cause := errors.New("database connection details")
|
||||
return apperror.Wrap(cause, apperror.CodeUserNotFound, http.StatusNotFound, "User not found")
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users/1", nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
Code apperror.Code `json:"code"`
|
||||
Details map[string]string `json:"details"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
|
||||
require.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
require.Equal(t, "User not found", body.Error)
|
||||
require.Equal(t, apperror.CodeUserNotFound, body.Code)
|
||||
require.Empty(t, body.Details)
|
||||
require.NotEmpty(t, body.RequestID)
|
||||
require.Equal(t, body.RequestID, handlerRequestID)
|
||||
require.Equal(t, body.RequestID, recorder.Header().Get(requestIDHeader))
|
||||
require.NotContains(t, recorder.Body.String(), "database connection details")
|
||||
}
|
||||
|
||||
func TestErrorHandlerMiddlewareHidesUnexpectedError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(NewErrorHandlerMiddleware().Add())
|
||||
router.GET("/failure", func(c *gin.Context) {
|
||||
_ = c.Error(errors.New("private database details"))
|
||||
c.Abort()
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/failure", nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
|
||||
require.Equal(t, http.StatusInternalServerError, recorder.Code)
|
||||
require.Equal(t, "Something went wrong", body["error"])
|
||||
require.Equal(t, string(apperror.CodeInternal), body["code"])
|
||||
require.NotContains(t, recorder.Body.String(), "private database details")
|
||||
require.NotEmpty(t, body["request_id"])
|
||||
}
|
||||
|
||||
func TestErrorHandlerMiddlewareHidesRecoveredPanic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(NewErrorHandlerMiddleware().Add())
|
||||
router.GET("/panic", httpserver.Handle(func(*gin.Context) error {
|
||||
panic("private panic details")
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/panic", nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
|
||||
require.Equal(t, http.StatusInternalServerError, recorder.Code)
|
||||
require.Equal(t, "Something went wrong", body["error"])
|
||||
require.Equal(t, string(apperror.CodeInternal), body["code"])
|
||||
require.NotContains(t, recorder.Body.String(), "private panic details")
|
||||
require.NotEmpty(t, body["request_id"])
|
||||
}
|
||||
|
||||
func TestErrorHandlerMiddlewareIncludesSafeDetails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(NewErrorHandlerMiddleware().Add())
|
||||
router.GET("/users", func(c *gin.Context) {
|
||||
_ = c.Error(apperror.AlreadyInUse("email"))
|
||||
c.Abort()
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users", nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
var body struct {
|
||||
Details map[string]string `json:"details"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
|
||||
require.Equal(t, map[string]string{"property": "email"}, body.Details)
|
||||
}
|
||||
|
||||
func TestErrorHandlerMiddlewareSetsRetryAfter(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(NewErrorHandlerMiddleware().Add())
|
||||
router.GET("/limited", func(c *gin.Context) {
|
||||
_ = c.Error(apperror.TooManyRequests().WithRetryAfter(1500 * time.Millisecond))
|
||||
c.Abort()
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/limited", nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, recorder.Code)
|
||||
require.Equal(t, "2", recorder.Header().Get("Retry-After"))
|
||||
}
|
||||
|
||||
func TestValidationResponseUsesJSONFieldNames(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(NewErrorHandlerMiddleware().Add())
|
||||
router.POST("/users", httpserver.Handle(func(c *gin.Context) error {
|
||||
var input dto.UserCreateDto
|
||||
return httpserver.BindJSON(c, &input)
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/users", strings.NewReader(`{}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
var body struct {
|
||||
Details struct {
|
||||
Fields []apperror.FieldError `json:"fields"`
|
||||
} `json:"details"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Contains(t, body.Details.Fields, apperror.FieldError{
|
||||
Field: "username",
|
||||
Code: "required",
|
||||
Message: "is required",
|
||||
})
|
||||
}
|
||||
|
||||
func TestClassifyUnmappedPersistenceErrorAsInternal(t *testing.T) {
|
||||
classified := classifyError(gorm.ErrRecordNotFound)
|
||||
|
||||
require.Equal(t, apperror.CodeInternal, classified.code)
|
||||
require.Equal(t, http.StatusInternalServerError, classified.status)
|
||||
}
|
||||
|
||||
func TestClassifyDeadlineAsRequestTimeout(t *testing.T) {
|
||||
classified := classifyError(context.DeadlineExceeded)
|
||||
|
||||
require.Equal(t, apperror.CodeRequestTimeout, classified.code)
|
||||
require.Equal(t, http.StatusGatewayTimeout, classified.status)
|
||||
require.Equal(t, "Request timed out", classified.message)
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
)
|
||||
|
||||
type FileSizeLimitMiddleware struct{}
|
||||
@@ -18,7 +20,13 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
|
||||
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
||||
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
|
||||
// Classify only size-limit failures as file_too_large so malformed multipart bodies remain invalid requests
|
||||
var maxBytesError *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesError) || errors.Is(err, multipart.ErrMessageTooLarge) {
|
||||
err = apperror.FileTooLarge(formatFileSize(maxSize))
|
||||
} else {
|
||||
err = apperror.InvalidRequestBody(err)
|
||||
}
|
||||
_ = c.Error(err)
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
66
backend/internal/middleware/file_size_limit_test.go
Normal file
66
backend/internal/middleware/file_size_limit_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFileSizeLimitMiddlewareClassifiesMultipartErrors(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("oversized body", func(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "large.png")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write(bytes.Repeat([]byte("x"), 128))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
recorder := serveMultipartRequest(t, body.Bytes(), writer.FormDataContentType(), 64)
|
||||
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
|
||||
require.Equal(t, apperror.CodeFileTooLarge, responseCode(t, recorder))
|
||||
})
|
||||
|
||||
t.Run("malformed body", func(t *testing.T) {
|
||||
recorder := serveMultipartRequest(t, []byte("not multipart"), "multipart/form-data; boundary=missing", 1024)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Equal(t, apperror.CodeInvalidRequestBody, responseCode(t, recorder))
|
||||
})
|
||||
}
|
||||
|
||||
func serveMultipartRequest(t *testing.T, body []byte, contentType string, maxSize int64) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(NewErrorHandlerMiddleware().Add())
|
||||
router.POST("/", NewFileSizeLimitMiddleware().Add(maxSize), func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func responseCode(t *testing.T, recorder *httptest.ResponseRecorder) apperror.Code {
|
||||
t.Helper()
|
||||
|
||||
var body struct {
|
||||
Code apperror.Code `json:"code"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
|
||||
return body.Code
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
@@ -44,37 +44,37 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject
|
||||
var ok bool
|
||||
_, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
|
||||
if !ok || accessToken == "" {
|
||||
return "", false, "", time.Time{}, &common.NotSignedInError{}
|
||||
return "", false, "", time.Time{}, apperror.NotSignedIn()
|
||||
}
|
||||
}
|
||||
|
||||
token, err := m.jwtService.VerifyAccessToken(accessToken)
|
||||
if err != nil {
|
||||
return "", false, "", time.Time{}, &common.NotSignedInError{}
|
||||
return "", false, "", time.Time{}, apperror.NotSignedIn()
|
||||
}
|
||||
authenticationMethod, err = m.jwtService.GetAuthenticationMethod(token)
|
||||
if err != nil {
|
||||
return "", false, "", time.Time{}, &common.NotSignedInError{}
|
||||
return "", false, "", time.Time{}, apperror.NotSignedIn()
|
||||
}
|
||||
authenticationTime, _ = token.IssuedAt()
|
||||
|
||||
subject, ok := token.Subject()
|
||||
if !ok {
|
||||
_ = c.Error(&common.TokenInvalidError{})
|
||||
return "", false, "", time.Time{}, &common.TokenInvalidError{}
|
||||
_ = c.Error(apperror.TokenInvalid())
|
||||
return "", false, "", time.Time{}, apperror.TokenInvalid()
|
||||
}
|
||||
|
||||
user, err := m.userService.GetUser(c, subject)
|
||||
if err != nil {
|
||||
return "", false, "", time.Time{}, &common.NotSignedInError{}
|
||||
return "", false, "", time.Time{}, apperror.NotSignedIn()
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
return "", false, "", time.Time{}, &common.UserDisabledError{}
|
||||
return "", false, "", time.Time{}, apperror.UserDisabled()
|
||||
}
|
||||
|
||||
if adminRequired && !user.IsAdmin {
|
||||
return "", false, "", time.Time{}, &common.MissingPermissionError{}
|
||||
return "", false, "", time.Time{}, apperror.MissingPermission()
|
||||
}
|
||||
|
||||
return subject, user.IsAdmin, authenticationMethod, authenticationTime, nil
|
||||
|
||||
@@ -3,16 +3,15 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/builtin/ratelimit"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
@@ -82,11 +81,12 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// A missing service means the policy was never registered on the actor host, which is a development-time errror
|
||||
// A missing service means the policy was never registered on the actor host, which is a development-time error
|
||||
svc := m.services[policy]
|
||||
if svc == nil {
|
||||
return func(c *gin.Context) {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
_ = c.Error(apperror.Internal(fmt.Errorf("rate limiter service is not configured for policy %q", policy)))
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,11 +113,8 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
// Advertise when the caller may retry, mapping the limiter's delay onto a Retry-After header
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds()))))
|
||||
}
|
||||
_ = c.Error(&common.TooManyRequestsError{})
|
||||
// Advertise when the caller may retry, mapping the limiter's delay onto a structured application error
|
||||
_ = c.Error(apperror.TooManyRequests().WithRetryAfter(retryAfter))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -144,6 +144,9 @@ func TestRateLimitMiddleware(t *testing.T) {
|
||||
t.Run("fails with 500 when the policy is not registered", func(t *testing.T) {
|
||||
// An unknown policy has no bound service, which is a configuration error surfaced as a 500
|
||||
r := newRateLimitRouter(t, services, "does-not-exist")
|
||||
require.Equal(t, http.StatusInternalServerError, doRateLimitRequest(t.Context(), r, "203.0.113.6").Code)
|
||||
w := doRateLimitRequest(t.Context(), r, "203.0.113.6")
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, w.Body.String(), `"code":"internal_error"`)
|
||||
assert.NotContains(t, w.Body.String(), "rate limiter service is not configured")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
@@ -124,8 +125,8 @@ func (h *authorizationHandler) completeInteraction(c *gin.Context) {
|
||||
typedAuthenticationTime, _ := authenticationTime.(time.Time)
|
||||
|
||||
var request completeInteractionRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
_ = c.Error(&common.ValidationError{Message: "invalid interaction request"})
|
||||
if err := httpserver.BindJSON(c, &request); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -140,6 +141,12 @@ func (h *authorizationHandler) completeInteraction(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) {
|
||||
// Keep authorization policy denials in Pocket ID so users can see why access was refused
|
||||
if errors.Is(err, fosite.ErrAccessDenied) {
|
||||
h.redirectToInteractionError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if ar.IsRedirectURIValid() {
|
||||
// Send the error to the client
|
||||
// fosite delivers the error through response_mode=form_post as well, so it needs the same CSP relaxation as the success path
|
||||
@@ -148,8 +155,10 @@ func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.C
|
||||
return
|
||||
}
|
||||
|
||||
// If no redirect URI is available, we can't send the error to the client,
|
||||
// so we redirect to a generic error page instead.
|
||||
h.redirectToInteractionError(c, err)
|
||||
}
|
||||
|
||||
func (h *authorizationHandler) redirectToInteractionError(c *gin.Context, err error) {
|
||||
errorMessage := "An unknown error occurred during the authorization request."
|
||||
if err, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
|
||||
if err.HintField != "" {
|
||||
@@ -159,7 +168,9 @@ func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.C
|
||||
}
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/interaction/error?error="+errorMessage)
|
||||
query := url.Values{}
|
||||
query.Set("error", errorMessage)
|
||||
c.Redirect(http.StatusFound, "/interaction/error?"+query.Encode())
|
||||
}
|
||||
|
||||
func requestMetaFromGin(c *gin.Context) requestMeta {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -119,7 +119,7 @@ func (s *authorizationService) authorize(ctx context.Context, input authorizeInp
|
||||
|
||||
// Reject authorization requests that require PAR when the request is not a resumed interaction and doesn't have a valid PAR
|
||||
if client.RequiresPushedAuthorizationRequests && !input.hasPushedAuthorizationRequest && interactionSession == nil {
|
||||
return authorizationResult{}, &common.OidcPARRequiredError{}
|
||||
return authorizationResult{}, apperror.OidcPARRequired()
|
||||
}
|
||||
|
||||
resource, err := input.requester.GetResource()
|
||||
@@ -209,6 +209,9 @@ func (s *authorizationService) authorizeAuthenticated(ctx context.Context, req a
|
||||
Preload("UserGroups").
|
||||
First(&user, "id = ?", req.userID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return authorizationResult{}, apperror.UserNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
@@ -510,6 +513,9 @@ func (s *authorizationService) switchInteractionSessionUser(ctx context.Context,
|
||||
|
||||
func (s *authorizationService) getInteractionSession(ctx context.Context, interactionSessionID string) (interactionSessionForUser, error) {
|
||||
interactionSession, err := s.interactionSessionService.get(ctx, interactionSessionID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return interactionSessionForUser{}, apperror.OidcInteractionNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return interactionSessionForUser{}, err
|
||||
}
|
||||
@@ -578,6 +584,9 @@ func (s *authorizationService) completeInteractionStep(ctx context.Context, inte
|
||||
err := withTx(ctx, s.db, func(ctx context.Context) error {
|
||||
var err error
|
||||
interactionSession, err = s.interactionSessionService.get(ctx, interactionSessionID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperror.OidcInteractionNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -599,7 +608,7 @@ func (s *authorizationService) completeInteractionStep(ctx context.Context, inte
|
||||
}
|
||||
|
||||
if requiredSteps[0] != step {
|
||||
return &common.ValidationError{Message: "expected interaction step " + string(requiredSteps[0]) + " but got " + string(step)}
|
||||
return apperror.ValidationMessage("expected interaction step " + string(requiredSteps[0]) + " but got " + string(step))
|
||||
}
|
||||
|
||||
if err := s.applyInteractionStep(ctx, &interactionSession, userID, step, reauthenticationToken, authenticationTime, meta); err != nil {
|
||||
@@ -646,7 +655,7 @@ func (s *authorizationService) applyInteractionStep(ctx context.Context, interac
|
||||
case interactionStepConsent:
|
||||
return s.completeConsentStep(ctx, interactionSession, userID, meta)
|
||||
default:
|
||||
return &common.ValidationError{Message: "unknown interaction step " + string(step)}
|
||||
return apperror.ValidationMessage("unknown interaction step " + string(step))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,7 +664,7 @@ func (s *authorizationService) completeReauthenticationStep(ctx context.Context,
|
||||
return err
|
||||
}
|
||||
if reauthenticationToken == "" {
|
||||
return &common.ValidationError{Message: "reauthentication token is required"}
|
||||
return apperror.MissingField("reauthenticationToken")
|
||||
}
|
||||
reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/ory/fosite"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
@@ -317,6 +317,17 @@ func TestInteractionSessionServiceGetRejectsExpiredSession(t *testing.T) {
|
||||
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
func TestInteractionAPIClassifiesMissingSession(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
_, err := service.getInteractionSession(t.Context(), "missing-interaction")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
|
||||
_, err = service.completeInteractionStep(t.Context(), "missing-interaction", "user", interactionStepConsent, "", time.Now().UTC(), requestMeta{})
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
@@ -735,8 +746,7 @@ func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
|
||||
|
||||
// Without a pushed authorization request the client must be rejected
|
||||
_, err := authorize("", false)
|
||||
var parRequiredError *common.OidcPARRequiredError
|
||||
require.ErrorAs(t, err, &parRequiredError)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeOidcPARRequired))
|
||||
|
||||
// Re-entry after a completed interaction carries no request_uri, but the bound
|
||||
// interaction session proves the original request was PAR-validated
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ func (h *deviceHandler) verifyDeviceCode(c *gin.Context) {
|
||||
|
||||
userCode := c.Query("code")
|
||||
if userCode == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "code is required"})
|
||||
_ = c.Error(apperror.MissingField("code"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func (h *deviceHandler) verifyDeviceCode(c *gin.Context) {
|
||||
func (h *deviceHandler) deviceCodeInfo(c *gin.Context) {
|
||||
userCode := c.Query("code")
|
||||
if userCode == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "code is required"})
|
||||
_ = c.Error(apperror.MissingField("code"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/ory/fosite/handler/rfc8628"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"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"
|
||||
@@ -93,12 +93,12 @@ func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID,
|
||||
// logged-in user from rebinding a pending device authorization to themselves before the device
|
||||
// polls for its token.
|
||||
if request.GetUserCodeState() != fosite.UserCodeUnused {
|
||||
return &common.OidcInvalidDeviceCodeError{}
|
||||
return apperror.OidcInvalidDeviceCode()
|
||||
}
|
||||
|
||||
client := request.GetClient().(Client)
|
||||
var user model.User
|
||||
if err = s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
|
||||
user, err := s.loadDeviceAuthorizationUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) {
|
||||
@@ -118,7 +118,7 @@ func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID,
|
||||
return withTx(ctx, s.db, func(ctx context.Context) error {
|
||||
if client.RequiresReauthentication {
|
||||
if reauthenticationToken == "" || s.authorizationService == nil || s.authorizationService.reauth == nil {
|
||||
return &common.ReauthenticationRequiredError{}
|
||||
return apperror.ReauthenticationRequired()
|
||||
}
|
||||
|
||||
reauthenticatedAt, err := s.authorizationService.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID)
|
||||
@@ -164,6 +164,25 @@ func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *deviceService) loadDeviceAuthorizationUser(ctx context.Context, userID string) (model.User, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var user model.User
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Preload("UserGroups").
|
||||
First(&user, "id = ?", userID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, apperror.UserNotFound()
|
||||
}
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID string) (*dto.DeviceCodeInfoDto, error) {
|
||||
request, _, err := s.deviceRequestFromUserCode(ctx, userCode)
|
||||
if err != nil {
|
||||
@@ -232,7 +251,7 @@ func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode
|
||||
|
||||
request, err := s.store.GetDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature)
|
||||
if errors.Is(err, fosite.ErrNotFound) {
|
||||
return nil, "", &common.OidcInvalidDeviceCodeError{}
|
||||
return nil, "", apperror.OidcInvalidDeviceCode()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
@@ -240,7 +259,7 @@ func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode
|
||||
|
||||
if err = s.userCodeStrategy.ValidateUserCode(ctx, request, userCode); err != nil {
|
||||
if errors.Is(err, fosite.ErrDeviceExpiredToken) {
|
||||
return nil, "", &common.OidcDeviceCodeExpiredError{}
|
||||
return nil, "", apperror.OidcDeviceCodeExpired()
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -13,13 +13,16 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// #nosec G101
|
||||
const testReauthenticationToken = "valid-reauth-token"
|
||||
|
||||
type fakeReauthenticationConsumer struct {
|
||||
token string
|
||||
userID string
|
||||
@@ -30,7 +33,7 @@ type fakeReauthenticationConsumer struct {
|
||||
func (f *fakeReauthenticationConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, userID string) (time.Time, error) {
|
||||
f.calls++
|
||||
if token != f.token || userID != f.userID {
|
||||
return time.Time{}, &common.ReauthenticationRequiredError{}
|
||||
return time.Time{}, apperror.ReauthenticationRequired()
|
||||
}
|
||||
|
||||
return f.reauthenticatedAt, nil
|
||||
@@ -41,15 +44,15 @@ func TestDeviceServiceAcceptRequiresReauthenticationTokenWhenClientRequiresIt(t
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
)
|
||||
reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential
|
||||
token: "valid-reauth-token",
|
||||
reauth := &fakeReauthenticationConsumer{
|
||||
token: testReauthenticationToken,
|
||||
userID: userID,
|
||||
reauthenticatedAt: time.Now().UTC().Truncate(time.Second),
|
||||
}
|
||||
service, _, _, userCode, _ := newTestDeviceServiceWithCode(t, clientID, userID, true, reauth)
|
||||
|
||||
err := service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().UTC(), "", requestMeta{})
|
||||
require.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
|
||||
require.Zero(t, reauth.calls)
|
||||
|
||||
info, err := service.getDeviceCodeInfo(t.Context(), userCode, userID)
|
||||
@@ -75,8 +78,8 @@ func TestDeviceServiceAcceptUsesReauthenticationTimeForDeviceSession(t *testing.
|
||||
clientID = "test-client"
|
||||
)
|
||||
reauthenticatedAt := time.Now().Add(-30 * time.Second).UTC().Truncate(time.Second)
|
||||
reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential
|
||||
token: "valid-reauth-token",
|
||||
reauth := &fakeReauthenticationConsumer{
|
||||
token: testReauthenticationToken,
|
||||
userID: userID,
|
||||
reauthenticatedAt: reauthenticatedAt,
|
||||
}
|
||||
@@ -153,7 +156,8 @@ func newTestDeviceService(t *testing.T, clientID, userID string, requiresReauthe
|
||||
store := NewStore(db, apiAccess)
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
provider, err := newProvider(store, nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(store, nil, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"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"
|
||||
@@ -34,35 +35,35 @@ func newEndSessionService(db *gorm.DB, store *Store, signer TokenSigner, baseURL
|
||||
// client's post-logout callback URL (empty if none is configured).
|
||||
func (s *endSessionService) endSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (string, error) {
|
||||
if input.IdTokenHint == "" {
|
||||
return "", &common.TokenInvalidError{}
|
||||
return "", apperror.TokenInvalid()
|
||||
}
|
||||
|
||||
token, err := s.verifyIDTokenHint(input.IdTokenHint)
|
||||
if err != nil {
|
||||
return "", &common.TokenInvalidError{}
|
||||
return "", apperror.TokenInvalid()
|
||||
}
|
||||
|
||||
clientIDs, ok := token.Audience()
|
||||
if !ok || len(clientIDs) == 0 {
|
||||
return "", &common.TokenInvalidError{}
|
||||
return "", apperror.TokenInvalid()
|
||||
}
|
||||
clientID := clientIDs[0]
|
||||
if input.ClientId != "" && clientID != input.ClientId {
|
||||
return "", &common.OidcClientIdNotMatchingError{}
|
||||
return "", apperror.OidcClientIDNotMatching()
|
||||
}
|
||||
|
||||
subject, ok := token.Subject()
|
||||
if !ok || subject == "" {
|
||||
return "", &common.TokenInvalidError{}
|
||||
return "", apperror.TokenInvalid()
|
||||
}
|
||||
if userID != "" && subject != userID {
|
||||
return "", &common.TokenInvalidError{}
|
||||
return "", apperror.TokenInvalid()
|
||||
}
|
||||
userID = subject
|
||||
|
||||
idTokenJTI, ok := token.JwtID()
|
||||
if !ok {
|
||||
return "", &common.TokenInvalidError{}
|
||||
return "", apperror.TokenInvalid()
|
||||
}
|
||||
|
||||
var callbackURL string
|
||||
@@ -74,7 +75,7 @@ func (s *endSessionService) endSession(ctx context.Context, input dto.OidcLogout
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &common.OidcMissingAuthorizationError{}
|
||||
return apperror.OidcMissingAuthorization()
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -117,7 +118,7 @@ func (s *endSessionService) verifyIDTokenHint(tokenString string) (jwt.Token, er
|
||||
// key). An expired ID token is still accepted here, as required by OIDC RP-Initiated Logout.
|
||||
var tokenType string
|
||||
if err := token.Get(common.TokenTypeClaim, &tokenType); err != nil || tokenType != idTokenType {
|
||||
return nil, &common.TokenInvalidError{}
|
||||
return nil, apperror.TokenInvalid()
|
||||
}
|
||||
|
||||
return token, nil
|
||||
@@ -133,7 +134,7 @@ func logoutCallbackURL(client *model.OidcClient, inputLogoutCallbackURL string)
|
||||
|
||||
matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
|
||||
if err != nil || matched == "" {
|
||||
return "", &common.OidcInvalidCallbackURLError{}
|
||||
return "", apperror.OidcInvalidCallbackURL()
|
||||
}
|
||||
|
||||
return matched, nil
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"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"
|
||||
@@ -58,8 +59,7 @@ func TestLogoutCallbackURL(t *testing.T) {
|
||||
|
||||
t.Run("unregistered URL is rejected (no open redirect)", func(t *testing.T) {
|
||||
_, err := logoutCallbackURL(withURLs, "https://evil.example/steal")
|
||||
var target *common.OidcInvalidCallbackURLError
|
||||
require.ErrorAs(t, err, &target)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeOidcInvalidCallbackURL))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,15 +144,13 @@ func TestEndSessionService(t *testing.T) {
|
||||
t.Run("missing id_token_hint is rejected", func(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{}, userID)
|
||||
var target *common.TokenInvalidError
|
||||
require.ErrorAs(t, err, &target)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken))
|
||||
})
|
||||
|
||||
t.Run("malformed id_token_hint is rejected", func(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: "not-a-jwt"}, userID)
|
||||
var target *common.TokenInvalidError
|
||||
require.ErrorAs(t, err, &target)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken))
|
||||
})
|
||||
|
||||
t.Run("token signed by a foreign key is rejected", func(t *testing.T) {
|
||||
@@ -166,8 +164,7 @@ func TestEndSessionService(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: string(signed)}, userID)
|
||||
var target *common.TokenInvalidError
|
||||
require.ErrorAs(t, err, &target)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken))
|
||||
})
|
||||
|
||||
t.Run("non-ID token (missing type claim) is rejected as id_token_hint", func(t *testing.T) {
|
||||
@@ -176,24 +173,21 @@ func TestEndSessionService(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
token := signToken(t, tokenOptions{issuer: baseURL, subject: userID, audience: clientID, jti: jti, omitType: true})
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
|
||||
var target *common.TokenInvalidError
|
||||
require.ErrorAs(t, err, &target)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken))
|
||||
})
|
||||
|
||||
t.Run("subject not matching the logged-in user is rejected", func(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
token := signToken(t, tokenOptions{issuer: baseURL, subject: "someone-else", audience: clientID, jti: jti})
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
|
||||
var target *common.TokenInvalidError
|
||||
require.ErrorAs(t, err, &target)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeInvalidToken))
|
||||
})
|
||||
|
||||
t.Run("client_id parameter not matching the token audience is rejected", func(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
token := signToken(t, validToken)
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token, ClientId: "different-client"}, userID)
|
||||
var target *common.OidcClientIdNotMatchingError
|
||||
require.ErrorAs(t, err, &target)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeOidcClientIDNotMatching))
|
||||
})
|
||||
|
||||
t.Run("user that never authorized the client is rejected", func(t *testing.T) {
|
||||
@@ -201,8 +195,7 @@ func TestEndSessionService(t *testing.T) {
|
||||
// A valid token for a user that has no authorization record for the client.
|
||||
token := signToken(t, tokenOptions{issuer: baseURL, subject: "ghost-user", audience: clientID, jti: jti})
|
||||
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, "ghost-user")
|
||||
var target *common.OidcMissingAuthorizationError
|
||||
require.ErrorAs(t, err, &target)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeOidcMissingAuthorization))
|
||||
})
|
||||
|
||||
t.Run("unregistered post_logout_redirect_uri is rejected", func(t *testing.T) {
|
||||
@@ -212,8 +205,7 @@ func TestEndSessionService(t *testing.T) {
|
||||
IdTokenHint: token,
|
||||
PostLogoutRedirectUri: "https://evil.example/steal",
|
||||
}, userID)
|
||||
var target *common.OidcInvalidCallbackURLError
|
||||
require.ErrorAs(t, err, &target)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeOidcInvalidCallbackURL))
|
||||
})
|
||||
|
||||
t.Run("valid logout returns the callback URL and revokes the sessions", func(t *testing.T) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"github.com/ory/fosite"
|
||||
)
|
||||
|
||||
const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
|
||||
const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" // #nosec G101 -- OAuth assertion type identifier, not a credential
|
||||
|
||||
var errNoFederatedClientAssertion = errors.New("no federated client assertion")
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ func TestIntrospectionHandlerBindsTokenToCallerClient(t *testing.T) {
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -142,7 +143,7 @@ func TestIntrospectionHandlerAllowsReusedFederatedClientAssertion(t *testing.T)
|
||||
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte("test-secret"),
|
||||
|
||||
@@ -17,7 +17,8 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) {
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -66,7 +67,8 @@ func TestClientPreviewBuilderIgnoresUnknownScopes(t *testing.T) {
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
|
||||
@@ -58,7 +58,8 @@ func TestProviderIssuesJWTAccessTokens(t *testing.T) {
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -139,7 +140,8 @@ func TestProviderInsecureCallbackURLCompatibility(t *testing.T) {
|
||||
CallbackURLs: datatype.StringList{"http://client.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -176,7 +178,8 @@ func TestProviderAcceptsWildcardRedirectURI(t *testing.T) {
|
||||
CallbackURLs: datatype.StringList{"https://*.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -209,7 +212,8 @@ func TestProviderAcceptsPushedAuthorizationWildcardRedirectURI(t *testing.T) {
|
||||
IsPublic: true,
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -241,7 +245,8 @@ func TestProviderRejectsUnmatchedWildcardRedirectURI(t *testing.T) {
|
||||
CallbackURLs: datatype.StringList{"https://*.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -281,7 +286,8 @@ func TestProviderAcceptsUnsignedRequestObject(t *testing.T) {
|
||||
CallbackURLs: datatype.StringList{"https://client.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -322,7 +328,8 @@ func TestProviderRejectsSignedRequestObject(t *testing.T) {
|
||||
CallbackURLs: datatype.StringList{"https://client.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -398,7 +405,8 @@ func TestProviderIssuesAndValidatesTokensForSupportedAlgorithms(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "test-client"}, Name: "Test Client"}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, algTestSigner{key: tc.gen(t), alg: tc.alg}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(NewStore(db, nil), nil, algTestSigner{key: tc.gen(t), alg: tc.alg}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -468,7 +476,8 @@ func TestProviderIgnoresUnknownScopes(t *testing.T) {
|
||||
CallbackURLs: datatype.StringList{"https://app.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
// #nosec G101
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
|
||||
@@ -7,8 +7,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
@@ -31,12 +32,11 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler {
|
||||
// @Param body body tokenCreateDto true "Token options"
|
||||
// @Success 201 {object} object "{ \"token\": \"string\" }"
|
||||
// @Router /api/users/{id}/one-time-access-token [post]
|
||||
func (h *handler) createTokenForUser(c *gin.Context) {
|
||||
func (h *handler) createTokenForUser(c *gin.Context) error {
|
||||
var input tokenCreateDto
|
||||
err := c.ShouldBindJSON(&input)
|
||||
err := httpserver.BindJSON(c, &input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the target user ID from the URL and apply the default expiration when no TTL is provided
|
||||
@@ -46,17 +46,16 @@ func (h *handler) createTokenForUser(c *gin.Context) {
|
||||
ttl = defaultTokenDuration
|
||||
}
|
||||
if userID == "" {
|
||||
_ = c.Error(&common.UserIdNotProvidedError{})
|
||||
return
|
||||
return apperror.MissingField("userId")
|
||||
}
|
||||
|
||||
token, err := h.service.CreateToken(c.Request.Context(), userID, ttl)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"token": token})
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestEmailAsUnauthenticatedUser godoc
|
||||
@@ -68,28 +67,26 @@ func (h *handler) createTokenForUser(c *gin.Context) {
|
||||
// @Param body body emailAsUnauthenticatedUserDto true "Email request information"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/one-time-access-email [post]
|
||||
func (h *handler) requestEmailAsUnauthenticatedUser(c *gin.Context) {
|
||||
func (h *handler) requestEmailAsUnauthenticatedUser(c *gin.Context) error {
|
||||
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
var input emailAsUnauthenticatedUserDto
|
||||
err = dto.ShouldBindWithNormalizedJSON(c, &input)
|
||||
err = httpserver.BindJSON(c, &input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
deviceToken, err := h.service.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), dbConfig, input.Email, input.RedirectPath)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
cookie.AddDeviceTokenCookie(c, deviceToken)
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestEmailAsAdmin godoc
|
||||
@@ -102,18 +99,16 @@ func (h *handler) requestEmailAsUnauthenticatedUser(c *gin.Context) {
|
||||
// @Param body body emailAsAdminDto true "Email request options"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/{id}/one-time-access-email [post]
|
||||
func (h *handler) requestEmailAsAdmin(c *gin.Context) {
|
||||
func (h *handler) requestEmailAsAdmin(c *gin.Context) error {
|
||||
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
var input emailAsAdminDto
|
||||
err = c.ShouldBindJSON(&input)
|
||||
err = httpserver.BindJSON(c, &input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
userID := c.Param("id")
|
||||
@@ -124,11 +119,11 @@ func (h *handler) requestEmailAsAdmin(c *gin.Context) {
|
||||
}
|
||||
err = h.service.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), dbConfig, userID, ttl)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// exchangeToken godoc
|
||||
@@ -138,36 +133,33 @@ func (h *handler) requestEmailAsAdmin(c *gin.Context) {
|
||||
// @Param token path string true "One-time access token"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/one-time-access-token/{token} [post]
|
||||
func (h *handler) exchangeToken(c *gin.Context) {
|
||||
func (h *handler) exchangeToken(c *gin.Context) error {
|
||||
cfg, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
loginCode := c.Param("token")
|
||||
// reject invalid length login codes
|
||||
if len(loginCode) != 6 && len(loginCode) != 16 {
|
||||
_ = c.Error(&common.TokenInvalidOrExpiredError{})
|
||||
return
|
||||
return apperror.TokenInvalidOrExpired()
|
||||
}
|
||||
|
||||
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
|
||||
user, token, err := h.service.ExchangeToken(c.Request.Context(), cfg, loginCode, deviceToken, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
err = dto.MapStruct(user, &userDto)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
maxAge := int(cfg.SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
@@ -69,8 +70,8 @@ func New(deps Dependencies) (*Module, error) {
|
||||
// RegisterRoutes mounts the one-time access token endpoints
|
||||
// auth guards the admin routes, while the rate limiters throttle the public exchange and email endpoints
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, exchangeRateLimit, emailRateLimit gin.HandlerFunc) {
|
||||
apiGroup.POST("/users/:id/one-time-access-token", auth, m.handler.createTokenForUser)
|
||||
apiGroup.POST("/users/:id/one-time-access-email", auth, m.handler.requestEmailAsAdmin)
|
||||
apiGroup.POST("/one-time-access-token/:token", exchangeRateLimit, m.handler.exchangeToken)
|
||||
apiGroup.POST("/one-time-access-email", emailRateLimit, m.handler.requestEmailAsUnauthenticatedUser)
|
||||
apiGroup.POST("/users/:id/one-time-access-token", auth, httpserver.Handle(m.handler.createTokenForUser))
|
||||
apiGroup.POST("/users/:id/one-time-access-email", auth, httpserver.Handle(m.handler.requestEmailAsAdmin))
|
||||
apiGroup.POST("/one-time-access-token/:token", exchangeRateLimit, httpserver.Handle(m.handler.exchangeToken))
|
||||
apiGroup.POST("/one-time-access-email", emailRateLimit, httpserver.Handle(m.handler.requestEmailAsUnauthenticatedUser))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
@@ -50,7 +51,7 @@ func newService(deps Dependencies, actorService *actor.Service) *Service {
|
||||
|
||||
func (s *Service) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, ttl time.Duration) error {
|
||||
if !dbConfig.EmailOneTimeAccessAsAdminEnabled.IsTrue() {
|
||||
return &common.OneTimeAccessDisabledError{}
|
||||
return apperror.OneTimeAccessDisabled()
|
||||
}
|
||||
|
||||
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false, dbConfig)
|
||||
@@ -59,7 +60,7 @@ func (s *Service) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig
|
||||
|
||||
func (s *Service) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, redirectPath string) (string, error) {
|
||||
if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() {
|
||||
return "", &common.OneTimeAccessDisabledError{}
|
||||
return "", apperror.OneTimeAccessDisabled()
|
||||
}
|
||||
|
||||
var userId string
|
||||
@@ -89,7 +90,7 @@ func (s *Service) requestOneTimeAccessEmailInternal(ctx context.Context, userID,
|
||||
}
|
||||
|
||||
if user.Email == nil {
|
||||
return nil, &common.UserEmailNotSetError{}
|
||||
return nil, apperror.UserEmailNotSet()
|
||||
}
|
||||
|
||||
oneTimeAccessToken, deviceToken, err := StoreToken(ctx, s.actorService, user.ID, ttl, withDeviceToken)
|
||||
@@ -134,7 +135,7 @@ func (s *Service) CreateToken(ctx context.Context, userID string, ttl time.Durat
|
||||
// Load the user to ensure it exists
|
||||
_, err = s.userProvider.GetUser(ctx, userID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", &common.UserNotFoundError{}
|
||||
return "", apperror.UserNotFound()
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -167,9 +168,9 @@ func (s *Service) ExchangeToken(ctx context.Context, dbConfig *appconfig.AppConf
|
||||
|
||||
switch consumeRes.Status {
|
||||
case tokenConsumeNotFound:
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
return model.User{}, "", apperror.TokenInvalidOrExpired()
|
||||
case tokenConsumeDeviceMismatch:
|
||||
return model.User{}, "", &common.DeviceCodeInvalid{}
|
||||
return model.User{}, "", apperror.DeviceCodeInvalid()
|
||||
case tokenConsumeOK:
|
||||
// All good, continue below
|
||||
default:
|
||||
@@ -195,13 +196,13 @@ func (s *Service) completeTokenExchange(ctx context.Context, dbConfig *appconfig
|
||||
First(&user).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
return model.User{}, "", apperror.TokenInvalidOrExpired()
|
||||
} else if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
return model.User{}, "", &common.UserDisabledError{}
|
||||
return model.User{}, "", apperror.UserDisabled()
|
||||
}
|
||||
|
||||
accessToken, err := s.signer.GenerateAccessToken(
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
@@ -128,8 +128,7 @@ func TestExchangeTokenInvalidToken(t *testing.T) {
|
||||
dbConfig := appconfig.NewTestConfig(nil)
|
||||
_, _, err := svc.ExchangeToken(t.Context(), dbConfig, "does-not-exist", "", "", "")
|
||||
|
||||
var invalidErr *common.TokenInvalidOrExpiredError
|
||||
require.ErrorAs(t, err, &invalidErr)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeTokenInvalidOrExpired))
|
||||
}
|
||||
|
||||
func TestExchangeTokenDeviceMismatch(t *testing.T) {
|
||||
@@ -150,8 +149,7 @@ func TestExchangeTokenDeviceMismatch(t *testing.T) {
|
||||
dbConfig := appconfig.NewTestConfig(nil)
|
||||
_, _, err = svc.ExchangeToken(t.Context(), dbConfig, token, "wrong-device-token", "", "")
|
||||
|
||||
var deviceErr *common.DeviceCodeInvalid
|
||||
require.ErrorAs(t, err, &deviceErr)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeDeviceCodeInvalid))
|
||||
|
||||
// The token must not have been consumed on a device-token mismatch
|
||||
var state TokenState
|
||||
@@ -178,8 +176,7 @@ func TestExchangeTokenRejectsDisabledUser(t *testing.T) {
|
||||
dbConfig := appconfig.NewTestConfig(nil)
|
||||
exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "", "")
|
||||
|
||||
var userDisabledErr *common.UserDisabledError
|
||||
require.ErrorAs(t, err, &userDisabledErr)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeUserDisabled))
|
||||
require.Empty(t, exchangedUser.ID)
|
||||
require.Empty(t, accessToken)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
imageutil "github.com/pocket-id/pocket-id/backend/internal/utils/image"
|
||||
@@ -40,7 +40,7 @@ func (s *AppImagesService) GetImage(ctx context.Context, name string) (io.ReadCl
|
||||
reader, size, err := s.storage.Open(ctx, imagePath)
|
||||
if err != nil {
|
||||
if storage.IsNotExist(err) {
|
||||
return nil, 0, "", &common.ImageNotFoundError{}
|
||||
return nil, 0, "", apperror.ImageNotFound()
|
||||
}
|
||||
return nil, 0, "", err
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (s *AppImagesService) UpdateImage(ctx context.Context, file *multipart.File
|
||||
fileType := strings.ToLower(utils.GetFileExtension(file.Filename))
|
||||
mimeType := utils.GetImageMimeType(fileType)
|
||||
if mimeType == "" {
|
||||
return &common.FileTypeNotSupportedError{}
|
||||
return apperror.UnsupportedFileType("")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -96,7 +96,7 @@ func (s *AppImagesService) DeleteImage(ctx context.Context, imageName string) er
|
||||
|
||||
ext, ok := s.extensions[imageName]
|
||||
if !ok || ext == "" {
|
||||
return &common.ImageNotFoundError{}
|
||||
return apperror.ImageNotFound()
|
||||
}
|
||||
|
||||
imagePath := path.Join("application-images", imageName+"."+ext)
|
||||
@@ -122,7 +122,7 @@ func (s *AppImagesService) getExtension(name string) (string, error) {
|
||||
|
||||
ext, ok := s.extensions[name]
|
||||
if !ok || ext == "" {
|
||||
return "", &common.ImageNotFoundError{}
|
||||
return "", apperror.ImageNotFound()
|
||||
}
|
||||
|
||||
return strings.ToLower(ext), nil
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
)
|
||||
|
||||
@@ -85,15 +85,13 @@ func TestAppImagesService_ErrorsAndFlags(t *testing.T) {
|
||||
t.Run("get missing image returns not found", func(t *testing.T) {
|
||||
_, _, _, err := service.GetImage(context.Background(), "missing")
|
||||
require.Error(t, err)
|
||||
var imageErr *common.ImageNotFoundError
|
||||
assert.ErrorAs(t, err, &imageErr)
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeImageNotFound))
|
||||
})
|
||||
|
||||
t.Run("reject unsupported file types", func(t *testing.T) {
|
||||
err := service.UpdateImage(context.Background(), newFileHeader(t, "logo.txt", []byte("nope")), "logo")
|
||||
require.Error(t, err)
|
||||
var fileTypeErr *common.FileTypeNotSupportedError
|
||||
assert.ErrorAs(t, err, &fileTypeErr)
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeFileTypeNotSupported))
|
||||
})
|
||||
|
||||
t.Run("delete and extension tracking", func(t *testing.T) {
|
||||
@@ -105,8 +103,7 @@ func TestAppImagesService_ErrorsAndFlags(t *testing.T) {
|
||||
|
||||
err := service.DeleteImage(context.Background(), "default-profile-picture")
|
||||
require.Error(t, err)
|
||||
var imageErr *common.ImageNotFoundError
|
||||
assert.ErrorAs(t, err, &imageErr)
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeImageNotFound))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
@@ -63,6 +65,11 @@ func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, user
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
// Reject missing owners before replacing claims so invalid IDs cannot appear successful
|
||||
if err := ensureCustomClaimOwnerExists(ctx, tx, UserID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserID, userID, claims, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -83,6 +90,11 @@ func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context,
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
// Reject missing owners before replacing claims so invalid IDs cannot appear successful
|
||||
if err := ensureCustomClaimOwnerExists(ctx, tx, UserGroupID, userGroupID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserGroupID, userGroupID, claims, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -96,13 +108,42 @@ func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context,
|
||||
return updatedClaims, nil
|
||||
}
|
||||
|
||||
// updateCustomClaimsInternal updates the custom claims for a user or user group within a transaction
|
||||
func ensureCustomClaimOwnerExists(ctx context.Context, tx *gorm.DB, ownerType idType, ownerID string) error {
|
||||
// Select the owner model and corresponding client-safe not-found error
|
||||
var (
|
||||
target any
|
||||
notFound error
|
||||
)
|
||||
switch ownerType {
|
||||
case UserID:
|
||||
target = &model.User{}
|
||||
notFound = apperror.UserNotFound()
|
||||
case UserGroupID:
|
||||
target = &model.UserGroup{}
|
||||
notFound = apperror.NotFound("User group")
|
||||
default:
|
||||
return fmt.Errorf("unsupported custom claim owner type %q", ownerType)
|
||||
}
|
||||
|
||||
// Verify the owner in the active transaction before changing its claims
|
||||
err := tx.WithContext(ctx).
|
||||
Select("id").
|
||||
First(target, "id = ?", ownerID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return notFound
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// updateCustomClaimsInternal keeps claim replacements within the caller's transaction
|
||||
func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto, tx *gorm.DB) ([]model.CustomClaim, error) {
|
||||
// Check for duplicate keys in the claims slice
|
||||
// Reject duplicate keys before changing persisted claims
|
||||
seenKeys := make(map[string]struct{})
|
||||
for _, claim := range claims {
|
||||
if _, ok := seenKeys[claim.Key]; ok {
|
||||
return nil, &common.DuplicateClaimError{Key: claim.Key}
|
||||
return nil, apperror.DuplicateClaim(claim.Key)
|
||||
}
|
||||
seenKeys[claim.Key] = struct{}{}
|
||||
}
|
||||
@@ -117,7 +158,7 @@ func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idT
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete claims that are not in the new list
|
||||
// Remove stale claims before applying the requested replacement set
|
||||
for _, existingClaim := range existingClaims {
|
||||
found := false
|
||||
for _, claim := range claims {
|
||||
@@ -138,10 +179,10 @@ func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idT
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update claims
|
||||
// Reject reserved keys and persist each requested claim
|
||||
for _, claim := range claims {
|
||||
if isReservedClaim(claim.Key) {
|
||||
return nil, &common.ReservedClaimError{Key: claim.Key}
|
||||
return nil, apperror.ReservedClaim(claim.Key)
|
||||
}
|
||||
customClaim := model.CustomClaim{
|
||||
Key: claim.Key,
|
||||
@@ -155,7 +196,7 @@ func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idT
|
||||
customClaim.UserGroupID = &value
|
||||
}
|
||||
|
||||
// Update the claim if it already exists or create a new one
|
||||
// Preserve claim identity when updating an existing owner and key pair
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where(string(idType)+" = ? AND key = ?", value, claim.Key).
|
||||
@@ -167,7 +208,7 @@ func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idT
|
||||
}
|
||||
}
|
||||
|
||||
// Get the updated claims
|
||||
// Return the persisted replacement set to the caller
|
||||
var updatedClaims []model.CustomClaim
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
|
||||
19
backend/internal/service/custom_claim_service_test.go
Normal file
19
backend/internal/service/custom_claim_service_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCustomClaimUpdatesRejectMissingOwner(t *testing.T) {
|
||||
service := NewCustomClaimService(testutils.NewDatabaseForTest(t))
|
||||
|
||||
_, err := service.UpdateCustomClaimsForUser(t.Context(), "missing-user", nil)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
|
||||
|
||||
_, err = service.UpdateCustomClaimsForUserGroup(t.Context(), "missing-group", nil)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -24,7 +23,7 @@ import (
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
@@ -84,7 +83,7 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, userService *UserServi
|
||||
|
||||
func (s *LdapService) createClient(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
|
||||
if !dbConfig.LdapEnabled.IsTrue() {
|
||||
return nil, fmt.Errorf("LDAP is not enabled")
|
||||
return nil, apperror.LdapDisabled()
|
||||
}
|
||||
|
||||
// Setup LDAP connection
|
||||
@@ -522,7 +521,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
|
||||
userID := databaseUser.ID
|
||||
if databaseUser.ID == "" {
|
||||
createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx, dbConfig)
|
||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||
if apperror.IsCode(err, apperror.CodeAlreadyInUse) {
|
||||
slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
|
||||
continue
|
||||
} else if err != nil {
|
||||
@@ -533,7 +532,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
|
||||
ldapUsersByID[desiredUser.ldapID] = createdUser
|
||||
} else {
|
||||
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx, dbConfig)
|
||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||
if apperror.IsCode(err, apperror.CodeAlreadyInUse) {
|
||||
slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
|
||||
continue
|
||||
} else if err != nil {
|
||||
@@ -573,7 +572,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
|
||||
|
||||
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true, dbConfig)
|
||||
if err != nil {
|
||||
if _, ok := errors.AsType[*common.LdapUserUpdateError](err); ok {
|
||||
if apperror.IsCode(err, apperror.CodeLdapUserUpdate) {
|
||||
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
@@ -19,6 +20,14 @@ type fakeLDAPClient struct {
|
||||
searchFn func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
|
||||
}
|
||||
|
||||
func TestCreateLDAPClientRejectsDisabledConfiguration(t *testing.T) {
|
||||
service := NewLdapService(nil, nil, nil, nil, nil)
|
||||
|
||||
_, err := service.createClient(&appconfig.AppConfigModel{LdapEnabled: "false"})
|
||||
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeLdapDisabled))
|
||||
}
|
||||
|
||||
func (c *fakeLDAPClient) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
if c.searchFn == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -85,14 +85,14 @@ func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.Oid
|
||||
// for a CIMD client, bypassing the cache TTL, and returns the refreshed client.
|
||||
func (s *OidcService) RefreshClientMetadata(ctx context.Context, clientID string) (model.OidcClient, error) {
|
||||
if s.metadataRefresher == nil {
|
||||
return model.OidcClient{}, &common.ValidationError{Message: "client ID metadata documents are not enabled"}
|
||||
return model.OidcClient{}, apperror.ValidationMessage("Client ID metadata documents are not enabled")
|
||||
}
|
||||
client, err := s.metadataRefresher.RefreshClientMetadata(ctx, clientID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
return model.OidcClient{}, &common.ValidationError{Message: err.Error()}
|
||||
return model.OidcClient{}, apperror.ValidationMessage(err.Error())
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
@@ -107,6 +107,9 @@ func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx
|
||||
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
q = q.First(&client, "id = ?", clientID)
|
||||
if errors.Is(q.Error, gorm.ErrRecordNotFound) {
|
||||
return model.OidcClient{}, apperror.NotFound("OIDC client")
|
||||
}
|
||||
if q.Error != nil {
|
||||
return model.OidcClient{}, q.Error
|
||||
}
|
||||
@@ -155,7 +158,7 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return model.OidcClient{}, &common.ClientIdAlreadyExistsError{}
|
||||
return model.OidcClient{}, apperror.ClientIDAlreadyExists()
|
||||
}
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
@@ -284,14 +287,16 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
|
||||
|
||||
func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
|
||||
var client model.OidcClient
|
||||
err := s.db.
|
||||
result := s.db.
|
||||
WithContext(ctx).
|
||||
Where("id = ?", clientID).
|
||||
Clauses(clause.Returning{}).
|
||||
Delete(&client).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
Delete(&client)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return apperror.NotFound("OIDC client")
|
||||
}
|
||||
|
||||
// Delete images if present
|
||||
@@ -314,17 +319,13 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, i
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var client model.OidcClient
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
First(&client, "id = ?", clientID).
|
||||
Error
|
||||
client, err := s.getClientInternal(ctx, clientID, tx, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if client.IsPublic {
|
||||
return "", &common.ValidationError{Message: "cannot create a secret for a public client"}
|
||||
return "", apperror.ValidationMessage("Cannot create a secret for a public client")
|
||||
}
|
||||
|
||||
clientSecret := input.Secret
|
||||
@@ -358,11 +359,7 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, i
|
||||
}
|
||||
|
||||
func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light bool) (io.ReadCloser, int64, string, error) {
|
||||
var client model.OidcClient
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
First(&client, "id = ?", clientID).
|
||||
Error
|
||||
client, err := s.getClientInternal(ctx, clientID, s.db, false)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
@@ -378,7 +375,7 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light
|
||||
// Light logo if requested or no dark logo is available
|
||||
ext = *client.ImageType
|
||||
default:
|
||||
return nil, 0, "", errors.New("image not found")
|
||||
return nil, 0, "", apperror.ImageNotFound()
|
||||
}
|
||||
|
||||
mimeType := utils.GetImageMimeType(ext)
|
||||
@@ -388,6 +385,9 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light
|
||||
key := oidcClientImagePath(client.ID, suffix, ext)
|
||||
reader, size, err := s.fileStorage.Open(ctx, key)
|
||||
if err != nil {
|
||||
if storage.IsNotExist(err) {
|
||||
return nil, 0, "", apperror.ImageNotFound()
|
||||
}
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light
|
||||
func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader, light bool) error {
|
||||
fileType := strings.ToLower(utils.GetFileExtension(file.Filename))
|
||||
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
||||
return &common.FileTypeNotSupportedError{}
|
||||
return apperror.UnsupportedFileType("")
|
||||
}
|
||||
|
||||
var darkSuffix string
|
||||
@@ -432,7 +432,7 @@ func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, fil
|
||||
func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) error {
|
||||
return s.deleteClientLogoInternal(ctx, clientID, "", func(client *model.OidcClient) (string, error) {
|
||||
if client.ImageType == nil {
|
||||
return "", errors.New("image not found")
|
||||
return "", apperror.ImageNotFound()
|
||||
}
|
||||
|
||||
oldImageType := *client.ImageType
|
||||
@@ -444,7 +444,7 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
|
||||
func (s *OidcService) DeleteClientDarkLogo(ctx context.Context, clientID string) error {
|
||||
return s.deleteClientLogoInternal(ctx, clientID, "-dark", func(client *model.OidcClient) (string, error) {
|
||||
if client.DarkImageType == nil {
|
||||
return "", errors.New("image not found")
|
||||
return "", apperror.ImageNotFound()
|
||||
}
|
||||
|
||||
oldImageType := *client.DarkImageType
|
||||
@@ -459,11 +459,7 @@ func (s *OidcService) deleteClientLogoInternal(ctx context.Context, clientID str
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var client model.OidcClient
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
First(&client, "id = ?", clientID).
|
||||
Error
|
||||
client, err := s.getClientInternal(ctx, clientID, tx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -555,8 +551,7 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var client model.OidcClient
|
||||
err := tx.WithContext(ctx).Where("id = ?", id).First(&client).Error
|
||||
client, err := s.getClientInternal(ctx, id, tx, false)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -566,8 +561,25 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri
|
||||
}
|
||||
|
||||
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
query := s.db.
|
||||
var user model.User
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Select("id").
|
||||
First(&user, "id = ?", userID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, utils.PaginationResponse{}, apperror.UserNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
query := tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.UserAuthorizedOidcClient{}).
|
||||
Preload("Client").
|
||||
@@ -590,6 +602,9 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
|
||||
WithContext(ctx).
|
||||
Where("user_id = ? AND client_id = ?", userID, clientID).
|
||||
First(&authorizedClient).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperror.NotFound("Client authorization")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -623,6 +638,9 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
||||
Preload("UserGroups").
|
||||
First(&user, "id = ?", userID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, utils.PaginationResponse{}, apperror.UserNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
@@ -692,12 +710,15 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
|
||||
Preload("UserGroups").
|
||||
First(&user, "id = ?", userID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, apperror.UserNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !oidc.IsUserGroupAllowedToAuthorize(user, client) {
|
||||
return nil, &common.OidcAccessDeniedError{}
|
||||
return nil, apperror.OidcAccessDenied()
|
||||
}
|
||||
|
||||
preview, err := s.previewBuilder.BuildClientPreview(ctx, client, userID, scopes, authenticationMethod)
|
||||
@@ -711,8 +732,6 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
|
||||
}, nil
|
||||
}
|
||||
|
||||
var errLogoTooLarge = errors.New("logo is too large")
|
||||
|
||||
func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *http.Request, via []*http.Request) error) *http.Client {
|
||||
if source == nil {
|
||||
source = http.DefaultClient
|
||||
@@ -732,7 +751,10 @@ func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *ht
|
||||
func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clientID string, raw string, light bool) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
return apperror.InvalidLogoURL(err)
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return apperror.InvalidLogoURL(fmt.Errorf("URL must use HTTP or HTTPS and include a host"))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
|
||||
@@ -741,22 +763,22 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
|
||||
// Prevents SSRF by allowing only public IPs
|
||||
ok, err := utils.IsURLPrivate(ctx, u)
|
||||
if err != nil {
|
||||
return err
|
||||
return apperror.LogoDownloadFailed(err)
|
||||
} else if ok {
|
||||
return errors.New("private IP addresses are not allowed")
|
||||
return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed"))
|
||||
}
|
||||
|
||||
// We need to check this on redirects too
|
||||
client := httpClientWithCheckRedirect(s.httpClient, func(r *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
return apperror.InvalidLogoURL(errors.New("stopped after 10 redirects"))
|
||||
}
|
||||
|
||||
ok, err := utils.IsURLPrivate(r.Context(), r.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
return errors.New("private IP addresses are not allowed")
|
||||
return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed"))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -764,24 +786,27 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return apperror.InvalidLogoURL(err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "pocket-id/oidc-logo-fetcher")
|
||||
req.Header.Set("Accept", "image/*")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
if appErr, ok := errors.AsType[*apperror.Error](err); ok {
|
||||
return appErr
|
||||
}
|
||||
return apperror.LogoDownloadFailed(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to fetch logo: %s", resp.Status)
|
||||
return apperror.LogoDownloadFailed(fmt.Errorf("logo server returned %s", resp.Status))
|
||||
}
|
||||
|
||||
const maxLogoSize int64 = 2 * 1024 * 1024 // 2MB
|
||||
if resp.ContentLength > maxLogoSize {
|
||||
return errLogoTooLarge
|
||||
return apperror.LogoTooLarge("2 MB")
|
||||
}
|
||||
|
||||
// Prefer extension in path if supported
|
||||
@@ -792,7 +817,7 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
|
||||
}
|
||||
|
||||
if ext == "" {
|
||||
return &common.FileTypeNotSupportedError{}
|
||||
return apperror.LogoTypeNotSupported()
|
||||
}
|
||||
|
||||
var darkSuffix string
|
||||
@@ -803,17 +828,17 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
|
||||
limitReader := utils.NewLimitReader(resp.Body, maxLogoSize+1)
|
||||
strippedReader, err := imageutil.StripMetadata(limitReader, ext)
|
||||
if errors.Is(err, utils.ErrSizeExceeded) {
|
||||
return errLogoTooLarge
|
||||
return apperror.LogoTooLarge("2 MB")
|
||||
} else if err != nil {
|
||||
return err
|
||||
return apperror.LogoDownloadFailed(err)
|
||||
}
|
||||
|
||||
imagePath := oidcClientImagePath(clientID, darkSuffix, ext)
|
||||
err = s.fileStorage.Save(ctx, imagePath, strippedReader)
|
||||
if errors.Is(err, utils.ErrSizeExceeded) {
|
||||
return errLogoTooLarge
|
||||
return apperror.LogoTooLarge("2 MB")
|
||||
} else if err != nil {
|
||||
return err
|
||||
return apperror.LogoDownloadFailed(err)
|
||||
}
|
||||
|
||||
err = s.updateClientLogoType(ctx, clientID, ext, light)
|
||||
@@ -843,6 +868,9 @@ func (s *OidcService) updateClientLogoType(ctx context.Context, clientID string,
|
||||
First(&client, "id = ?", clientID).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperror.NotFound("OIDC client")
|
||||
}
|
||||
return fmt.Errorf("failed to look up client: %w", err)
|
||||
}
|
||||
|
||||
@@ -892,6 +920,9 @@ func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID
|
||||
First(&provider, "oidc_client_id = ?", clientID).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.ScimServiceProvider{}, apperror.NotFound("SCIM service provider")
|
||||
}
|
||||
return model.ScimServiceProvider{}, err
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -22,6 +22,14 @@ import (
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestListAuthorizedClientsRejectsMissingUser(t *testing.T) {
|
||||
service := &OidcService{db: testutils.NewDatabaseForTest(t)}
|
||||
|
||||
_, _, err := service.ListAuthorizedClients(t.Context(), "missing-user", utils.ListRequestOptions{})
|
||||
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
|
||||
}
|
||||
|
||||
func TestOidcService_DeleteClientDeletesOAuth2Sessions(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
require.NoError(t, db.Exec("PRAGMA foreign_keys = ON").Error)
|
||||
@@ -193,7 +201,7 @@ func TestOidcService_updateClientLogoType(t *testing.T) {
|
||||
t.Run("Returns error for non-existent client", func(t *testing.T) {
|
||||
err := s.updateClientLogoType(t.Context(), "non-existent-client-id", "png", true)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "failed to look up client")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -403,6 +411,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "://invalid-url", true)
|
||||
require.Error(t, err)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
|
||||
})
|
||||
|
||||
t.Run("Returns error for non-200 status code", func(t *testing.T) {
|
||||
@@ -424,7 +433,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/not-found.png", true)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "failed to fetch logo")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeLogoDownloadFailed))
|
||||
})
|
||||
|
||||
t.Run("Returns error for too large content", func(t *testing.T) {
|
||||
@@ -454,7 +463,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/large.png", true)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errLogoTooLarge)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeLogoTooLarge))
|
||||
})
|
||||
|
||||
t.Run("Returns error for unsupported file type", func(t *testing.T) {
|
||||
@@ -480,8 +489,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/file.txt", true)
|
||||
require.Error(t, err)
|
||||
var fileTypeErr *common.FileTypeNotSupportedError
|
||||
require.ErrorAs(t, err, &fileTypeErr)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeLogoTypeNotSupported))
|
||||
})
|
||||
|
||||
t.Run("Returns error for non-existent client", func(t *testing.T) {
|
||||
@@ -507,7 +515,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), "non-existent-client-id", publicLogoHost+"/logo.png", true)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "failed to look up client")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -73,6 +74,9 @@ func (s *ScimService) GetServiceProvider(
|
||||
Preload("OidcClient.AllowedUserGroups").
|
||||
First(&provider, "id = ?", serviceProviderID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.ScimServiceProvider{}, apperror.NotFound("SCIM service provider")
|
||||
}
|
||||
if err != nil {
|
||||
return model.ScimServiceProvider{}, err
|
||||
}
|
||||
@@ -94,13 +98,25 @@ func (s *ScimService) ListServiceProviders(ctx context.Context) ([]model.ScimSer
|
||||
func (s *ScimService) CreateServiceProvider(
|
||||
ctx context.Context,
|
||||
input *dto.ScimServiceProviderCreateDTO) (model.ScimServiceProvider, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
if err := ensureScimOIDCClientExists(ctx, tx, input.OidcClientID); err != nil {
|
||||
return model.ScimServiceProvider{}, err
|
||||
}
|
||||
|
||||
provider := model.ScimServiceProvider{
|
||||
Endpoint: input.Endpoint,
|
||||
Token: datatype.EncryptedString(input.Token),
|
||||
OidcClientID: input.OidcClientID,
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Create(&provider).Error; err != nil {
|
||||
if err := tx.WithContext(ctx).Create(&provider).Error; err != nil {
|
||||
return model.ScimServiceProvider{}, err
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return model.ScimServiceProvider{}, err
|
||||
}
|
||||
|
||||
@@ -111,29 +127,64 @@ func (s *ScimService) UpdateServiceProvider(ctx context.Context,
|
||||
serviceProviderID string,
|
||||
input *dto.ScimServiceProviderCreateDTO,
|
||||
) (model.ScimServiceProvider, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var provider model.ScimServiceProvider
|
||||
err := s.db.WithContext(ctx).
|
||||
err := tx.WithContext(ctx).
|
||||
First(&provider, "id = ?", serviceProviderID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.ScimServiceProvider{}, apperror.NotFound("SCIM service provider")
|
||||
}
|
||||
if err != nil {
|
||||
return model.ScimServiceProvider{}, err
|
||||
}
|
||||
|
||||
if err := ensureScimOIDCClientExists(ctx, tx, input.OidcClientID); err != nil {
|
||||
return model.ScimServiceProvider{}, err
|
||||
}
|
||||
|
||||
provider.Endpoint = input.Endpoint
|
||||
provider.Token = datatype.EncryptedString(input.Token)
|
||||
provider.OidcClientID = input.OidcClientID
|
||||
|
||||
if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
|
||||
if err := tx.WithContext(ctx).Save(&provider).Error; err != nil {
|
||||
return model.ScimServiceProvider{}, err
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return model.ScimServiceProvider{}, err
|
||||
}
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProviderID string) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Delete(&model.ScimServiceProvider{}, "id = ?", serviceProviderID).
|
||||
func ensureScimOIDCClientExists(ctx context.Context, db *gorm.DB, clientID string) error {
|
||||
var client model.OidcClient
|
||||
err := db.WithContext(ctx).
|
||||
Select("id").
|
||||
First(&client, "id = ?", clientID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperror.NotFound("OIDC client")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProviderID string) error {
|
||||
result := s.db.WithContext(ctx).
|
||||
Delete(&model.ScimServiceProvider{}, "id = ?", serviceProviderID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return apperror.NotFound("SCIM service provider")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:contextcheck
|
||||
|
||||
61
backend/internal/service/scim_service_test.go
Normal file
61
backend/internal/service/scim_service_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestScimServiceProviderOperationsReturnSpecificNotFoundErrors(t *testing.T) {
|
||||
service := NewScimService(testutils.NewDatabaseForTest(t), nil, nil)
|
||||
|
||||
_, err := service.CreateServiceProvider(t.Context(), &dto.ScimServiceProviderCreateDTO{
|
||||
Endpoint: "https://scim.example.com",
|
||||
OidcClientID: "missing-client",
|
||||
})
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
|
||||
_, err = service.GetServiceProvider(t.Context(), "missing-provider")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
|
||||
err = service.DeleteServiceProvider(t.Context(), "missing-provider")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
}
|
||||
|
||||
func TestScimServiceProviderCreateAndUpdate(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := NewScimService(db, nil, nil)
|
||||
|
||||
// Create two clients so provider creation and reassignment both satisfy the foreign key
|
||||
require.NoError(t, db.Create(&[]model.OidcClient{
|
||||
{Base: model.Base{ID: "client-1"}, Name: "Client 1"},
|
||||
{Base: model.Base{ID: "client-2"}, Name: "Client 2"},
|
||||
}).Error)
|
||||
|
||||
// Create the provider with its initial client in one transaction
|
||||
provider, err := service.CreateServiceProvider(t.Context(), &dto.ScimServiceProviderCreateDTO{
|
||||
Endpoint: "https://scim.example.com/v1",
|
||||
OidcClientID: "client-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, provider.ID)
|
||||
|
||||
// Move the provider to the second client in one transaction
|
||||
provider, err = service.UpdateServiceProvider(t.Context(), provider.ID, &dto.ScimServiceProviderCreateDTO{
|
||||
Endpoint: "https://scim.example.com/v2",
|
||||
OidcClientID: "client-2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://scim.example.com/v2", provider.Endpoint)
|
||||
require.Equal(t, "client-2", provider.OidcClientID)
|
||||
|
||||
// Verify the committed provider retains both updated values
|
||||
persisted, err := service.GetServiceProvider(t.Context(), provider.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, provider.Endpoint, persisted.Endpoint)
|
||||
require.Equal(t, provider.OidcClientID, persisted.OidcClientID)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"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"
|
||||
@@ -59,6 +59,9 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
|
||||
Preload("AllowedOidcClients").
|
||||
First(&group).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.UserGroup{}, apperror.NotFound("User group")
|
||||
}
|
||||
return group, err
|
||||
}
|
||||
|
||||
@@ -74,13 +77,16 @@ func (s *UserGroupService) Delete(ctx context.Context, cfg *appconfig.AppConfigM
|
||||
Where("id = ?", id).
|
||||
First(&group).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperror.NotFound("User group")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Disallow deleting the group if it is an LDAP group and LDAP is enabled
|
||||
if group.LdapID != nil && cfg.LdapEnabled.IsTrue() {
|
||||
return &common.LdapUserGroupUpdateError{}
|
||||
return apperror.LdapUserGroupUpdate()
|
||||
}
|
||||
|
||||
err = tx.
|
||||
@@ -123,7 +129,7 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
|
||||
Create(&group).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||
return model.UserGroup{}, apperror.AlreadyInUse("name")
|
||||
} else if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
@@ -163,7 +169,7 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
|
||||
// Disallow updating the group if it is an LDAP group and LDAP is enabled
|
||||
if !isLdapSync && group.LdapID != nil {
|
||||
if cfg.LdapEnabled.IsTrue() {
|
||||
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
|
||||
return model.UserGroup{}, apperror.LdapUserGroupUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +183,7 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
|
||||
Save(&group).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||
return model.UserGroup{}, apperror.AlreadyInUse("name")
|
||||
} else if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
@@ -269,6 +275,9 @@ func (s *UserGroupService) GetUserCountOfGroup(ctx context.Context, id string) (
|
||||
Where("id = ?", id).
|
||||
First(&group).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, apperror.NotFound("User group")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -80,13 +80,16 @@ func (s *UserService) getUserInternal(ctx context.Context, userID string, tx *go
|
||||
Where("id = ?", userID).
|
||||
First(&user).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, apperror.UserNotFound()
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.ReadCloser, int64, error) {
|
||||
// Validate the user ID to prevent directory traversal
|
||||
if err := uuid.Validate(userID); err != nil {
|
||||
return nil, 0, &common.InvalidUUIDError{}
|
||||
return nil, 0, apperror.InvalidUserID()
|
||||
}
|
||||
|
||||
user, err := s.GetUser(ctx, userID)
|
||||
@@ -110,7 +113,7 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
|
||||
if err == nil {
|
||||
return reader, size, nil
|
||||
}
|
||||
if !errors.Is(err, &common.ImageNotFoundError{}) {
|
||||
if !apperror.IsCode(err, apperror.CodeImageNotFound) {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
@@ -153,6 +156,9 @@ func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model
|
||||
Where("id = ?", userID).
|
||||
First(&user).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, apperror.UserNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -163,11 +169,18 @@ func (s *UserService) UpdateProfilePicture(ctx context.Context, userID string, f
|
||||
// Validate the user ID to prevent directory traversal
|
||||
err := uuid.Validate(userID)
|
||||
if err != nil {
|
||||
return &common.InvalidUUIDError{}
|
||||
return apperror.InvalidUserID()
|
||||
}
|
||||
|
||||
if _, err := s.GetUser(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert the image to a smaller square image
|
||||
profilePicture, err := profilepicture.CreateProfilePicture(file)
|
||||
if errors.Is(err, profilepicture.ErrInvalidImage) {
|
||||
return apperror.InvalidImage(err)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -207,6 +220,9 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&user).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperror.UserNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load user to delete: %w", err)
|
||||
}
|
||||
@@ -214,7 +230,7 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
|
||||
// Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled
|
||||
if !allowLdapDelete && !user.Disabled && user.LdapID != nil {
|
||||
if cfg.LdapEnabled.IsTrue() {
|
||||
return &common.LdapUserUpdateError{}
|
||||
return apperror.LdapUserUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +271,7 @@ func (s *UserService) CreateUserInternal(ctx context.Context, dbConfig *appconfi
|
||||
|
||||
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) {
|
||||
if cfg.RequireUserEmail.IsTrue() && input.Email == nil {
|
||||
return model.User{}, &common.UserEmailNotSetError{}
|
||||
return model.User{}, apperror.MissingField("email")
|
||||
}
|
||||
|
||||
var userGroups []model.UserGroup
|
||||
@@ -438,7 +454,7 @@ func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigMo
|
||||
|
||||
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) {
|
||||
if cfg.RequireUserEmail.IsTrue() && updatedUser.Email == nil {
|
||||
return model.User{}, &common.UserEmailNotSetError{}
|
||||
return model.User{}, apperror.MissingField("email")
|
||||
}
|
||||
|
||||
var user model.User
|
||||
@@ -448,6 +464,9 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&user).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, apperror.UserNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
@@ -588,7 +607,7 @@ func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User
|
||||
return err
|
||||
}
|
||||
if result.Found {
|
||||
return &common.AlreadyInUseError{Property: "email"}
|
||||
return apperror.AlreadyInUse("email")
|
||||
}
|
||||
|
||||
err = tx.
|
||||
@@ -600,7 +619,7 @@ func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User
|
||||
return err
|
||||
}
|
||||
if result.Found {
|
||||
return &common.AlreadyInUseError{Property: "username"}
|
||||
return apperror.AlreadyInUse("username")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -610,7 +629,11 @@ func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User
|
||||
func (s *UserService) ResetProfilePicture(ctx context.Context, userID string) error {
|
||||
// Validate the user ID to prevent directory traversal
|
||||
if err := uuid.Validate(userID); err != nil {
|
||||
return &common.InvalidUUIDError{}
|
||||
return apperror.InvalidUserID()
|
||||
}
|
||||
|
||||
if _, err := s.GetUser(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profilePicturePath := path.Join("profile-pictures", userID+".png")
|
||||
|
||||
@@ -2,11 +2,14 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
@@ -34,6 +37,48 @@ func newTestUserService(t *testing.T) (*UserService, *UserGroupService) {
|
||||
return userService, groupService
|
||||
}
|
||||
|
||||
func TestUserAndGroupLookupsReturnSpecificNotFoundErrors(t *testing.T) {
|
||||
userService, groupService := newTestUserService(t)
|
||||
|
||||
_, err := userService.GetUser(t.Context(), "missing-user")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
|
||||
|
||||
_, err = groupService.Get(t.Context(), "missing-group")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
var appErr *apperror.Error
|
||||
require.ErrorAs(t, err, &appErr)
|
||||
require.Equal(t, "User group", appErr.Details()["resource"])
|
||||
}
|
||||
|
||||
func TestUpdateProfilePictureRejectsInvalidImageData(t *testing.T) {
|
||||
userService, _ := newTestUserService(t)
|
||||
config := &appconfig.AppConfigModel{RequireUserEmail: "false"}
|
||||
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
|
||||
ID: uuid.NewString(),
|
||||
Username: "image-test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = userService.UpdateProfilePicture(
|
||||
t.Context(),
|
||||
user.ID,
|
||||
strings.NewReader("not an image"),
|
||||
)
|
||||
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeInvalidImage))
|
||||
}
|
||||
|
||||
func TestProfilePictureUpdatesRejectMissingUser(t *testing.T) {
|
||||
userService, _ := newTestUserService(t)
|
||||
missingUserID := uuid.NewString()
|
||||
|
||||
err := userService.UpdateProfilePicture(t.Context(), missingUserID, strings.NewReader("not an image"))
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
|
||||
|
||||
err = userService.ResetProfilePicture(t.Context(), missingUserID)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
|
||||
}
|
||||
|
||||
func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) {
|
||||
config := &appconfig.AppConfigModel{RequireUserEmail: "false"}
|
||||
userService, groupService := newTestUserService(t)
|
||||
|
||||
@@ -7,8 +7,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
@@ -24,19 +25,18 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler {
|
||||
return &handler{service: service, appConfig: appConfig}
|
||||
}
|
||||
|
||||
func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) {
|
||||
func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) error {
|
||||
setupCompleted, err := h.service.IsInitialAdminSetupCompleted(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
if setupCompleted {
|
||||
_ = c.Error(&common.SetupNotAvailableError{})
|
||||
return
|
||||
return apperror.SetupNotAvailable()
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// signUpInitialAdmin godoc
|
||||
@@ -48,37 +48,34 @@ func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) {
|
||||
// @Param body body signUpDto true "User information"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/signup/setup [post]
|
||||
func (h *handler) signUpInitialAdmin(c *gin.Context) {
|
||||
func (h *handler) signUpInitialAdmin(c *gin.Context) error {
|
||||
config, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
var input signUpDto
|
||||
err = dto.ShouldBindWithNormalizedJSON(c, &input)
|
||||
err = httpserver.BindJSON(c, &input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), config, input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
err = dto.MapStruct(user, &userDto)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSignupTokenHandler godoc
|
||||
@@ -90,11 +87,10 @@ func (h *handler) signUpInitialAdmin(c *gin.Context) {
|
||||
// @Param token body signupTokenCreateDto true "Signup token information"
|
||||
// @Success 201 {object} signupTokenDto
|
||||
// @Router /api/signup-tokens [post]
|
||||
func (h *handler) createSignupToken(c *gin.Context) {
|
||||
func (h *handler) createSignupToken(c *gin.Context) error {
|
||||
var input signupTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ttl := input.TTL.Duration
|
||||
@@ -104,18 +100,17 @@ func (h *handler) createSignupToken(c *gin.Context) {
|
||||
|
||||
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var tokenDto signupTokenDto
|
||||
err = dto.MapStruct(signupToken, &tokenDto)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, tokenDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listSignupTokensHandler godoc
|
||||
@@ -128,26 +123,25 @@ func (h *handler) createSignupToken(c *gin.Context) {
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[signupTokenDto]
|
||||
// @Router /api/signup-tokens [get]
|
||||
func (h *handler) listSignupTokens(c *gin.Context) {
|
||||
func (h *handler) listSignupTokens(c *gin.Context) error {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
tokens, pagination, err := h.service.ListSignupTokens(c.Request.Context(), listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var tokensDto []signupTokenDto
|
||||
err = dto.MapStructList(tokens, &tokensDto)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[signupTokenDto]{
|
||||
Data: tokensDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteSignupTokenHandler godoc
|
||||
@@ -157,16 +151,16 @@ func (h *handler) listSignupTokens(c *gin.Context) {
|
||||
// @Param id path string true "Token ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/signup-tokens/{id} [delete]
|
||||
func (h *handler) deleteSignupToken(c *gin.Context) {
|
||||
func (h *handler) deleteSignupToken(c *gin.Context) error {
|
||||
tokenID := c.Param("id")
|
||||
|
||||
err := h.service.DeleteSignupToken(c.Request.Context(), tokenID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// signupHandler godoc
|
||||
@@ -178,24 +172,21 @@ func (h *handler) deleteSignupToken(c *gin.Context) {
|
||||
// @Param user body signUpDto true "User information"
|
||||
// @Success 201 {object} dto.UserDto
|
||||
// @Router /api/signup [post]
|
||||
func (h *handler) signup(c *gin.Context) {
|
||||
func (h *handler) signup(c *gin.Context) error {
|
||||
config, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
var input signUpDto
|
||||
err = dto.ShouldBindWithNormalizedJSON(c, &input)
|
||||
err = httpserver.BindJSON(c, &input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
user, accessToken, err := h.service.SignUp(c.Request.Context(), config, input, c.ClientIP(), c.GetHeader("User-Agent"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds())
|
||||
@@ -204,9 +195,9 @@ func (h *handler) signup(c *gin.Context) {
|
||||
var userDto dto.UserDto
|
||||
err = dto.MapStruct(user, &userDto)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, userDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// It's loaded here to create the per-token actors on first startup.
|
||||
|
||||
// signupTokensMigratedKey is the kv key under which the pre-actor signup tokens were frozen.
|
||||
const signupTokensMigratedKey = "signup_tokens_migrated" //nolint:gosec // G101 false positive: this is the name of a kv key, not a credential
|
||||
const signupTokensMigratedKey = "signup_tokens_migrated" // #nosec G101 -- database key name, not a credential
|
||||
|
||||
// migratedSignupToken is the JSON shape of a signup token frozen into the kv table by the migration.
|
||||
// All timestamps are expressed as Unix seconds.
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
@@ -77,10 +78,10 @@ func (m *Module) RunSignupTokenMigration(ctx context.Context) error {
|
||||
// RegisterRoutes mounts the signup and signup-token management endpoints
|
||||
// adminAuth guards the admin token-management routes; signupRateLimit throttles public self-signup
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRateLimit gin.HandlerFunc) {
|
||||
apiGroup.POST("/signup-tokens", adminAuth, m.handler.createSignupToken)
|
||||
apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens)
|
||||
apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken)
|
||||
apiGroup.POST("/signup", signupRateLimit, m.handler.signup)
|
||||
apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable)
|
||||
apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin)
|
||||
apiGroup.POST("/signup-tokens", adminAuth, httpserver.Handle(m.handler.createSignupToken))
|
||||
apiGroup.GET("/signup-tokens", adminAuth, httpserver.Handle(m.handler.listSignupTokens))
|
||||
apiGroup.DELETE("/signup-tokens/:id", adminAuth, httpserver.Handle(m.handler.deleteSignupToken))
|
||||
apiGroup.POST("/signup", signupRateLimit, httpserver.Handle(m.handler.signup))
|
||||
apiGroup.GET("/signup/setup", httpserver.Handle(m.handler.checkInitialAdminSetupAvailable))
|
||||
apiGroup.POST("/signup/setup", httpserver.Handle(m.handler.signUpInitialAdmin))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"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"
|
||||
@@ -46,7 +47,7 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
|
||||
tokenProvided := signupData.Token != ""
|
||||
|
||||
if config.AllowUserSignups.String() != "open" && !tokenProvided {
|
||||
return model.User{}, "", &common.OpenSignupDisabledError{}
|
||||
return model.User{}, "", apperror.OpenSignupDisabled()
|
||||
}
|
||||
|
||||
var userGroupIDs []string
|
||||
@@ -65,7 +66,7 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
|
||||
}
|
||||
|
||||
if consumeRes.Status != signupTokenConsumeOK {
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
return model.User{}, "", apperror.TokenInvalidOrExpired()
|
||||
}
|
||||
userGroupIDs = consumeRes.UserGroupIDs
|
||||
}
|
||||
@@ -160,7 +161,7 @@ func (s *Service) SignUpInitialAdmin(ctx context.Context, config *appconfig.AppC
|
||||
return model.User{}, "", err
|
||||
}
|
||||
if setupCompleted {
|
||||
return model.User{}, "", &common.SetupNotAvailableError{}
|
||||
return model.User{}, "", apperror.SetupAlreadyCompleted()
|
||||
}
|
||||
|
||||
// Build the first user with administrator privileges
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"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"
|
||||
@@ -114,8 +114,7 @@ func TestSignUpRejectsInvalidToken(t *testing.T) {
|
||||
Token: "not-a-real-token",
|
||||
}, "1.2.3.4", "test-agent")
|
||||
|
||||
var invalidErr *common.TokenInvalidOrExpiredError
|
||||
require.ErrorAs(t, err, &invalidErr)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeTokenInvalidOrExpired))
|
||||
}
|
||||
|
||||
func TestSignUpInitialAdminCreatesAdmin(t *testing.T) {
|
||||
@@ -154,8 +153,7 @@ func TestSignUpInitialAdminRejectsExistingInstallation(t *testing.T) {
|
||||
|
||||
// Reject setup when the installation already contains a user
|
||||
_, _, err := svc.SignUpInitialAdmin(t.Context(), appconfig.NewTestConfig(nil), signUpDto{Username: "new-admin"})
|
||||
var setupNotAvailableErr *common.SetupNotAvailableError
|
||||
require.ErrorAs(t, err, &setupNotAvailableErr)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeSetupAlreadyCompleted))
|
||||
}
|
||||
|
||||
// listAllOptions returns list options that return every token on a single page.
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
)
|
||||
|
||||
func TestValidateCallbackURLPattern(t *testing.T) {
|
||||
// #nosec G101
|
||||
const wildcardUserinfoPattern = "https://user:*@example.com/callback"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
@@ -35,7 +38,7 @@ func TestValidateCallbackURLPattern(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "wildcard userinfo",
|
||||
pattern: "https://user:*@example.com/callback", // #nosec G101 - Test credential
|
||||
pattern: wildcardUserinfoPattern,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
var AccessTokenCookieName = "__Host-access_token"
|
||||
var SessionIdCookieName = "__Host-session"
|
||||
var DeviceTokenCookieName = "__Secure-device_token" //nolint:gosec
|
||||
var DeviceLoginTokenCookieName = "__Secure-device_login_token" //nolint:gosec
|
||||
var ReauthenticationTokenCookieName = "__Secure-reauthentication_token" //nolint:gosec
|
||||
var DeviceTokenCookieName = "__Secure-device_token" // #nosec G101 -- cookie name, not a credential
|
||||
var DeviceLoginTokenCookieName = "__Secure-device_login_token" // #nosec G101 -- cookie name, not a credential
|
||||
var ReauthenticationTokenCookieName = "__Secure-reauthentication_token" // #nosec G101 -- cookie name, not a credential
|
||||
|
||||
func init() {
|
||||
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {
|
||||
|
||||
@@ -2,6 +2,7 @@ package profilepicture
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
@@ -19,6 +20,8 @@ import (
|
||||
|
||||
const profilePictureSize = 300
|
||||
|
||||
var ErrInvalidImage = errors.New("invalid image")
|
||||
|
||||
// CreateProfilePicture resizes the profile picture to a square and encodes it as PNG
|
||||
func CreateProfilePicture(file io.ReadSeeker) (io.ReadSeeker, error) {
|
||||
// Attempt standard formats first
|
||||
@@ -30,7 +33,10 @@ func CreateProfilePicture(file io.ReadSeeker) (io.ReadSeeker, error) {
|
||||
// Try WebP
|
||||
webpImg, webpErr := webp.Decode(file)
|
||||
if webpErr != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
return nil, fmt.Errorf("%w: %w", ErrInvalidImage, errors.Join(
|
||||
fmt.Errorf("standard formats: %w", err),
|
||||
fmt.Errorf("WebP: %w", webpErr),
|
||||
))
|
||||
}
|
||||
|
||||
img = webpImg
|
||||
|
||||
@@ -7,8 +7,9 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
@@ -21,113 +22,105 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler {
|
||||
return &handler{service: service, appConfig: appConfig}
|
||||
}
|
||||
|
||||
func (h *handler) beginRegistration(c *gin.Context) {
|
||||
func (h *handler) beginRegistration(c *gin.Context) error {
|
||||
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
options, err := h.service.BeginRegistration(c.Request.Context(), dbConfig, userID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
|
||||
c.JSON(http.StatusOK, options.Response)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) verifyRegistration(c *gin.Context) {
|
||||
func (h *handler) verifyRegistration(c *gin.Context) error {
|
||||
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||
if err != nil {
|
||||
_ = c.Error(&common.MissingSessionIdError{})
|
||||
return
|
||||
return apperror.MissingSessionID()
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
credential, err := h.service.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var credentialDto dto.WebauthnCredentialDto
|
||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, credentialDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) beginLogin(c *gin.Context) {
|
||||
func (h *handler) beginLogin(c *gin.Context) error {
|
||||
options, err := h.service.BeginLogin(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
|
||||
c.JSON(http.StatusOK, options.Response)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) verifyLogin(c *gin.Context) {
|
||||
func (h *handler) verifyLogin(c *gin.Context) error {
|
||||
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||
if err != nil {
|
||||
_ = c.Error(&common.MissingSessionIdError{})
|
||||
return
|
||||
return apperror.MissingSessionID()
|
||||
}
|
||||
|
||||
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return apperror.InvalidWebAuthnResponse(err)
|
||||
}
|
||||
|
||||
user, token, err := h.service.VerifyLogin(c.Request.Context(), dbConfig, sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
maxAge := int(dbConfig.SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) listCredentials(c *gin.Context) {
|
||||
func (h *handler) listCredentials(c *gin.Context) error {
|
||||
userID := c.GetString("userID")
|
||||
credentials, err := h.service.ListCredentials(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var credentialDtos []dto.WebauthnCredentialDto
|
||||
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, credentialDtos)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) deleteCredential(c *gin.Context) {
|
||||
func (h *handler) deleteCredential(c *gin.Context) error {
|
||||
userID := c.GetString("userID")
|
||||
credentialID := c.Param("id")
|
||||
clientIP := c.ClientIP()
|
||||
@@ -135,48 +128,46 @@ func (h *handler) deleteCredential(c *gin.Context) {
|
||||
|
||||
err := h.service.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) updateCredential(c *gin.Context) {
|
||||
func (h *handler) updateCredential(c *gin.Context) error {
|
||||
userID := c.GetString("userID")
|
||||
credentialID := c.Param("id")
|
||||
|
||||
var input dto.WebauthnCredentialUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := httpserver.BindJSON(c, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
credential, err := h.service.UpdateCredential(c.Request.Context(), userID, credentialID, input.Name)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var credentialDto dto.WebauthnCredentialDto
|
||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, credentialDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) logout(c *gin.Context) {
|
||||
func (h *handler) logout(c *gin.Context) error {
|
||||
cookie.AddAccessTokenCookie(c, 0, "")
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) reauthenticate(c *gin.Context) {
|
||||
func (h *handler) reauthenticate(c *gin.Context) error {
|
||||
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||
if err != nil {
|
||||
_ = c.Error(&common.MissingSessionIdError{})
|
||||
return
|
||||
return apperror.MissingSessionID()
|
||||
}
|
||||
|
||||
var token string
|
||||
@@ -186,19 +177,18 @@ func (h *handler) reauthenticate(c *gin.Context) {
|
||||
if err == nil {
|
||||
token, err = h.service.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// If WebAuthn fails, try to create a reauthentication token with the access token
|
||||
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
|
||||
token, err = h.service.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
cookie.AddReauthenticationTokenCookie(c, token)
|
||||
c.Status(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
@@ -56,19 +57,19 @@ func New(deps Dependencies) (*Module, error) {
|
||||
|
||||
// RegisterRoutes mounts the WebAuthn registration, login and reauthentication endpoints
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, userAuth, loginRateLimit, reauthRateLimit gin.HandlerFunc) {
|
||||
apiGroup.GET("/webauthn/register/start", userAuth, m.handler.beginRegistration)
|
||||
apiGroup.POST("/webauthn/register/finish", userAuth, m.handler.verifyRegistration)
|
||||
apiGroup.GET("/webauthn/register/start", userAuth, httpserver.Handle(m.handler.beginRegistration))
|
||||
apiGroup.POST("/webauthn/register/finish", userAuth, httpserver.Handle(m.handler.verifyRegistration))
|
||||
|
||||
apiGroup.GET("/webauthn/login/start", m.handler.beginLogin)
|
||||
apiGroup.POST("/webauthn/login/finish", loginRateLimit, m.handler.verifyLogin)
|
||||
apiGroup.GET("/webauthn/login/start", httpserver.Handle(m.handler.beginLogin))
|
||||
apiGroup.POST("/webauthn/login/finish", loginRateLimit, httpserver.Handle(m.handler.verifyLogin))
|
||||
|
||||
apiGroup.POST("/webauthn/logout", userAuth, m.handler.logout)
|
||||
apiGroup.POST("/webauthn/logout", userAuth, httpserver.Handle(m.handler.logout))
|
||||
|
||||
apiGroup.POST("/webauthn/reauthenticate", userAuth, reauthRateLimit, m.handler.reauthenticate)
|
||||
apiGroup.POST("/webauthn/reauthenticate", userAuth, reauthRateLimit, httpserver.Handle(m.handler.reauthenticate))
|
||||
|
||||
apiGroup.GET("/webauthn/credentials", userAuth, m.handler.listCredentials)
|
||||
apiGroup.PATCH("/webauthn/credentials/:id", userAuth, m.handler.updateCredential)
|
||||
apiGroup.DELETE("/webauthn/credentials/:id", userAuth, m.handler.deleteCredential)
|
||||
apiGroup.GET("/webauthn/credentials", userAuth, httpserver.Handle(m.handler.listCredentials))
|
||||
apiGroup.PATCH("/webauthn/credentials/:id", userAuth, httpserver.Handle(m.handler.updateCredential))
|
||||
apiGroup.DELETE("/webauthn/credentials/:id", userAuth, httpserver.Handle(m.handler.deleteCredential))
|
||||
}
|
||||
|
||||
// ConsumeReauthenticationToken implements the OIDC module's ReauthenticationTokenConsumer interface
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
@@ -26,6 +26,9 @@ const authenticationMethodPhishingResistant = "phr"
|
||||
|
||||
const defaultRPDisplayName = "Pocket ID"
|
||||
|
||||
// go-webauthn exposes the missing user-verification reason only through DevInfo
|
||||
const missingUserVerificationErrorInfo = "User verification required but flag not set by authenticator"
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
webAuthn *gowebauthn.WebAuthn
|
||||
@@ -79,8 +82,11 @@ func (s *Service) BeginRegistration(ctx context.Context, dbConfig *appconfig.App
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Preload("Credentials").
|
||||
Find(&user, "id = ?", userID).
|
||||
First(&user, "id = ?", userID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, apperror.UserNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load user: %w", err)
|
||||
}
|
||||
@@ -138,7 +144,7 @@ func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, user
|
||||
return model.WebauthnCredential{}, fmt.Errorf("failed to load WebAuthn session: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.WebauthnCredential{}, &common.InvalidWebauthnSessionError{}
|
||||
return model.WebauthnCredential{}, apperror.InvalidWebAuthnSession()
|
||||
}
|
||||
|
||||
session := gowebauthn.SessionData{
|
||||
@@ -152,15 +158,18 @@ func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, user
|
||||
var user model.User
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Find(&user, "id = ?", userID).
|
||||
First(&user, "id = ?", userID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.WebauthnCredential{}, apperror.UserNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return model.WebauthnCredential{}, fmt.Errorf("failed to load user: %w", err)
|
||||
}
|
||||
|
||||
credential, err := s.webAuthn.FinishRegistration(&user, session, r)
|
||||
if err != nil {
|
||||
return model.WebauthnCredential{}, fmt.Errorf("failed to finish WebAuthn registration: %w", err)
|
||||
return model.WebauthnCredential{}, classifyPasskeyError(err, apperror.InvalidWebAuthnResponse)
|
||||
}
|
||||
|
||||
// Determine passkey name using AAGUID and User-Agent
|
||||
@@ -248,7 +257,7 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig
|
||||
return model.User{}, "", fmt.Errorf("failed to load WebAuthn session: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.User{}, "", &common.InvalidWebauthnSessionError{}
|
||||
return model.User{}, "", apperror.InvalidWebAuthnSession()
|
||||
}
|
||||
|
||||
session := gowebauthn.SessionData{
|
||||
@@ -265,18 +274,25 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig
|
||||
Preload("Credentials").
|
||||
First(&user, "id = ?", string(userHandle)).
|
||||
Error
|
||||
// Preserve infrastructure failures through go-webauthn's wrapped callback error
|
||||
if innerErr != nil {
|
||||
if !errors.Is(innerErr, gorm.ErrRecordNotFound) {
|
||||
return nil, apperror.Internal(innerErr)
|
||||
}
|
||||
return nil, innerErr
|
||||
}
|
||||
return user, nil
|
||||
}, session, credentialAssertionData)
|
||||
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
return model.User{}, "", classifyPasskeyError(err, apperror.WebAuthnAuthenticationFailed)
|
||||
}
|
||||
if user == nil {
|
||||
return model.User{}, "", apperror.WebAuthnAuthenticationFailed(errors.New("WebAuthn response did not resolve to a user"))
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
return model.User{}, "", &common.UserDisabledError{}
|
||||
return model.User{}, "", apperror.UserDisabled()
|
||||
}
|
||||
|
||||
token, err := s.signer.GenerateAccessToken(*user, authenticationMethodPhishingResistant, dbConfig.SessionDuration.AsDurationMinutes())
|
||||
@@ -321,7 +337,7 @@ func (s *Service) DeleteCredential(ctx context.Context, userID string, credentia
|
||||
return fmt.Errorf("failed to delete record: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
return apperror.NotFound("Passkey")
|
||||
}
|
||||
|
||||
auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.CredentialID), "passkeyName": credential.Name}
|
||||
@@ -331,6 +347,9 @@ func (s *Service) DeleteCredential(ctx context.Context, userID string, credentia
|
||||
WithContext(ctx).
|
||||
First(&actor, "id = ?", actorUserID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperror.UserNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load actor user: %w", err)
|
||||
}
|
||||
@@ -359,6 +378,9 @@ func (s *Service) UpdateCredential(ctx context.Context, userID, credentialID, na
|
||||
Where("id = ? AND user_id = ?", credentialID, userID).
|
||||
First(&credential).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.WebauthnCredential{}, apperror.NotFound("Passkey")
|
||||
}
|
||||
if err != nil {
|
||||
return credential, err
|
||||
}
|
||||
@@ -394,26 +416,26 @@ func (s *Service) CreateReauthenticationTokenWithAccessToken(ctx context.Context
|
||||
|
||||
token, err := s.signer.VerifyAccessToken(accessToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid access token: %w", err)
|
||||
return "", apperror.ReauthenticationRequiredWithCause(err)
|
||||
}
|
||||
|
||||
userID, ok := token.Subject()
|
||||
if !ok {
|
||||
return "", errors.New("access token does not contain user ID")
|
||||
return "", apperror.ReauthenticationRequiredWithCause(errors.New("access token does not contain user ID"))
|
||||
}
|
||||
|
||||
authenticationMethod, err := s.signer.GetAuthenticationMethod(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", apperror.ReauthenticationRequiredWithCause(err)
|
||||
}
|
||||
if authenticationMethod != authenticationMethodPhishingResistant {
|
||||
return "", &common.ReauthenticationRequiredError{}
|
||||
return "", apperror.ReauthenticationRequired()
|
||||
}
|
||||
|
||||
// Check if token is issued less than a minute ago
|
||||
tokenExpiration, ok := token.IssuedAt()
|
||||
if !ok || time.Since(tokenExpiration) > time.Minute {
|
||||
return "", &common.ReauthenticationRequiredError{}
|
||||
return "", apperror.ReauthenticationRequired()
|
||||
}
|
||||
|
||||
var user model.User
|
||||
@@ -421,6 +443,9 @@ func (s *Service) CreateReauthenticationTokenWithAccessToken(ctx context.Context
|
||||
WithContext(ctx).
|
||||
First(&user, "id = ?", userID).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", apperror.ReauthenticationRequiredWithCause(err)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load user: %w", err)
|
||||
}
|
||||
@@ -454,7 +479,7 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s
|
||||
return "", fmt.Errorf("failed to load WebAuthn session: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return "", &common.InvalidWebauthnSessionError{}
|
||||
return "", apperror.InvalidWebAuthnSession()
|
||||
}
|
||||
|
||||
session := gowebauthn.SessionData{
|
||||
@@ -472,14 +497,21 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s
|
||||
Preload("Credentials").
|
||||
First(&user, "id = ?", string(userHandle)).
|
||||
Error
|
||||
// Preserve infrastructure failures through go-webauthn's wrapped callback error
|
||||
if innerErr != nil {
|
||||
if !errors.Is(innerErr, gorm.ErrRecordNotFound) {
|
||||
return nil, apperror.Internal(innerErr)
|
||||
}
|
||||
return nil, innerErr
|
||||
}
|
||||
return user, nil
|
||||
}, session, credentialAssertionData)
|
||||
|
||||
if err != nil || user == nil {
|
||||
return "", err
|
||||
if err != nil {
|
||||
return "", classifyPasskeyError(err, apperror.WebAuthnAuthenticationFailed)
|
||||
}
|
||||
if user == nil {
|
||||
return "", apperror.WebAuthnAuthenticationFailed(errors.New("WebAuthn response did not resolve to a user"))
|
||||
}
|
||||
|
||||
// Create reauthentication token
|
||||
@@ -496,6 +528,20 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func classifyPasskeyError(err error, fallback func(error) *apperror.Error) *apperror.Error {
|
||||
if appErr, ok := errors.AsType[*apperror.Error](err); ok {
|
||||
return appErr
|
||||
}
|
||||
|
||||
if protocolError, ok := errors.AsType[*protocol.Error](err); ok &&
|
||||
protocolError.Type == protocol.ErrVerification.Type &&
|
||||
protocolError.DevInfo == missingUserVerificationErrorInfo {
|
||||
return apperror.PasskeyUserVerificationRequired(err)
|
||||
}
|
||||
|
||||
return fallback(err)
|
||||
}
|
||||
|
||||
func (s *Service) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) {
|
||||
hashedToken := utils.CreateSha256Hash(token)
|
||||
var reauthToken ReauthenticationToken
|
||||
@@ -507,7 +553,7 @@ func (s *Service) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB,
|
||||
return time.Time{}, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return time.Time{}, &common.ReauthenticationRequiredError{}
|
||||
return time.Time{}, apperror.ReauthenticationRequired()
|
||||
}
|
||||
return reauthToken.CreatedAt.UTC(), nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -105,7 +106,7 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
|
||||
|
||||
assert.Empty(t, reauthenticationToken)
|
||||
require.Error(t, err)
|
||||
assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
|
||||
})
|
||||
|
||||
t.Run("rejects a fresh access token without an authentication method", func(t *testing.T) {
|
||||
@@ -117,7 +118,16 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
|
||||
|
||||
assert.Empty(t, reauthenticationToken)
|
||||
require.Error(t, err)
|
||||
assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
|
||||
})
|
||||
|
||||
t.Run("classifies an invalid access token as missing reauthentication", func(t *testing.T) {
|
||||
service, _, _ := setupService(t)
|
||||
|
||||
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), "invalid")
|
||||
|
||||
assert.Empty(t, reauthenticationToken)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeReauthenticationRequired))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,6 +143,54 @@ func TestWebAuthnDisplayNameUsesRequestConfig(t *testing.T) {
|
||||
require.Equal(t, "Custom App", service.webAuthn.Config.RPDisplayName)
|
||||
}
|
||||
|
||||
func TestClassifyPasskeyErrorRecognizesMissingUserVerification(t *testing.T) {
|
||||
rpIDHash := make([]byte, 32)
|
||||
authenticatorData := protocol.AuthenticatorData{
|
||||
RPIDHash: rpIDHash,
|
||||
Flags: protocol.FlagUserPresent,
|
||||
}
|
||||
cause := authenticatorData.Verify(rpIDHash, nil, true, true)
|
||||
require.Error(t, cause)
|
||||
|
||||
err := classifyPasskeyError(cause, apperror.WebAuthnAuthenticationFailed)
|
||||
|
||||
require.True(t, apperror.IsCode(err, apperror.CodePasskeyUserVerificationRequired))
|
||||
require.ErrorIs(t, err, cause)
|
||||
|
||||
other := protocol.ErrVerification.WithInfo("RP Hash mismatch")
|
||||
err = classifyPasskeyError(other, apperror.WebAuthnAuthenticationFailed)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeWebAuthnAuthenticationFailed))
|
||||
}
|
||||
|
||||
func TestClassifyPasskeyErrorPreservesStructuredLookupFailure(t *testing.T) {
|
||||
// Wrap a structured database failure exactly as go-webauthn wraps callback errors
|
||||
cause := errors.New("database unavailable")
|
||||
lookupErr := protocol.ErrBadRequest.WithError(apperror.Internal(cause))
|
||||
|
||||
// Verify the structured failure and diagnostic cause both survive classification
|
||||
err := classifyPasskeyError(lookupErr, apperror.WebAuthnAuthenticationFailed)
|
||||
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeInternal))
|
||||
require.ErrorIs(t, err, cause)
|
||||
}
|
||||
|
||||
func TestWebAuthnManagementOperationsReturnSpecificNotFoundErrors(t *testing.T) {
|
||||
service, err := newService(Dependencies{
|
||||
DB: testutils.NewDatabaseForTest(t),
|
||||
AppURL: "https://example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.BeginRegistration(t.Context(), &appconfig.AppConfigModel{}, "missing-user")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
|
||||
|
||||
_, err = service.UpdateCredential(t.Context(), "missing-user", "missing-passkey", "New name")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
|
||||
err = service.DeleteCredential(t.Context(), "missing-user", "missing-passkey", "", "", "")
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||
}
|
||||
|
||||
// A ceremony that references a session which does not exist must be rejected outright
|
||||
// The delete-and-return leaves the struct zero-valued when nothing matched, and a zero session has an
|
||||
// empty user verification requirement, a zero expiry and an empty challenge, so letting it reach the
|
||||
@@ -158,7 +216,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) {
|
||||
_, err := service.VerifyRegistration(t.Context(), "does-not-exist", userID, nil, "127.0.0.1")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorAs(t, err, new(*common.InvalidWebauthnSessionError))
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
|
||||
})
|
||||
|
||||
t.Run("login rejects an unknown session", func(t *testing.T) {
|
||||
@@ -168,7 +226,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) {
|
||||
|
||||
assert.Empty(t, token)
|
||||
require.Error(t, err)
|
||||
assert.ErrorAs(t, err, new(*common.InvalidWebauthnSessionError))
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
|
||||
})
|
||||
|
||||
t.Run("reauthentication rejects an unknown session", func(t *testing.T) {
|
||||
@@ -178,7 +236,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) {
|
||||
|
||||
assert.Empty(t, token)
|
||||
require.Error(t, err)
|
||||
assert.ErrorAs(t, err, new(*common.InvalidWebauthnSessionError))
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
|
||||
})
|
||||
|
||||
// The reauthentication query filters expired rows out in SQL, so an expired session matches no row
|
||||
@@ -197,7 +255,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) {
|
||||
|
||||
assert.Empty(t, token)
|
||||
require.Error(t, err)
|
||||
assert.ErrorAs(t, err, new(*common.InvalidWebauthnSessionError))
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -38,17 +38,21 @@
|
||||
"expiration": "Expiration",
|
||||
"name": "Name",
|
||||
"an_unknown_error_occurred": "An unknown error occurred",
|
||||
"authentication_process_was_aborted": "The authentication process was aborted",
|
||||
"error_occurred_with_authenticator": "An error occurred with the authenticator",
|
||||
"authenticator_does_not_support_discoverable_credentials": "The authenticator does not support discoverable credentials",
|
||||
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys",
|
||||
"authentication_process_was_aborted": "Passkey verification was canceled",
|
||||
"error_occurred_with_authenticator": "Something went wrong while using your passkey",
|
||||
"authenticator_does_not_support_discoverable_credentials": "This device does not support the type of passkey required",
|
||||
"authenticator_does_not_support_resident_keys": "This device does not support the type of passkey required",
|
||||
"passkey_was_previously_registered": "This passkey was previously registered",
|
||||
"authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms",
|
||||
"webauthn_error_invalid_rp_id": "The configured relying party ID is invalid.",
|
||||
"webauthn_error_invalid_domain": "The configured domain is invalid.",
|
||||
"authenticator_does_not_support_any_of_the_requested_algorithms": "This device does not support a compatible passkey",
|
||||
"webauthn_error_invalid_rp_id": "Passkeys are not configured correctly for this domain.",
|
||||
"webauthn_error_invalid_domain": "Passkeys cannot be used on the configured domain.",
|
||||
"contact_administrator_to_fix": "Contact your administrator to fix this issue.",
|
||||
"webauthn_operation_not_allowed_or_timed_out": "The operation was not allowed or timed out",
|
||||
"webauthn_operation_not_allowed_or_timed_out": "The passkey prompt was canceled or timed out",
|
||||
"webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.",
|
||||
"passkey_request_expired": "Your passkey request has expired",
|
||||
"passkey_response_invalid": "We couldn't process the response from your passkey",
|
||||
"passkey_verification_failed": "We couldn't verify your passkey",
|
||||
"passkey_user_verification_required": "Your passkey couldn't verify you. If you're using a security key, configure a FIDO2 PIN and try again",
|
||||
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
|
||||
"sign_in_to": "Sign in to {name}",
|
||||
"account_selection_signin_confirmation": "Do you want to use the following account to continue to {#b}{name}{/b}?",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
import { AxiosError } from 'axios';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { getAxiosErrorMessage, getAxiosErrorRequestId } from '$lib/utils/error-util';
|
||||
|
||||
export const handleError: HandleClientError = async ({ error, message, status }) => {
|
||||
if (error instanceof AxiosError) {
|
||||
message = error.response?.data.error || message;
|
||||
if (isAxiosError(error)) {
|
||||
message = getAxiosErrorMessage(error, message);
|
||||
status = error.response?.status || status;
|
||||
console.error(
|
||||
`Axios error: ${error.request.path} - ${error.response?.data.error ?? error.message}`
|
||||
`Axios error: ${error.request?.path ?? 'unknown path'} - ${getAxiosErrorMessage(error, error.message)}`,
|
||||
{ requestId: getAxiosErrorRequestId(error) }
|
||||
);
|
||||
} else {
|
||||
console.error(error);
|
||||
|
||||
@@ -1,17 +1,65 @@
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { WebAuthnError } from '@simplewebauthn/browser';
|
||||
import { AxiosError } from 'axios';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: unknown;
|
||||
code?: string;
|
||||
request_id?: string;
|
||||
}
|
||||
|
||||
const codeMessages: Record<string, () => string> = {
|
||||
internal_error: () => m.an_unknown_error_occurred(),
|
||||
request_timeout: () => m.please_try_again(),
|
||||
rate_limited: () => m.please_try_again(),
|
||||
not_signed_in: () => m.please_try_to_sign_in_again(),
|
||||
reauthentication_required: () => m.please_try_to_sign_in_again(),
|
||||
invalid_webauthn_session: () => m.passkey_request_expired(),
|
||||
invalid_webauthn_response: () => m.passkey_response_invalid(),
|
||||
webauthn_authentication_failed: () => m.passkey_verification_failed(),
|
||||
passkey_user_verification_required: () => m.passkey_user_verification_required(),
|
||||
device_login_expired: () => m.device_login_request_expired()
|
||||
};
|
||||
|
||||
function getApiErrorResponse(e: unknown): ApiErrorResponse | undefined {
|
||||
if (!isAxiosError(e)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const data = e.response?.data;
|
||||
if (!data || typeof data !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return data as ApiErrorResponse;
|
||||
}
|
||||
|
||||
export function getAxiosErrorMessage(
|
||||
e: unknown,
|
||||
defaultMessage: string = m.an_unknown_error_occurred()
|
||||
) {
|
||||
let message = defaultMessage;
|
||||
if (e instanceof AxiosError) {
|
||||
message = e.response?.data.error || message;
|
||||
const response = getApiErrorResponse(e);
|
||||
if (!response) {
|
||||
return defaultMessage;
|
||||
}
|
||||
return message;
|
||||
|
||||
const codeMessage = response.code ? codeMessages[response.code]?.() : undefined;
|
||||
return codeMessage || (typeof response.error === 'string' ? response.error : defaultMessage);
|
||||
}
|
||||
|
||||
export function getAxiosErrorRequestId(e: unknown): string | undefined {
|
||||
if (!isAxiosError(e)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bodyRequestId = getApiErrorResponse(e)?.request_id;
|
||||
if (bodyRequestId) {
|
||||
return bodyRequestId;
|
||||
}
|
||||
|
||||
const headerRequestId = e.response?.headers?.['x-request-id'];
|
||||
return typeof headerRequestId === 'string' ? headerRequestId : undefined;
|
||||
}
|
||||
|
||||
export function axiosErrorToast(
|
||||
@@ -39,13 +87,14 @@ export function getWebauthnErrorMessage(e: unknown) {
|
||||
NotAllowedError: m.webauthn_operation_not_allowed_or_timed_out()
|
||||
};
|
||||
|
||||
const response = getApiErrorResponse(e);
|
||||
let message: string = m.an_unknown_error_occurred();
|
||||
if (e instanceof WebAuthnError && e.code in errors) {
|
||||
message = errors[e.code as keyof typeof errors];
|
||||
} else if (e instanceof WebAuthnError && e.cause instanceof Error && e.cause.name in errors) {
|
||||
message = errors[e.cause.name as keyof typeof errors];
|
||||
} else if (e instanceof AxiosError && e.response?.data.error) {
|
||||
message = e.response?.data.error;
|
||||
} else if (isAxiosError(e) && (response?.code || response?.error)) {
|
||||
message = getAxiosErrorMessage(e);
|
||||
} else {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import AppConfigService from '$lib/services/app-config-service';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import { getAxiosErrorMessage, getAxiosErrorRequestId } from '$lib/utils/error-util';
|
||||
import { setLocaleForLibraries } from '$lib/utils/locale.util';
|
||||
import { getAuthRedirectPath } from '$lib/utils/redirection-util';
|
||||
import { setTracingEnabled } from '$lib/utils/tracing-util';
|
||||
@@ -17,8 +19,12 @@ export const load: LayoutLoad = async ({ url }) => {
|
||||
const userPromise = userService.getCurrent().catch(() => null);
|
||||
|
||||
const appConfigPromise = appConfigService.list().catch((e) => {
|
||||
const fallbackMessage = e instanceof Error ? e.message : m.an_unknown_error_occurred();
|
||||
console.error(
|
||||
`Failed to get application configuration: ${e.response?.data.error || e.message}`
|
||||
`Failed to get application configuration: ${getAxiosErrorMessage(e, fallbackMessage)}`,
|
||||
{
|
||||
requestId: getAxiosErrorRequestId(e)
|
||||
}
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user