feat: custom claims (#53)

This commit is contained in:
Elias Schneider
2024-10-28 18:11:54 +01:00
committed by GitHub
parent 3350398abc
commit c056089c60
43 changed files with 1071 additions and 281 deletions

View File

@@ -39,11 +39,13 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService)
customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService)
userGroupService := service.NewUserGroupService(db)
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
@@ -59,6 +61,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {

View File

@@ -1,19 +1,146 @@
package common
import "errors"
var (
ErrUsernameTaken = errors.New("username is already taken")
ErrEmailTaken = errors.New("email is already taken")
ErrSetupAlreadyCompleted = errors.New("setup already completed")
ErrTokenInvalidOrExpired = errors.New("token is invalid or expired")
ErrOidcMissingAuthorization = errors.New("missing authorization")
ErrOidcGrantTypeNotSupported = errors.New("grant type not supported")
ErrOidcMissingClientCredentials = errors.New("client id or secret not provided")
ErrOidcClientSecretInvalid = errors.New("invalid client secret")
ErrOidcInvalidAuthorizationCode = errors.New("invalid authorization code")
ErrOidcInvalidCallbackURL = errors.New("invalid callback URL")
ErrFileTypeNotSupported = errors.New("file type not supported")
ErrInvalidCredentials = errors.New("no user found with provided credentials")
ErrNameAlreadyInUse = errors.New("name is already in use")
import (
"fmt"
"net/http"
)
type AppError interface {
error
HttpStatusCode() int
}
// Custom error types for various conditions
type AlreadyInUseError struct {
Property string
}
func (e *AlreadyInUseError) Error() string {
return fmt.Sprintf("%s is already in use", e.Property)
}
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
type SetupAlreadyCompletedError struct{}
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
type TokenInvalidOrExpiredError struct{}
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
type OidcMissingAuthorizationError struct{}
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
type OidcGrantTypeNotSupportedError struct{}
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
type OidcMissingClientCredentialsError struct{}
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
type OidcInvalidAuthorizationCodeError struct{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL" }
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
type FileTypeNotSupportedError struct{}
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
type InvalidCredentialsError struct{}
func (e *InvalidCredentialsError) Error() string { return "no user found with provided credentials" }
func (e *InvalidCredentialsError) HttpStatusCode() int { return 400 }
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. Please wait a while before trying again."
}
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
type ClientIdOrSecretNotProvidedError struct{}
func (e *ClientIdOrSecretNotProvidedError) Error() string {
return "Client id and secret not provided"
}
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
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 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 }

View File

@@ -1,7 +1,6 @@
package controller
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
@@ -39,13 +38,13 @@ type AppConfigController struct {
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(false)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var configVariablesDto []dto.PublicAppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -55,13 +54,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(true)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -71,19 +70,19 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -136,13 +135,13 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" {
utils.CustomControllerError(c, http.StatusBadRequest, "File must be of type .ico")
c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
return
}
acc.updateImage(c, "favicon", "ico")
@@ -164,17 +163,13 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
file, err := c.FormFile("file")
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
if err != nil {
if errors.Is(err, common.ErrFileTypeNotSupported) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
c.Error(err)
return
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
)
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
@@ -31,7 +30,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -39,7 +38,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}

View File

@@ -0,0 +1,78 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"net/http"
)
func NewCustomClaimController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, customClaimService *service.CustomClaimService) {
wkc := &CustomClaimController{customClaimService: customClaimService}
group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler)
group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler)
group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler)
}
type CustomClaimController struct {
customClaimService *service.CustomClaimService
}
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions()
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, claims)
}
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
userId := c.Param("userId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(userId, input)
if err != nil {
c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
userId := c.Param("userGroupId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input)
if err != nil {
c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}

View File

@@ -1,13 +1,11 @@
package controller
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"strconv"
"strings"
@@ -42,19 +40,13 @@ type OidcController struct {
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
if errors.Is(err, common.ErrOidcMissingAuthorization) {
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
} else if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
c.Error(err)
return
}
@@ -69,17 +61,13 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
c.Error(err)
return
}
@@ -95,7 +83,7 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
var input dto.OidcIdTokenDto
if err := c.ShouldBind(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -107,21 +95,14 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
var ok bool
clientID, clientSecret, ok = c.Request.BasicAuth()
if !ok {
utils.CustomControllerError(c, http.StatusBadRequest, "Client id and secret not provided")
c.Error(&common.ClientIdOrSecretNotProvidedError{})
return
}
}
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
if err != nil {
if errors.Is(err, common.ErrOidcGrantTypeNotSupported) ||
errors.Is(err, common.ErrOidcMissingClientCredentials) ||
errors.Is(err, common.ErrOidcClientSecretInvalid) ||
errors.Is(err, common.ErrOidcInvalidAuthorizationCode) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
c.Error(err)
return
}
@@ -132,14 +113,14 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
if err != nil {
utils.CustomControllerError(c, http.StatusUnauthorized, common.ErrTokenInvalidOrExpired.Error())
c.Error(err)
return
}
userID := jwtClaims.Subject
clientId := jwtClaims.Audience[0]
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -150,7 +131,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -171,7 +152,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
}
}
utils.ControllerError(c, err)
c.Error(err)
}
func (oc *OidcController) listClientsHandler(c *gin.Context) {
@@ -181,13 +162,13 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var clientsDto []dto.OidcClientDto
if err := dto.MapStructList(clients, &clientsDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -200,19 +181,19 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var clientDto dto.OidcClientDto
if err := dto.MapStruct(client, &clientDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -222,7 +203,7 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Param("id"))
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -232,19 +213,19 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var clientDto dto.OidcClientDto
if err := dto.MapStruct(client, &clientDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -254,7 +235,7 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -264,7 +245,7 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -275,17 +256,13 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
if err != nil {
if errors.Is(err, common.ErrFileTypeNotSupported) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
c.Error(err)
return
}
@@ -295,7 +272,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}

View File

@@ -3,7 +3,6 @@ package controller
import (
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
)
@@ -19,22 +18,22 @@ type TestController struct {
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
if err := tc.TestService.ResetDatabase(); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
if err := tc.TestService.ResetApplicationImages(); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
if err := tc.TestService.SeedDatabase(); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
if err := tc.TestService.ResetAppConfig(); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}

View File

@@ -1,13 +1,10 @@
package controller
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/time/rate"
"net/http"
"strconv"
@@ -43,13 +40,13 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var usersDto []dto.UserDto
if err := dto.MapStructList(users, &usersDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -62,13 +59,13 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
func (uc *UserController) getUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.Param("id"))
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -78,13 +75,13 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.GetString("userID"))
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -93,7 +90,7 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -103,23 +100,19 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
func (uc *UserController) createUserHandler(c *gin.Context) {
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
user, err := uc.UserService.CreateUser(input)
if err != nil {
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else {
utils.ControllerError(c, err)
}
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -137,13 +130,13 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -153,17 +146,13 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"))
if err != nil {
if errors.Is(err, common.ErrTokenInvalidOrExpired) {
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
} else {
utils.ControllerError(c, err)
}
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -174,17 +163,13 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.SetupInitialAdmin()
if err != nil {
if errors.Is(err, common.ErrSetupAlreadyCompleted) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -195,7 +180,7 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -208,17 +193,13 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
if err != nil {
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else {
utils.ControllerError(c, err)
}
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}

View File

@@ -1,16 +1,13 @@
package controller
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
)
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
@@ -37,7 +34,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -45,12 +42,12 @@ func (ugc *UserGroupController) list(c *gin.Context) {
for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount
if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
groupsDto[i] = groupDto
@@ -65,13 +62,13 @@ func (ugc *UserGroupController) list(c *gin.Context) {
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Param("id"))
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -81,23 +78,19 @@ func (ugc *UserGroupController) get(c *gin.Context) {
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
group, err := ugc.UserGroupService.Create(input)
if err != nil {
if errors.Is(err, common.ErrNameAlreadyInUse) {
utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else {
utils.ControllerError(c, err)
}
c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -107,23 +100,19 @@ func (ugc *UserGroupController) create(c *gin.Context) {
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
group, err := ugc.UserGroupService.Update(c.Param("id"), input)
if err != nil {
if errors.Is(err, common.ErrNameAlreadyInUse) {
utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else {
utils.ControllerError(c, err)
}
c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -132,7 +121,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
func (ugc *UserGroupController) delete(c *gin.Context) {
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -142,19 +131,19 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}

View File

@@ -1,17 +1,15 @@
package controller
import (
"errors"
"github.com/go-webauthn/webauthn/protocol"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/time/rate"
)
@@ -38,7 +36,7 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
userID := c.GetString("userID")
options, err := wc.webAuthnService.BeginRegistration(userID)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -49,20 +47,20 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
sessionID, err := c.Cookie("session_id")
if err != nil {
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
c.Error(&common.MissingSessionIdError{})
return
}
userID := c.GetString("userID")
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -72,7 +70,7 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
options, err := wc.webAuthnService.BeginLogin()
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -83,13 +81,13 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
sessionID, err := c.Cookie("session_id")
if err != nil {
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
c.Error(&common.MissingSessionIdError{})
return
}
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -97,17 +95,13 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil {
if errors.Is(err, common.ErrInvalidCredentials) {
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
} else {
utils.ControllerError(c, err)
}
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -119,13 +113,13 @@ func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
userID := c.GetString("userID")
credentials, err := wc.webAuthnService.ListCredentials(userID)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -138,7 +132,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
@@ -151,19 +145,19 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}

View File

@@ -4,7 +4,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
)
@@ -21,7 +20,7 @@ type WellKnownController struct {
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
jwk, err := wkc.jwtService.GetJWK()
if err != nil {
utils.ControllerError(c, err)
c.Error(err)
return
}

View File

@@ -0,0 +1,8 @@
package dto
type CustomClaimDto struct {
Key string `json:"key" binding:"required,max=20"`
Value string `json:"value" binding:"required,max=10000"`
}
type CustomClaimCreateDto = CustomClaimDto

View File

@@ -3,12 +3,13 @@ package dto
import "time"
type UserDto struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email" `
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"`
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email" `
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"`
CustomClaims []CustomClaimDto `json:"customClaims"`
}
type UserCreateDto struct {

View File

@@ -3,19 +3,21 @@ package dto
import "time"
type UserGroupDtoWithUsers struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
Users []UserDto `json:"users"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
Users []UserDto `json:"users"`
CreatedAt time.Time `json:"createdAt"`
}
type UserGroupDtoWithUserCount struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
UserCount int64 `json:"userCount"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserCount int64 `json:"userCount"`
CreatedAt time.Time `json:"createdAt"`
}
type UserGroupCreateDto struct {

View File

@@ -1,37 +1,69 @@
package utils
package middleware
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/stonith404/pocket-id/backend/internal/common"
"gorm.io/gorm"
"log"
"net/http"
"strings"
)
import (
"fmt"
)
type ErrorHandlerMiddleware struct{}
func ControllerError(c *gin.Context, err error) {
// Check for record not found errors
if errors.Is(err, gorm.ErrRecordNotFound) {
CustomControllerError(c, http.StatusNotFound, "Record not found")
return
func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware {
return &ErrorHandlerMiddleware{}
}
func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
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
var sliceValidationErrors binding.SliceValidationError
if errors.As(err, &sliceValidationErrors) {
if errors.As(sliceValidationErrors[0], &validationErrors) {
message := handleValidationError(validationErrors)
errorResponse(c, http.StatusBadRequest, message)
return
}
}
var appErr common.AppError
if errors.As(err, &appErr) {
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
return
}
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
}
}
}
// Check for validation errors
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
message := handleValidationError(validationErrors)
CustomControllerError(c, http.StatusBadRequest, message)
return
}
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
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, gin.H{"error": message})
}
func handleValidationError(validationErrors validator.ValidationErrors) string {
@@ -67,9 +99,3 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
return combinedErrors
}
func CustomControllerError(c *gin.Context, statusCode int, message string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, gin.H{"error": message})
}

View File

@@ -3,7 +3,7 @@ package middleware
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/internal/common"
"net/http"
)
@@ -17,8 +17,8 @@ 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 {
utils.CustomControllerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize)))
c.Abort()
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
c.Error(err)
return
}
c.Next()

View File

@@ -2,9 +2,8 @@ package middleware
import (
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"strings"
)
@@ -29,8 +28,7 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
c.Next()
return
} else {
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
c.Abort()
c.Error(&common.NotSignedInError{})
return
}
}
@@ -40,14 +38,14 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
c.Next()
return
} else if err != nil {
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
c.Error(&common.NotSignedInError{})
c.Abort()
return
}
// Check if the user is an admin
if adminOnly && !claims.IsAdmin {
utils.CustomControllerError(c, http.StatusForbidden, "You don't have permission to access this resource")
c.Error(&common.MissingPermissionError{})
c.Abort()
return
}

View File

@@ -2,8 +2,6 @@ package middleware
import (
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"sync"
"time"
@@ -33,8 +31,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
limiter := getLimiter(ip, limit, burst)
if !limiter.Allow() {
utils.CustomControllerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
c.Abort()
c.Error(&common.TooManyRequestsError{})
return
}

View File

@@ -0,0 +1,11 @@
package model
type CustomClaim struct {
Base
Key string
Value string
UserID *string
UserGroupID *string
}

View File

@@ -15,8 +15,9 @@ type User struct {
LastName string
IsAdmin bool
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
Credentials []WebauthnCredential
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
Credentials []WebauthnCredential
}
func (u User) WebAuthnID() []byte { return []byte(u.ID) }

View File

@@ -5,4 +5,5 @@ type UserGroup struct {
FriendlyName string
Name string `gorm:"unique"`
Users []User `gorm:"many2many:user_groups_users;"`
CustomClaims []CustomClaim
}

View File

@@ -165,7 +165,7 @@ func (s *AppConfigService) UpdateImage(uploadedFile *multipart.FileHeader, image
fileType := utils.GetFileExtension(uploadedFile.Filename)
mimeType := utils.GetImageMimeType(fileType)
if mimeType == "" {
return common.ErrFileTypeNotSupported
return &common.FileTypeNotSupportedError{}
}
// Delete the old image if it has a different file type

View File

@@ -0,0 +1,197 @@
package service
import (
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
// Reserved claims
var reservedClaims = map[string]struct{}{
"given_name": {},
"family_name": {},
"name": {},
"email": {},
"preferred_username": {},
"groups": {},
"sub": {},
"iss": {},
"aud": {},
"exp": {},
"iat": {},
"auth_time": {},
"nonce": {},
"acr": {},
"amr": {},
"azp": {},
"nbf": {},
"jti": {},
}
type CustomClaimService struct {
db *gorm.DB
}
func NewCustomClaimService(db *gorm.DB) *CustomClaimService {
return &CustomClaimService{db: db}
}
// isReservedClaim checks if a claim key is reserved e.g. email, preferred_username
func isReservedClaim(key string) bool {
_, ok := reservedClaims[key]
return ok
}
// idType is the type of the id used to identify the user or user group
type idType string
const (
UserID idType = "user_id"
UserGroupID idType = "user_group_id"
)
// UpdateCustomClaimsForUser updates the custom claims for a user
func (s *CustomClaimService) UpdateCustomClaimsForUser(userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
return s.updateCustomClaims(UserID, userID, claims)
}
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
return s.updateCustomClaims(UserGroupID, userGroupID, claims)
}
// updateCustomClaims updates the custom claims for a user or user group
func (s *CustomClaimService) updateCustomClaims(idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
// Check for duplicate keys in the claims slice
seenKeys := make(map[string]bool)
for _, claim := range claims {
if seenKeys[claim.Key] {
return nil, &common.DuplicateClaimError{Key: claim.Key}
}
seenKeys[claim.Key] = true
}
var existingClaims []model.CustomClaim
err := s.db.Where(string(idType), value).Find(&existingClaims).Error
if err != nil {
return nil, err
}
// Delete claims that are not in the new list
for _, existingClaim := range existingClaims {
found := false
for _, claim := range claims {
if claim.Key == existingClaim.Key {
found = true
break
}
}
if !found {
err = s.db.Delete(&existingClaim).Error
if err != nil {
return nil, err
}
}
}
// Add or update claims
for _, claim := range claims {
if isReservedClaim(claim.Key) {
return nil, &common.ReservedClaimError{Key: claim.Key}
}
customClaim := model.CustomClaim{
Key: claim.Key,
Value: claim.Value,
}
if idType == UserID {
customClaim.UserID = &value
} else if idType == UserGroupID {
customClaim.UserGroupID = &value
}
// Update the claim if it already exists or create a new one
err = s.db.Where(string(idType)+" = ? AND key = ?", value, claim.Key).Assign(&customClaim).FirstOrCreate(&model.CustomClaim{}).Error
if err != nil {
return nil, err
}
}
// Get the updated claims
var updatedClaims []model.CustomClaim
err = s.db.Where(string(idType)+" = ?", value).Find(&updatedClaims).Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}
func (s *CustomClaimService) GetCustomClaimsForUser(userID string) ([]model.CustomClaim, error) {
var customClaims []model.CustomClaim
err := s.db.Where("user_id = ?", userID).Find(&customClaims).Error
return customClaims, err
}
func (s *CustomClaimService) GetCustomClaimsForUserGroup(userGroupID string) ([]model.CustomClaim, error) {
var customClaims []model.CustomClaim
err := s.db.Where("user_group_id = ?", userGroupID).Find(&customClaims).Error
return customClaims, err
}
// GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of,
// prioritizing the user's claims over user group claims with the same key.
func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(userID string) ([]model.CustomClaim, error) {
// Get the custom claims of the user
customClaims, err := s.GetCustomClaimsForUser(userID)
if err != nil {
return nil, err
}
// Store user's claims in a map to prioritize and prevent duplicates
claimsMap := make(map[string]model.CustomClaim)
for _, claim := range customClaims {
claimsMap[claim.Key] = claim
}
// Get all user groups of the user
var userGroupsOfUser []model.UserGroup
err = s.db.Preload("CustomClaims").
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
Where("user_groups_users.user_id = ?", userID).
Find(&userGroupsOfUser).Error
if err != nil {
return nil, err
}
// Add only non-duplicate custom claims from user groups
for _, userGroup := range userGroupsOfUser {
for _, groupClaim := range userGroup.CustomClaims {
// Only add claim if it does not exist in the user's claims
if _, exists := claimsMap[groupClaim.Key]; !exists {
claimsMap[groupClaim.Key] = groupClaim
}
}
}
// Convert the claimsMap back to a slice
finalClaims := make([]model.CustomClaim, 0, len(claimsMap))
for _, claim := range claimsMap {
finalClaims = append(finalClaims, claim)
}
return finalClaims, nil
}
// GetSuggestions returns a list of custom claim keys that have been used before
func (s *CustomClaimService) GetSuggestions() ([]string, error) {
var customClaimsKeys []string
err := s.db.Model(&model.CustomClaim{}).
Group("key").
Order("COUNT(*) DESC").
Pluck("key", &customClaimsKeys).Error
return customClaimsKeys, err
}

View File

@@ -18,18 +18,20 @@ import (
)
type OidcService struct {
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
auditLogService *AuditLogService
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
auditLogService *AuditLogService
customClaimService *CustomClaimService
}
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService) *OidcService {
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService, customClaimService *CustomClaimService) *OidcService {
return &OidcService{
db: db,
jwtService: jwtService,
appConfigService: appConfigService,
auditLogService: auditLogService,
db: db,
jwtService: jwtService,
appConfigService: appConfigService,
auditLogService: auditLogService,
customClaimService: customClaimService,
}
}
@@ -38,7 +40,7 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
if userAuthorizedOIDCClient.Scope != input.Scope {
return "", "", common.ErrOidcMissingAuthorization
return "", "", &common.OidcMissingAuthorizationError{}
}
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
@@ -93,11 +95,11 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
if grantType != "authorization_code" {
return "", "", common.ErrOidcGrantTypeNotSupported
return "", "", &common.OidcGrantTypeNotSupportedError{}
}
if clientID == "" || clientSecret == "" {
return "", "", common.ErrOidcMissingClientCredentials
return "", "", &common.OidcMissingClientCredentialsError{}
}
var client model.OidcClient
@@ -107,17 +109,17 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
if err != nil {
return "", "", common.ErrOidcClientSecretInvalid
return "", "", &common.OidcClientSecretInvalidError{}
}
var authorizationCodeMetaData model.OidcAuthorizationCode
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
if err != nil {
return "", "", common.ErrOidcInvalidAuthorizationCode
return "", "", &common.OidcInvalidAuthorizationCodeError{}
}
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
return "", "", common.ErrOidcInvalidAuthorizationCode
return "", "", &common.OidcInvalidAuthorizationCodeError{}
}
userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID)
@@ -249,7 +251,7 @@ func (s *OidcService) GetClientLogo(clientID string) (string, string, error) {
func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHeader) error {
fileType := utils.GetFileExtension(file.Filename)
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
return common.ErrFileTypeNotSupported
return &common.FileTypeNotSupportedError{}
}
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, clientID, fileType)
@@ -334,9 +336,20 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
}
if strings.Contains(scope, "profile") {
// Add profile claims
for k, v := range profileClaims {
claims[k] = v
}
// Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(userID)
if err != nil {
return nil, err
}
for _, customClaim := range customClaims {
claims[customClaim.Key] = customClaim.Value
}
}
if strings.Contains(scope, "email") {
claims["email"] = user.Email
@@ -375,5 +388,5 @@ func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackU
return inputCallbackURL, nil
}
return "", common.ErrOidcInvalidCallbackURL
return "", &common.OidcInvalidCallbackURLError{}
}

View File

@@ -18,7 +18,7 @@ func NewUserGroupService(db *gorm.DB) *UserGroupService {
}
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
query := s.db.Model(&model.UserGroup{})
query := s.db.Preload("CustomClaims").Model(&model.UserGroup{})
if name != "" {
query = query.Where("name LIKE ?", "%"+name+"%")
@@ -29,7 +29,7 @@ func (s *UserGroupService) List(name string, page int, pageSize int) (groups []m
}
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) {
err = s.db.Where("id = ?", id).Preload("Users").First(&group).Error
err = s.db.Where("id = ?", id).Preload("CustomClaims").Preload("Users").First(&group).Error
return group, err
}
@@ -50,7 +50,7 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
if err := s.db.Preload("Users").Create(&group).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, common.ErrNameAlreadyInUse
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
}
return model.UserGroup{}, err
}
@@ -68,7 +68,7 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto) (grou
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, common.ErrNameAlreadyInUse
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
}
return model.UserGroup{}, err
}

View File

@@ -35,7 +35,7 @@ func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]mo
func (s *UserService) GetUser(userID string) (model.User, error) {
var user model.User
err := s.db.Where("id = ?", userID).First(&user).Error
err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
return user, err
}
@@ -111,7 +111,7 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, stri
var oneTimeAccessToken model.OneTimeAccessToken
if err := s.db.Where("token = ? AND expires_at > ?", token, time.Now().Unix()).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", common.ErrTokenInvalidOrExpired
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
@@ -133,7 +133,7 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
return model.User{}, "", err
}
if userCount > 1 {
return model.User{}, "", common.ErrSetupAlreadyCompleted
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
user := model.User{
@@ -149,7 +149,7 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
}
if len(user.Credentials) > 0 {
return model.User{}, "", common.ErrSetupAlreadyCompleted
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
token, err := s.jwtService.GenerateAccessToken(user)
@@ -163,11 +163,11 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
func (s *UserService) checkDuplicatedFields(user model.User) error {
var existingUser model.User
if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
return common.ErrEmailTaken
return &common.AlreadyInUseError{Property: "email"}
}
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
return common.ErrUsernameTaken
return &common.AlreadyInUseError{Property: "username"}
}
return nil