mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-12 16:23:00 +03:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1a6c8db85 | ||
|
|
552d7ccfa5 | ||
|
|
e45b0b3ed0 | ||
|
|
8166e2ead7 | ||
|
|
ae7aeb0945 | ||
|
|
16f273ffce | ||
|
|
9f49e5577e | ||
|
|
d92b80b80f | ||
|
|
aaed71e1c8 | ||
|
|
4780548843 |
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,3 +1,22 @@
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.2.1...v) (2024-08-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add support for multiple callback urls ([8166e2e](https://github.com/stonith404/pocket-id/commit/8166e2ead7fc71a0b7a45950b05c5c65a60833b6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* db migration for multiple callback urls ([552d7cc](https://github.com/stonith404/pocket-id/commit/552d7ccfa58d7922ecb94bdfe6a86651b4cf2745))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.2.0...v) (2024-08-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* session duration can't be updated ([4780548](https://github.com/stonith404/pocket-id/commit/478054884389ed8a08d707fd82da7b31177a67e5))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.1.3...v) (2024-08-19)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/783dc0c1-1580-476b-9bb1-d9ef1077bc1e" width="1200"/>
|
||||
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
|
||||
|
||||
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.
|
||||
|
||||
@@ -91,10 +91,17 @@ You may need the following information:
|
||||
|
||||
- **Authorization URL**: `https://<your-domain>/authorize`
|
||||
- **Token URL**: `https://<your-domain>/api/oidc/token`
|
||||
- **Userinfo URL**: `https://<your-domain>/api/oidc/userinfo`
|
||||
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
|
||||
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
|
||||
- **PKCE**: `false` as this is not supported yet.
|
||||
|
||||
### Proxy Services with Pocket ID
|
||||
|
||||
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC.
|
||||
|
||||
See the [guide](docs/proxy-services.md) for more information.
|
||||
|
||||
### Update
|
||||
|
||||
#### Docker
|
||||
|
||||
@@ -27,12 +27,6 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
r := gin.Default()
|
||||
r.Use(gin.Logger())
|
||||
|
||||
// Add middleware
|
||||
r.Use(
|
||||
middleware.NewCorsMiddleware().Add(),
|
||||
middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60),
|
||||
)
|
||||
|
||||
// Initialize services
|
||||
webauthnService := service.NewWebAuthnService(db, appConfigService)
|
||||
jwtService := service.NewJwtService(appConfigService)
|
||||
@@ -40,8 +34,13 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
oidcService := service.NewOidcService(db, jwtService)
|
||||
testService := service.NewTestService(db, appConfigService)
|
||||
|
||||
// Add global middleware
|
||||
r.Use(middleware.NewCorsMiddleware().Add())
|
||||
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
|
||||
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
|
||||
|
||||
// Initialize middleware
|
||||
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService)
|
||||
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
|
||||
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
||||
|
||||
// Set up API routes
|
||||
@@ -49,7 +48,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, jwtService)
|
||||
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
|
||||
controller.NewApplicationConfigurationController(apiGroup, jwtAuthMiddleware, appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
||||
|
||||
// Add test controller in non-production environments
|
||||
if common.EnvConfig.AppEnv != "production" {
|
||||
|
||||
@@ -6,13 +6,13 @@ var (
|
||||
ErrUsernameTaken = errors.New("username is already taken")
|
||||
ErrEmailTaken = errors.New("email is already taken")
|
||||
ErrSetupAlreadyCompleted = errors.New("setup already completed")
|
||||
ErrInvalidBody = errors.New("invalid request body")
|
||||
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")
|
||||
)
|
||||
|
||||
@@ -5,19 +5,19 @@ import (
|
||||
"fmt"
|
||||
"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/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func NewApplicationConfigurationController(
|
||||
func NewAppConfigController(
|
||||
group *gin.RouterGroup,
|
||||
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
|
||||
appConfigService *service.AppConfigService) {
|
||||
|
||||
acc := &ApplicationConfigurationController{
|
||||
acc := &AppConfigController{
|
||||
appConfigService: appConfigService,
|
||||
}
|
||||
group.GET("/application-configuration", acc.listApplicationConfigurationHandler)
|
||||
@@ -32,86 +32,104 @@ func NewApplicationConfigurationController(
|
||||
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
|
||||
}
|
||||
|
||||
type ApplicationConfigurationController struct {
|
||||
type AppConfigController struct {
|
||||
appConfigService *service.AppConfigService
|
||||
}
|
||||
|
||||
func (acc *ApplicationConfigurationController) listApplicationConfigurationHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) listApplicationConfigurationHandler(c *gin.Context) {
|
||||
configuration, err := acc.appConfigService.ListApplicationConfiguration(false)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, configuration)
|
||||
var configVariablesDto []dto.PublicAppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, configVariablesDto)
|
||||
}
|
||||
|
||||
func (acc *ApplicationConfigurationController) listAllApplicationConfigurationHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) listAllApplicationConfigurationHandler(c *gin.Context) {
|
||||
configuration, err := acc.appConfigService.ListApplicationConfiguration(true)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, configuration)
|
||||
var configVariablesDto []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, configVariablesDto)
|
||||
}
|
||||
|
||||
func (acc *ApplicationConfigurationController) updateApplicationConfigurationHandler(c *gin.Context) {
|
||||
var input model.AppConfigUpdateDto
|
||||
func (acc *AppConfigController) updateApplicationConfigurationHandler(c *gin.Context) {
|
||||
var input dto.AppConfigUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
savedConfigVariables, err := acc.appConfigService.UpdateApplicationConfiguration(input)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, savedConfigVariables)
|
||||
var configVariablesDto []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, configVariablesDto)
|
||||
}
|
||||
|
||||
func (acc *ApplicationConfigurationController) getLogoHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
||||
imageType := acc.appConfigService.DbConfig.LogoImageType.Value
|
||||
acc.getImage(c, "logo", imageType)
|
||||
}
|
||||
|
||||
func (acc *ApplicationConfigurationController) getFaviconHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
||||
acc.getImage(c, "favicon", "ico")
|
||||
}
|
||||
|
||||
func (acc *ApplicationConfigurationController) getBackgroundImageHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
|
||||
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
|
||||
acc.getImage(c, "background", imageType)
|
||||
}
|
||||
|
||||
func (acc *ApplicationConfigurationController) updateLogoHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
||||
imageType := acc.appConfigService.DbConfig.LogoImageType.Value
|
||||
acc.updateImage(c, "logo", imageType)
|
||||
}
|
||||
|
||||
func (acc *ApplicationConfigurationController) updateFaviconHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
if fileType != "ico" {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "File must be of type .ico")
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, "File must be of type .ico")
|
||||
return
|
||||
}
|
||||
acc.updateImage(c, "favicon", "ico")
|
||||
}
|
||||
|
||||
func (acc *ApplicationConfigurationController) updateBackgroundImageHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
|
||||
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
|
||||
acc.updateImage(c, "background", imageType)
|
||||
}
|
||||
|
||||
func (acc *ApplicationConfigurationController) getImage(c *gin.Context, name string, imageType string) {
|
||||
func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) {
|
||||
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
|
||||
mimeType := utils.GetImageMimeType(imageType)
|
||||
|
||||
@@ -119,19 +137,19 @@ func (acc *ApplicationConfigurationController) getImage(c *gin.Context, name str
|
||||
c.File(imagePath)
|
||||
}
|
||||
|
||||
func (acc *ApplicationConfigurationController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
||||
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrFileTypeNotSupported) {
|
||||
utils.HandlerError(c, http.StatusBadRequest, err.Error())
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ 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/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
@@ -40,71 +40,87 @@ type OidcController struct {
|
||||
}
|
||||
|
||||
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
var parsedBody model.AuthorizeRequest
|
||||
if err := c.ShouldBindJSON(&parsedBody); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
var input dto.AuthorizeOidcClientRequestDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
code, err := oc.oidcService.Authorize(parsedBody, c.GetString("userID"))
|
||||
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"))
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrOidcMissingAuthorization) {
|
||||
utils.HandlerError(c, http.StatusForbidden, err.Error())
|
||||
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
|
||||
} else if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": code})
|
||||
response := dto.AuthorizeOidcClientResponseDto{
|
||||
Code: code,
|
||||
CallbackURL: callbackURL,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
||||
var parsedBody model.AuthorizeNewClientDto
|
||||
if err := c.ShouldBindJSON(&parsedBody); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
var input dto.AuthorizeOidcClientRequestDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
code, err := oc.oidcService.AuthorizeNewClient(parsedBody, c.GetString("userID"))
|
||||
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"))
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": code})
|
||||
response := dto.AuthorizeOidcClientResponseDto{
|
||||
Code: code,
|
||||
CallbackURL: callbackURL,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (oc *OidcController) createIDTokenHandler(c *gin.Context) {
|
||||
var body model.OidcIdTokenDto
|
||||
var input dto.OidcIdTokenDto
|
||||
|
||||
if err := c.ShouldBind(&body); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
if err := c.ShouldBind(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := body.ClientID
|
||||
clientSecret := body.ClientSecret
|
||||
clientID := input.ClientID
|
||||
clientSecret := input.ClientSecret
|
||||
|
||||
// Client id and secret can also be passed over the Authorization header
|
||||
if clientID == "" || clientSecret == "" {
|
||||
var ok bool
|
||||
clientID, clientSecret, ok = c.Request.BasicAuth()
|
||||
if !ok {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "Client id and secret not provided")
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, "Client id and secret not provided")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
idToken, accessToken, err := oc.oidcService.CreateTokens(body.Code, body.GrantType, clientID, clientSecret)
|
||||
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.HandlerError(c, http.StatusBadRequest, err.Error())
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -116,14 +132,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.HandlerError(c, http.StatusUnauthorized, common.ErrTokenInvalidOrExpired.Error())
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, common.ErrTokenInvalidOrExpired.Error())
|
||||
return
|
||||
}
|
||||
userID := jwtClaims.Subject
|
||||
clientId := jwtClaims.Audience[0]
|
||||
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,11 +150,28 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||
clientId := c.Param("id")
|
||||
client, err := oc.oidcService.GetClient(clientId)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, client)
|
||||
// Return a different DTO based on the user's role
|
||||
if c.GetBool("userIsAdmin") {
|
||||
clientDto := dto.OidcClientDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
clientDto := dto.PublicOidcClientDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
|
||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
@@ -148,36 +181,48 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
|
||||
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var clientsDto []dto.OidcClientDto
|
||||
if err := dto.MapStructList(clients, &clientsDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": clients,
|
||||
"data": clientsDto,
|
||||
"pagination": pagination,
|
||||
})
|
||||
}
|
||||
|
||||
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||
var input model.OidcClientCreateDto
|
||||
var input dto.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, client)
|
||||
var clientDto dto.OidcClientDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, clientDto)
|
||||
}
|
||||
|
||||
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
err := oc.oidcService.DeleteClient(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -185,25 +230,31 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
var input model.OidcClientCreateDto
|
||||
var input dto.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, client)
|
||||
var clientDto dto.OidcClientDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
}
|
||||
|
||||
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -213,7 +264,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.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -224,16 +275,16 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrFileTypeNotSupported) {
|
||||
utils.HandlerError(c, http.StatusBadRequest, err.Error())
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -244,7 +295,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.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
|
||||
@@ -18,19 +19,19 @@ type TestController struct {
|
||||
|
||||
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
if err := tc.TestService.ResetDatabase(); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetApplicationImages(); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.SeedDatabase(); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"message": "Database reset and seeded"})
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ 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/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/time/rate"
|
||||
@@ -43,12 +43,18 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||
|
||||
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var usersDto []dto.UserDto
|
||||
if err := dto.MapStructList(users, &usersDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": users,
|
||||
"data": usersDto,
|
||||
"pagination": pagination,
|
||||
})
|
||||
}
|
||||
@@ -56,25 +62,38 @@ 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.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
||||
user, err := uc.UserService.GetUser(c.GetString("userID"))
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, user)
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,22 +101,29 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := c.ShouldBindJSON(&user); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
var input dto.UserCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := uc.UserService.CreateUser(&user); err != nil {
|
||||
user, err := uc.UserService.CreateUser(input)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
|
||||
utils.HandlerError(c, http.StatusConflict, err.Error())
|
||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, user)
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, userDto)
|
||||
}
|
||||
|
||||
func (uc *UserController) updateUserHandler(c *gin.Context) {
|
||||
@@ -109,15 +135,15 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
var input model.OneTimeAccessTokenCreateDto
|
||||
var input dto.OneTimeAccessTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,9 +154,9 @@ 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.HandlerError(c, http.StatusUnauthorized, err.Error())
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -143,21 +169,27 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
||||
user, token, err := uc.UserService.SetupInitialAdmin()
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrSetupAlreadyCompleted) {
|
||||
utils.HandlerError(c, http.StatusBadRequest, err.Error())
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||
c.JSON(http.StatusOK, user)
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
var updatedUser model.User
|
||||
if err := c.ShouldBindJSON(&updatedUser); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
var input dto.UserCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -168,15 +200,21 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
userID = c.Param("id")
|
||||
}
|
||||
|
||||
user, err := uc.UserService.UpdateUser(userID, updatedUser, updateOwnUser)
|
||||
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
|
||||
utils.HandlerError(c, http.StatusConflict, err.Error())
|
||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ package controller
|
||||
import (
|
||||
"errors"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -40,8 +39,7 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
options, err := wc.webAuthnService.BeginRegistration(userID)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
log.Println(err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -52,24 +50,30 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
||||
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "Session ID missing")
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, credential)
|
||||
var credentialDto dto.WebauthnCredentialDto
|
||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, credentialDto)
|
||||
}
|
||||
|
||||
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
||||
options, err := wc.webAuthnService.BeginLogin()
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,13 +84,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.HandlerError(c, http.StatusBadRequest, "Session ID missing")
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
|
||||
return
|
||||
}
|
||||
|
||||
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -94,32 +98,44 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
||||
user, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrInvalidCredentials) {
|
||||
utils.HandlerError(c, http.StatusUnauthorized, err.Error())
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
|
||||
} else {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
token, err := wc.jwtService.GenerateAccessToken(*user)
|
||||
token, err := wc.jwtService.GenerateAccessToken(user)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||
c.JSON(http.StatusOK, user)
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
credentials, err := wc.webAuthnService.ListCredentials(userID)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, credentials)
|
||||
var credentialDtos []dto.WebauthnCredentialDto
|
||||
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, credentialDtos)
|
||||
}
|
||||
|
||||
func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
|
||||
@@ -128,7 +144,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
|
||||
|
||||
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,19 +155,25 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
credentialID := c.Param("id")
|
||||
|
||||
var input model.WebauthnCredentialUpdateDto
|
||||
var input dto.WebauthnCredentialUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
|
||||
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
var credentialDto dto.WebauthnCredentialDto
|
||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, credentialDto)
|
||||
}
|
||||
|
||||
func (wc *WebauthnController) logoutHandler(c *gin.Context) {
|
||||
|
||||
@@ -21,7 +21,7 @@ type WellKnownController struct {
|
||||
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
||||
jwk, err := wkc.jwtService.GetJWK()
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
utils.ControllerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
17
backend/internal/dto/app_config_dto.go
Normal file
17
backend/internal/dto/app_config_dto.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package dto
|
||||
|
||||
type PublicAppConfigVariableDto struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type AppConfigVariableDto struct {
|
||||
PublicAppConfigVariableDto
|
||||
IsPublic bool `json:"isPublic"`
|
||||
}
|
||||
|
||||
type AppConfigUpdateDto struct {
|
||||
AppName string `json:"appName" binding:"required,min=1,max=30"`
|
||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||
}
|
||||
79
backend/internal/dto/dto_mapper.go
Normal file
79
backend/internal/dto/dto_mapper.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// MapStructList maps a list of source structs to a list of destination structs
|
||||
func MapStructList[S any, D any](source []S, destination *[]D) error {
|
||||
for _, item := range source {
|
||||
var destItem D
|
||||
if err := MapStruct(item, &destItem); err != nil {
|
||||
return err
|
||||
}
|
||||
*destination = append(*destination, destItem)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MapStruct maps a source struct to a destination struct
|
||||
func MapStruct[S any, D any](source S, destination *D) error {
|
||||
// Ensure destination is a non-nil pointer
|
||||
destValue := reflect.ValueOf(destination)
|
||||
if destValue.Kind() != reflect.Ptr || destValue.IsNil() {
|
||||
return errors.New("destination must be a non-nil pointer to a struct")
|
||||
}
|
||||
|
||||
// Ensure source is a struct
|
||||
sourceValue := reflect.ValueOf(source)
|
||||
if sourceValue.Kind() != reflect.Struct {
|
||||
return errors.New("source must be a struct")
|
||||
}
|
||||
|
||||
return mapStructInternal(sourceValue, destValue.Elem())
|
||||
}
|
||||
|
||||
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
|
||||
// Loop through the fields of the destination struct
|
||||
for i := 0; i < destVal.NumField(); i++ {
|
||||
destField := destVal.Field(i)
|
||||
destFieldType := destVal.Type().Field(i)
|
||||
|
||||
if destFieldType.Anonymous {
|
||||
// Recursively handle embedded structs
|
||||
if err := mapStructInternal(sourceVal, destField); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
sourceField := sourceVal.FieldByName(destFieldType.Name)
|
||||
|
||||
// If the source field is valid and can be assigned to the destination field
|
||||
if sourceField.IsValid() && destField.CanSet() {
|
||||
// Handle direct assignment for simple types
|
||||
if sourceField.Type() == destField.Type() {
|
||||
destField.Set(sourceField)
|
||||
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
|
||||
// Handle slices
|
||||
if sourceField.Type().Elem() == destField.Type().Elem() {
|
||||
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
|
||||
|
||||
for j := 0; j < sourceField.Len(); j++ {
|
||||
newSlice.Index(j).Set(sourceField.Index(j))
|
||||
}
|
||||
|
||||
destField.Set(newSlice)
|
||||
}
|
||||
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
|
||||
// Recursively map nested structs
|
||||
if err := mapStructInternal(sourceField, destField); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
37
backend/internal/dto/oidc_dto.go
Normal file
37
backend/internal/dto/oidc_dto.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package dto
|
||||
|
||||
type PublicOidcClientDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type OidcClientDto struct {
|
||||
PublicOidcClientDto
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
CreatedBy UserDto `json:"createdBy"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientRequestDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
Code string `json:"code"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
}
|
||||
|
||||
type OidcIdTokenDto struct {
|
||||
GrantType string `form:"grant_type" binding:"required"`
|
||||
Code string `form:"code" binding:"required"`
|
||||
ClientID string `form:"client_id"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
}
|
||||
30
backend/internal/dto/user_dto.go
Normal file
30
backend/internal/dto/user_dto.go
Normal file
@@ -0,0 +1,30 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=3,max=20"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
FirstName string `json:"firstName" binding:"required,min=3,max=30"`
|
||||
LastName string `json:"lastName" binding:"required,min=3,max=30"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginUserDto struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
39
backend/internal/dto/validations.go
Normal file
39
backend/internal/dto/validations.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"log"
|
||||
"net/url"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var validateUrlList validator.Func = func(fl validator.FieldLevel) bool {
|
||||
urls := fl.Field().Interface().([]string)
|
||||
for _, u := range urls {
|
||||
_, err := url.ParseRequestURI(u)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
||||
regex := "^[a-z0-9_]*$"
|
||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||
return matched
|
||||
}
|
||||
|
||||
func init() {
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := v.RegisterValidation("urlList", validateUrlList); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
}
|
||||
}
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := v.RegisterValidation("username", validateUsername); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
backend/internal/dto/webauthn_dto.go
Normal file
23
backend/internal/dto/webauthn_dto.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WebauthnCredentialDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CredentialID string `json:"credentialID"`
|
||||
AttestationType string `json:"attestationType"`
|
||||
Transport []protocol.AuthenticatorTransport `json:"transport"`
|
||||
|
||||
BackupEligible bool `json:"backupEligible"`
|
||||
BackupState bool `json:"backupState"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type WebauthnCredentialUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=30"`
|
||||
}
|
||||
@@ -17,7 +17,7 @@ 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.HandlerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize)))
|
||||
utils.CustomControllerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize)))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,11 +9,12 @@ import (
|
||||
)
|
||||
|
||||
type JwtAuthMiddleware struct {
|
||||
jwtService *service.JwtService
|
||||
jwtService *service.JwtService
|
||||
ignoreUnauthenticated bool
|
||||
}
|
||||
|
||||
func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware {
|
||||
return &JwtAuthMiddleware{jwtService: jwtService}
|
||||
func NewJwtAuthMiddleware(jwtService *service.JwtService, ignoreUnauthenticated bool) *JwtAuthMiddleware {
|
||||
return &JwtAuthMiddleware{jwtService: jwtService, ignoreUnauthenticated: ignoreUnauthenticated}
|
||||
}
|
||||
|
||||
func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
||||
@@ -24,23 +25,29 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
||||
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
|
||||
if len(authorizationHeaderSplitted) == 2 {
|
||||
token = authorizationHeaderSplitted[1]
|
||||
} else if m.ignoreUnauthenticated {
|
||||
c.Next()
|
||||
return
|
||||
} else {
|
||||
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
claims, err := m.jwtService.VerifyAccessToken(token)
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
|
||||
if err != nil && m.ignoreUnauthenticated {
|
||||
c.Next()
|
||||
return
|
||||
} else if err != nil {
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the user is an admin
|
||||
if adminOnly && !claims.IsAdmin {
|
||||
utils.HandlerError(c, http.StatusForbidden, "You don't have permission to access this resource")
|
||||
utils.CustomControllerError(c, http.StatusForbidden, "You don't have permission to access this resource")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
|
||||
|
||||
limiter := getLimiter(ip, limit, burst)
|
||||
if !limiter.Allow() {
|
||||
utils.HandlerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
|
||||
utils.CustomControllerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package model
|
||||
|
||||
type AppConfigVariable struct {
|
||||
Key string `gorm:"primaryKey;not null" json:"key"`
|
||||
Type string `json:"type"`
|
||||
IsPublic bool `json:"-"`
|
||||
IsInternal bool `json:"-"`
|
||||
Value string `json:"value"`
|
||||
Key string `gorm:"primaryKey;not null"`
|
||||
Type string
|
||||
IsPublic bool
|
||||
IsInternal bool
|
||||
Value string
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
@@ -14,7 +14,3 @@ type AppConfig struct {
|
||||
LogoImageType AppConfigVariable
|
||||
SessionDuration AppConfigVariable
|
||||
}
|
||||
|
||||
type AppConfigUpdateDto struct {
|
||||
AppName string `json:"appName" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
// Base contains common columns for all tables.
|
||||
type Base struct {
|
||||
ID string `gorm:"primaryKey;not null" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `gorm:"primaryKey;not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
|
||||
|
||||
@@ -1,38 +1,22 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserAuthorizedOidcClient struct {
|
||||
Scope string
|
||||
UserID string `json:"userId" gorm:"primary_key;"`
|
||||
UserID string `gorm:"primary_key;"`
|
||||
User User
|
||||
|
||||
ClientID string `json:"clientId" gorm:"primary_key;"`
|
||||
ClientID string `gorm:"primary_key;"`
|
||||
Client OidcClient
|
||||
}
|
||||
|
||||
type OidcClient struct {
|
||||
Base
|
||||
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"-"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
ImageType *string `json:"-"`
|
||||
HasLogo bool `gorm:"-" json:"hasLogo"`
|
||||
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
}
|
||||
|
||||
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
// Compute HasLogo field
|
||||
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
|
||||
return nil
|
||||
}
|
||||
|
||||
type OidcAuthorizationCode struct {
|
||||
Base
|
||||
|
||||
@@ -47,26 +31,35 @@ type OidcAuthorizationCode struct {
|
||||
ClientID string
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL" binding:"required"`
|
||||
type OidcClient struct {
|
||||
Base
|
||||
|
||||
Name string
|
||||
Secret string
|
||||
CallbackURLs CallbackURLs
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
}
|
||||
|
||||
type AuthorizeNewClientDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
Nonce string `json:"nonce"`
|
||||
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
// Compute HasLogo field
|
||||
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
|
||||
return nil
|
||||
}
|
||||
|
||||
type OidcIdTokenDto struct {
|
||||
GrantType string `form:"grant_type" binding:"required"`
|
||||
Code string `form:"code" binding:"required"`
|
||||
ClientID string `form:"client_id"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
type CallbackURLs []string
|
||||
|
||||
func (cu *CallbackURLs) Scan(value interface{}) error {
|
||||
if v, ok := value.([]byte); ok {
|
||||
return json.Unmarshal(v, cu)
|
||||
} else {
|
||||
return errors.New("type assertion to []byte failed")
|
||||
}
|
||||
}
|
||||
|
||||
type AuthorizeRequest struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
Nonce string `json:"nonce"`
|
||||
func (cu CallbackURLs) Value() (driver.Value, error) {
|
||||
return json.Marshal(cu)
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
type User struct {
|
||||
Base
|
||||
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email" `
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Username string
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
IsAdmin bool
|
||||
|
||||
Credentials []WebauthnCredential `json:"-"`
|
||||
Credentials []WebauthnCredential
|
||||
}
|
||||
|
||||
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
||||
@@ -59,19 +59,9 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
||||
|
||||
type OneTimeAccessToken struct {
|
||||
Base
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
|
||||
UserID string `json:"userId"`
|
||||
UserID string
|
||||
User User
|
||||
}
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginUserDto struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ type WebauthnSession struct {
|
||||
type WebauthnCredential struct {
|
||||
Base
|
||||
|
||||
Name string `json:"name"`
|
||||
CredentialID string `json:"credentialID"`
|
||||
PublicKey []byte `json:"-"`
|
||||
AttestationType string `json:"attestationType"`
|
||||
Transport AuthenticatorTransportList `json:"-"`
|
||||
Name string
|
||||
CredentialID string
|
||||
PublicKey []byte
|
||||
AttestationType string
|
||||
Transport AuthenticatorTransportList
|
||||
|
||||
BackupEligible bool `json:"backupEligible"`
|
||||
BackupState bool `json:"backupState"`
|
||||
@@ -32,15 +32,15 @@ type WebauthnCredential struct {
|
||||
}
|
||||
|
||||
type PublicKeyCredentialCreationOptions struct {
|
||||
Response protocol.PublicKeyCredentialCreationOptions `json:"response"`
|
||||
SessionID string `json:"session_id"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
Response protocol.PublicKeyCredentialCreationOptions
|
||||
SessionID string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type PublicKeyCredentialRequestOptions struct {
|
||||
Response protocol.PublicKeyCredentialRequestOptions `json:"response"`
|
||||
SessionID string `json:"session_id"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
Response protocol.PublicKeyCredentialRequestOptions
|
||||
SessionID string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type AuthenticatorTransportList []protocol.AuthenticatorTransport
|
||||
@@ -58,7 +58,3 @@ func (atl *AuthenticatorTransportList) Scan(value interface{}) error {
|
||||
func (atl AuthenticatorTransportList) Value() (driver.Value, error) {
|
||||
return json.Marshal(atl)
|
||||
}
|
||||
|
||||
type WebauthnCredentialUpdateDto struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
@@ -54,8 +55,8 @@ var defaultDbConfig = model.AppConfig{
|
||||
},
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateApplicationConfiguration(input model.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||
savedConfigVariables := make([]model.AppConfigVariable, 10)
|
||||
func (s *AppConfigService) UpdateApplicationConfiguration(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||
var savedConfigVariables []model.AppConfigVariable
|
||||
|
||||
tx := s.db.Begin()
|
||||
rt := reflect.ValueOf(input).Type()
|
||||
@@ -78,7 +79,7 @@ func (s *AppConfigService) UpdateApplicationConfiguration(input model.AppConfigU
|
||||
return nil, err
|
||||
}
|
||||
|
||||
savedConfigVariables[i] = applicationConfigurationVariable
|
||||
savedConfigVariables = append(savedConfigVariables, applicationConfigurationVariable)
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -26,33 +28,50 @@ func NewOidcService(db *gorm.DB, jwtService *JwtService) *OidcService {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OidcService) Authorize(req model.AuthorizeRequest, userID string) (string, error) {
|
||||
func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID string) (string, string, error) {
|
||||
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||
s.db.First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", req.ClientID, userID)
|
||||
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
||||
|
||||
if userAuthorizedOIDCClient.Scope != req.Scope {
|
||||
return "", common.ErrOidcMissingAuthorization
|
||||
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||
return "", "", common.ErrOidcMissingAuthorization
|
||||
}
|
||||
|
||||
return s.createAuthorizationCode(req.ClientID, userID, req.Scope, req.Nonce)
|
||||
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
||||
return code, callbackURL, err
|
||||
}
|
||||
|
||||
func (s *OidcService) AuthorizeNewClient(req model.AuthorizeNewClientDto, userID string) (string, error) {
|
||||
func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID string) (string, string, error) {
|
||||
var client model.OidcClient
|
||||
if err := s.db.First(&client, "id = ?", input.ClientID).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
callbackURL, err := getCallbackURL(client, input.CallbackURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: req.ClientID,
|
||||
Scope: req.Scope,
|
||||
ClientID: input.ClientID,
|
||||
Scope: input.Scope,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&userAuthorizedClient).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
err = s.db.Model(&userAuthorizedClient).Update("scope", req.Scope).Error
|
||||
err = s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error
|
||||
} else {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
return s.createAuthorizationCode(req.ClientID, userID, req.Scope, req.Nonce)
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
||||
return code, callbackURL, err
|
||||
}
|
||||
|
||||
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
||||
@@ -101,18 +120,18 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
|
||||
return idToken, accessToken, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) GetClient(clientID string) (*model.OidcClient, error) {
|
||||
func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) {
|
||||
var client model.OidcClient
|
||||
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||
return nil, err
|
||||
if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
return &client, nil
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]model.OidcClient, utils.PaginationResponse, error) {
|
||||
var clients []model.OidcClient
|
||||
|
||||
query := s.db.Model(&model.OidcClient{})
|
||||
query := s.db.Preload("CreatedBy").Model(&model.OidcClient{})
|
||||
if searchTerm != "" {
|
||||
searchPattern := "%" + searchTerm + "%"
|
||||
query = query.Where("name LIKE ?", searchPattern)
|
||||
@@ -126,34 +145,34 @@ func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]
|
||||
return clients, pagination, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) CreateClient(input model.OidcClientCreateDto, userID string) (*model.OidcClient, error) {
|
||||
func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
|
||||
client := model.OidcClient{
|
||||
Name: input.Name,
|
||||
CallbackURL: input.CallbackURL,
|
||||
CreatedByID: userID,
|
||||
Name: input.Name,
|
||||
CallbackURLs: input.CallbackURLs,
|
||||
CreatedByID: userID,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&client).Error; err != nil {
|
||||
return nil, err
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) UpdateClient(clientID string, input model.OidcClientCreateDto) (*model.OidcClient, error) {
|
||||
func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDto) (model.OidcClient, error) {
|
||||
var client model.OidcClient
|
||||
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||
return nil, err
|
||||
if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
client.Name = input.Name
|
||||
client.CallbackURL = input.CallbackURL
|
||||
client.CallbackURLs = input.CallbackURLs
|
||||
|
||||
if err := s.db.Save(&client).Error; err != nil {
|
||||
return nil, err
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) DeleteClient(clientID string) error {
|
||||
@@ -320,3 +339,14 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
|
||||
|
||||
return randomString, nil
|
||||
}
|
||||
|
||||
func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
||||
if inputCallbackURL == "" {
|
||||
return client.CallbackURLs[0], nil
|
||||
}
|
||||
if slices.Contains(client.CallbackURLs, inputCallbackURL) {
|
||||
return inputCallbackURL, nil
|
||||
}
|
||||
|
||||
return "", common.ErrOidcInvalidCallbackURL
|
||||
}
|
||||
|
||||
@@ -61,20 +61,20 @@ func (s *TestService) SeedDatabase() error {
|
||||
Base: model.Base{
|
||||
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
},
|
||||
Name: "Nextcloud",
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURL: "http://nextcloud/auth/callback",
|
||||
ImageType: utils.StringPointer("png"),
|
||||
CreatedByID: users[0].ID,
|
||||
Name: "Nextcloud",
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURLs: model.CallbackURLs{"http://nextcloud/auth/callback"},
|
||||
ImageType: utils.StringPointer("png"),
|
||||
CreatedByID: users[0].ID,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
},
|
||||
Name: "Immich",
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURL: "http://immich/auth/callback",
|
||||
CreatedByID: users[0].ID,
|
||||
Name: "Immich",
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"},
|
||||
CreatedByID: users[0].ID,
|
||||
},
|
||||
}
|
||||
for _, client := range oidcClients {
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
@@ -46,17 +47,24 @@ func (s *UserService) DeleteUser(userID string) error {
|
||||
return s.db.Delete(&user).Error
|
||||
}
|
||||
|
||||
func (s *UserService) CreateUser(user *model.User) error {
|
||||
func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
|
||||
user := &model.User{
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
Email: input.Email,
|
||||
Username: input.Username,
|
||||
IsAdmin: input.IsAdmin,
|
||||
}
|
||||
if err := s.db.Create(user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return s.checkDuplicatedFields(*user)
|
||||
return model.User{}, s.checkDuplicatedFields(*user)
|
||||
}
|
||||
return err
|
||||
return model.User{}, err
|
||||
}
|
||||
return nil
|
||||
return *user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateUser(userID string, updatedUser model.User, updateOwnUser bool) (model.User, error) {
|
||||
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool) (model.User, error) {
|
||||
var user model.User
|
||||
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return model.User{}, err
|
||||
|
||||
@@ -67,10 +67,10 @@ func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCred
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.Request) (*model.WebauthnCredential, error) {
|
||||
func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.Request) (model.WebauthnCredential, error) {
|
||||
var storedSession model.WebauthnSession
|
||||
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
|
||||
return nil, err
|
||||
return model.WebauthnCredential{}, err
|
||||
}
|
||||
|
||||
session := webauthn.SessionData{
|
||||
@@ -81,12 +81,12 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
|
||||
|
||||
var user model.User
|
||||
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
|
||||
return nil, err
|
||||
return model.WebauthnCredential{}, err
|
||||
}
|
||||
|
||||
credential, err := s.webAuthn.FinishRegistration(&user, session, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return model.WebauthnCredential{}, err
|
||||
}
|
||||
|
||||
credentialToStore := model.WebauthnCredential{
|
||||
@@ -100,10 +100,10 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
|
||||
BackupState: credential.Flags.BackupState,
|
||||
}
|
||||
if err := s.db.Create(&credentialToStore).Error; err != nil {
|
||||
return nil, err
|
||||
return model.WebauthnCredential{}, err
|
||||
}
|
||||
|
||||
return &credentialToStore, nil
|
||||
return credentialToStore, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions, error) {
|
||||
@@ -129,10 +129,10 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (*model.User, error) {
|
||||
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (model.User, error) {
|
||||
var storedSession model.WebauthnSession
|
||||
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
|
||||
return nil, err
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
session := webauthn.SessionData{
|
||||
@@ -149,14 +149,14 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
|
||||
}, session, credentialAssertionData)
|
||||
|
||||
if err != nil {
|
||||
return nil, common.ErrInvalidCredentials
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
|
||||
return nil, err
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
return *user, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) ListCredentials(userID string) ([]model.WebauthnCredential, error) {
|
||||
@@ -180,17 +180,17 @@ func (s *WebAuthnService) DeleteCredential(userID, credentialID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) error {
|
||||
func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) (model.WebauthnCredential, error) {
|
||||
var credential model.WebauthnCredential
|
||||
if err := s.db.Where("id = ? AND user_id = ?", credentialID, userID).First(&credential).Error; err != nil {
|
||||
return err
|
||||
return credential, err
|
||||
}
|
||||
|
||||
credential.Name = name
|
||||
|
||||
if err := s.db.Save(&credential).Error; err != nil {
|
||||
return err
|
||||
return credential, err
|
||||
}
|
||||
|
||||
return nil
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
75
backend/internal/utils/controller_error_util.go
Normal file
75
backend/internal/utils/controller_error_util.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 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 contain only lowercase letters, numbers, and underscores", fieldName)
|
||||
case "url":
|
||||
errorMessage = fmt.Sprintf("%s must be a valid URL", 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())
|
||||
case "urlList":
|
||||
errorMessage = fmt.Sprintf("%s must be a list of valid URLs", fieldName)
|
||||
default:
|
||||
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
|
||||
}
|
||||
|
||||
errorMessages = append(errorMessages, errorMessage)
|
||||
}
|
||||
|
||||
// Join all the error messages into a single string
|
||||
combinedErrors := strings.Join(errorMessages, ", ")
|
||||
|
||||
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})
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func UnknownHandlerError(c *gin.Context, err error) {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
HandlerError(c, http.StatusNotFound, "Record not found")
|
||||
return
|
||||
} else {
|
||||
log.Println(err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func HandlerError(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})
|
||||
}
|
||||
@@ -57,7 +57,7 @@ CREATE TABLE webauthn_credentials
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key BLOB NOT NULL,
|
||||
attestation_type TEXT NOT NULL,
|
||||
transport TEXT NOT NULL,
|
||||
transport BLOB NOT NULL,
|
||||
user_id TEXT REFERENCES users
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
create table oidc_clients
|
||||
(
|
||||
id TEXT not null primary key,
|
||||
created_at DATETIME,
|
||||
name TEXT,
|
||||
secret TEXT,
|
||||
callback_url TEXT,
|
||||
image_type TEXT,
|
||||
created_by_id TEXT
|
||||
references users
|
||||
);
|
||||
|
||||
insert into oidc_clients(id, created_at, name, secret, callback_url, image_type, created_by_id)
|
||||
select id,
|
||||
created_at,
|
||||
name,
|
||||
secret,
|
||||
json_extract(callback_urls, '$[0]'),
|
||||
image_type,
|
||||
created_by_id
|
||||
from oidc_clients_dg_tmp;
|
||||
|
||||
drop table oidc_clients_dg_tmp;
|
||||
@@ -0,0 +1,26 @@
|
||||
create table oidc_clients_dg_tmp
|
||||
(
|
||||
id TEXT not null primary key,
|
||||
created_at DATETIME,
|
||||
name TEXT,
|
||||
secret TEXT,
|
||||
callback_urls BLOB,
|
||||
image_type TEXT,
|
||||
created_by_id TEXT
|
||||
references users
|
||||
);
|
||||
|
||||
insert into oidc_clients_dg_tmp(id, created_at, name, secret, callback_urls, image_type, created_by_id)
|
||||
select id,
|
||||
created_at,
|
||||
name,
|
||||
secret,
|
||||
CAST('["' || callback_url || '"]' AS BLOB),
|
||||
image_type,
|
||||
created_by_id
|
||||
from oidc_clients;
|
||||
|
||||
drop table oidc_clients;
|
||||
|
||||
alter table oidc_clients_dg_tmp
|
||||
rename to oidc_clients;
|
||||
81
docs/proxy-services.md
Normal file
81
docs/proxy-services.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Proxy Services through Pocket ID
|
||||
|
||||
The goal of Pocket ID is to stay simple. Because of that we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC. This guide will show you how to set up OAuth2 Proxy with Pocket ID.
|
||||
|
||||
## Docker Setup
|
||||
|
||||
#### 1. Add OAuth2 proxy to the service that should be proxied.
|
||||
|
||||
To configure OAuth2 Proxy with Pocket ID, you have to add the following service to the service that should be proxied. E.g., [Uptime Kuma](https://github.com/louislam/uptime-kuma) should be proxied, you can add the following service to the `docker-compose.yml` of Uptime Kuma:
|
||||
|
||||
```yaml
|
||||
# Example with Uptime Kuma
|
||||
# uptime-kuma:
|
||||
# image: louislam/uptime-kuma
|
||||
oauth2-proxy:
|
||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
|
||||
command: --config /oauth2-proxy.cfg
|
||||
volumes:
|
||||
- "./oauth2-proxy.cfg:/oauth2-proxy.cfg"
|
||||
ports:
|
||||
- 4180:4180
|
||||
```
|
||||
|
||||
#### 2. Create a new OIDC client in Pocket ID.
|
||||
|
||||
Create a new OIDC client in Pocket ID by navigating to `https://<your-domain>/settings/admin/oidc-clients`. After adding the client, you will obtain the client ID and client secret.
|
||||
|
||||
#### 3. Create a configuration file for OAuth2 Proxy.
|
||||
|
||||
Create a configuration file named `oauth2-proxy.cfg` in the same directory as your `docker-compose.yml` file of the service that should be proxied (e.g. Uptime Kuma). This file will contain the necessary configurations for OAuth2 Proxy to work with Pocket ID.
|
||||
|
||||
Here is the recommend `oauth2-proxy.cfg` configuration:
|
||||
|
||||
```cfg
|
||||
# Replace with your own credentials
|
||||
client_id="client-id-from-pocket-id"
|
||||
client_secret="client-secret-from-pocket-id"
|
||||
oidc_issuer_url="https://<your-pocket-id-domain>"
|
||||
|
||||
# Replace with a secure random string
|
||||
cookie_secret="random-string"
|
||||
|
||||
# Upstream servers (e.g http://uptime-kuma:3001)
|
||||
upstreams="http://<service-to-be-proxied>:<port>"
|
||||
|
||||
# Additional Configuration
|
||||
provider="oidc"
|
||||
scope = "openid email profile"
|
||||
|
||||
# If you are using a reverse proxy in front of OAuth2 Proxy
|
||||
reverse_proxy = true
|
||||
|
||||
# Email domains allowed for authentication
|
||||
email_domains = ["*"]
|
||||
|
||||
# If you are using HTTPS
|
||||
cookie_secure="true"
|
||||
|
||||
# Listen on all interfaces
|
||||
http_address="0.0.0.0:4180"
|
||||
```
|
||||
|
||||
For additional configuration options, refer to the official [OAuth2 Proxy documentation](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview).
|
||||
|
||||
#### 4. Start the services.
|
||||
|
||||
After creating the configuration file, you can start the services using Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### 5. Access the service.
|
||||
|
||||
You can now access the service through OAuth2 Proxy by visiting `http://localhost:4180`.
|
||||
|
||||
## Standalone Installation
|
||||
|
||||
Setting up OAuth2 Proxy with Pocket ID without Docker is similar to the Docker setup. As the setup depends on your environment, you have to adjust the steps accordingly but is should be similar to the Docker setup.
|
||||
|
||||
You can visit the official [OAuth2 Proxy documentation](https://oauth2-proxy.github.io/oauth2-proxy/installation) for more information.
|
||||
@@ -2,15 +2,17 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type { FormInput } from '$lib/utils/form-util';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
let {
|
||||
input = $bindable(),
|
||||
label,
|
||||
description,
|
||||
children
|
||||
}: {
|
||||
input: FormInput<string | boolean | number>;
|
||||
children,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
input?: FormInput<string | boolean | number>;
|
||||
label: string;
|
||||
description?: string;
|
||||
children?: Snippet;
|
||||
@@ -19,19 +21,19 @@
|
||||
const id = label.toLowerCase().replace(/ /g, '-');
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div {...restProps}>
|
||||
<Label class="mb-0" for={id}>{label}</Label>
|
||||
{#if description}
|
||||
<p class="text-muted-foreground text-xs mt-1">{description}</p>
|
||||
<p class="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||
{/if}
|
||||
<div class="mt-2">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
{:else if input}
|
||||
<Input {id} bind:value={input.value} />
|
||||
{/if}
|
||||
{#if input.error}
|
||||
<p class="text-sm text-red-500">{input.error}</p>
|
||||
{#if input?.error}
|
||||
<p class="mt-1 text-sm text-red-500">{input.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import type { OidcClient, OidcClientCreate } from '$lib/types/oidc.type';
|
||||
import type { AuthorizeResponse, OidcClient, OidcClientCreate } from '$lib/types/oidc.type';
|
||||
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
||||
import APIService from './api-service';
|
||||
|
||||
class OidcService extends APIService {
|
||||
async authorize(clientId: string, scope: string, nonce?: string) {
|
||||
async authorize(clientId: string, scope: string, callbackURL : string, nonce?: string) {
|
||||
const res = await this.api.post('/oidc/authorize', {
|
||||
scope,
|
||||
nonce,
|
||||
callbackURL,
|
||||
clientId
|
||||
});
|
||||
|
||||
return res.data.code as string;
|
||||
return res.data as AuthorizeResponse;
|
||||
}
|
||||
|
||||
async authorizeNewClient(clientId: string, scope: string, nonce?: string) {
|
||||
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string) {
|
||||
const res = await this.api.post('/oidc/authorize/new-client', {
|
||||
scope,
|
||||
nonce,
|
||||
callbackURL,
|
||||
clientId
|
||||
});
|
||||
|
||||
return res.data.code as string;
|
||||
return res.data as AuthorizeResponse;
|
||||
}
|
||||
|
||||
async listClients(search?: string, pagination?: PaginationRequest) {
|
||||
|
||||
@@ -2,7 +2,7 @@ export type OidcClient = {
|
||||
id: string;
|
||||
name: string;
|
||||
logoURL: string;
|
||||
callbackURL: string;
|
||||
callbackURLs: [string, ...string[]];
|
||||
hasLogo: boolean;
|
||||
};
|
||||
|
||||
@@ -11,3 +11,8 @@ export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
||||
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
||||
logo: File | null;
|
||||
};
|
||||
|
||||
export type AuthorizeResponse = {
|
||||
code: string;
|
||||
callbackURL: string;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
scope: url.searchParams.get('scope')!,
|
||||
nonce: url.searchParams.get('nonce') || undefined,
|
||||
state: url.searchParams.get('state')!,
|
||||
callbackURL: url.searchParams.get('redirect_uri')!,
|
||||
client
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
let authorizationRequired = false;
|
||||
|
||||
export let data: PageData;
|
||||
let { scope, nonce, client, state } = data;
|
||||
let { scope, nonce, client, state, callbackURL } = data;
|
||||
|
||||
async function authorize() {
|
||||
isLoading = true;
|
||||
@@ -36,9 +36,11 @@
|
||||
await webauthnService.finishLogin(authResponse);
|
||||
}
|
||||
|
||||
await oidService.authorize(client!.id, scope, nonce).then(async (code) => {
|
||||
onSuccess(code);
|
||||
});
|
||||
await oidService
|
||||
.authorize(client!.id, scope, callbackURL, nonce)
|
||||
.then(async ({ code, callbackURL }) => {
|
||||
onSuccess(code, callbackURL);
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof AxiosError && e.response?.status === 403) {
|
||||
authorizationRequired = true;
|
||||
@@ -52,19 +54,21 @@
|
||||
async function authorizeNewClient() {
|
||||
isLoading = true;
|
||||
try {
|
||||
await oidService.authorizeNewClient(client!.id, scope, nonce).then(async (code) => {
|
||||
onSuccess(code);
|
||||
});
|
||||
await oidService
|
||||
.authorizeNewClient(client!.id, scope, callbackURL, nonce)
|
||||
.then(async ({ code, callbackURL }) => {
|
||||
onSuccess(code, callbackURL);
|
||||
});
|
||||
} catch (e) {
|
||||
errorMessage = getWebauthnErrorMessage(e);
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSuccess(code: string) {
|
||||
function onSuccess(code: string, callbackURL: string) {
|
||||
success = true;
|
||||
setTimeout(() => {
|
||||
window.location.href = `${client!.callbackURL}?code=${code}&state=${state}`;
|
||||
window.location.href = `${callbackURL}?code=${code}&state=${state}`;
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
<form onsubmit={onSubmit}>
|
||||
<div class="flex flex-col gap-5">
|
||||
<FormInput label="Application Name" bind:input={$inputs.appName} />
|
||||
|
||||
<FormInput
|
||||
label="Session Duration"
|
||||
description="The duration of a session in minutes before the user has to sign in again."
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { LucideMinus, LucidePlus } from 'lucide-svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
callbackURLs = $bindable(),
|
||||
error = $bindable(null),
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
callbackURLs: string[];
|
||||
error?: string | null;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const limit = 5;
|
||||
</script>
|
||||
|
||||
<div {...restProps}>
|
||||
<FormInput label="Callback URLs">
|
||||
<div class="flex flex-col gap-y-2">
|
||||
{#each callbackURLs as _, i}
|
||||
<div class="flex gap-x-2">
|
||||
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
|
||||
{#if callbackURLs.length > 1}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
on:click={() => callbackURLs = callbackURLs.filter((_, index) => index !== i)}
|
||||
>
|
||||
<LucideMinus class="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</FormInput>
|
||||
{#if error}
|
||||
<p class="mt-1 text-sm text-red-500">{error}</p>
|
||||
{/if}
|
||||
{#if callbackURLs.length < limit}
|
||||
<Button
|
||||
class="mt-2"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
on:click={() => callbackURLs = [...callbackURLs, '']}
|
||||
>
|
||||
<LucidePlus class="mr-1 h-4 w-4" />
|
||||
Add another
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -10,6 +10,7 @@
|
||||
} from '$lib/types/oidc.type';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { z } from 'zod';
|
||||
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
||||
|
||||
let {
|
||||
callback,
|
||||
@@ -27,12 +28,12 @@
|
||||
|
||||
const client: OidcClientCreate = {
|
||||
name: existingClient?.name || '',
|
||||
callbackURL: existingClient?.callbackURL || ''
|
||||
callbackURLs: existingClient?.callbackURLs || [""]
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(50),
|
||||
callbackURL: z.string().url()
|
||||
callbackURLs: z.array(z.string().url()).nonempty()
|
||||
});
|
||||
|
||||
type FormSchema = typeof formSchema;
|
||||
@@ -70,32 +71,40 @@
|
||||
</script>
|
||||
|
||||
<form onsubmit={onSubmit}>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<FormInput label="Name" bind:input={$inputs.name} />
|
||||
<FormInput label="Callback URL" bind:input={$inputs.callbackURL} />
|
||||
<div class="mt-3">
|
||||
<Label for="logo">Logo</Label>
|
||||
<div class="mt-2 flex items-end gap-3">
|
||||
{#if logoDataURL}
|
||||
<div class="h-32 w-32 rounded-2xl bg-muted p-3">
|
||||
<img class="m-auto max-h-full max-w-full object-contain" src={logoDataURL} alt={`${$inputs.name.value} logo`} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<FileInput
|
||||
id="logo"
|
||||
variant="secondary"
|
||||
accept="image/png, image/jpeg, image/svg+xml"
|
||||
onchange={onLogoChange}
|
||||
>
|
||||
<Button variant="secondary">
|
||||
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
|
||||
</Button>
|
||||
</FileInput>
|
||||
{#if logoDataURL}
|
||||
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
|
||||
<OidcCallbackUrlInput
|
||||
class="w-full"
|
||||
bind:callbackURLs={$inputs.callbackURLs.value}
|
||||
bind:error={$inputs.callbackURLs.error}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<Label for="logo">Logo</Label>
|
||||
<div class="mt-2 flex items-end gap-3">
|
||||
{#if logoDataURL}
|
||||
<div class="bg-muted h-32 w-32 rounded-2xl p-3">
|
||||
<img
|
||||
class="m-auto max-h-full max-w-full object-contain"
|
||||
src={logoDataURL}
|
||||
alt={`${$inputs.name.value} logo`}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<FileInput
|
||||
id="logo"
|
||||
variant="secondary"
|
||||
accept="image/png, image/jpeg, image/svg+xml"
|
||||
onchange={onLogoChange}
|
||||
>
|
||||
<Button variant="secondary">
|
||||
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
|
||||
</Button>
|
||||
</FileInput>
|
||||
{#if logoDataURL}
|
||||
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,9 +26,13 @@
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
firstName: z.string().min(2).max(50),
|
||||
lastName: z.string().min(2).max(50),
|
||||
username: z.string().min(2).max(50),
|
||||
firstName: z.string().min(2).max(30),
|
||||
lastName: z.string().min(2).max(30),
|
||||
username: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(30)
|
||||
.regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores are allowed'),
|
||||
email: z.string().email(),
|
||||
isAdmin: z.boolean()
|
||||
});
|
||||
@@ -66,10 +70,10 @@
|
||||
<div class="items-top mt-5 flex space-x-2">
|
||||
<Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} />
|
||||
<div class="grid gap-1.5 leading-none">
|
||||
<Label for="admin-privileges" class="text-sm font-medium leading-none mb-0">
|
||||
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
||||
Admin Privileges
|
||||
</Label>
|
||||
<p class="text-[0.8rem] text-muted-foreground">Admins have full access to the admin panel.</p>
|
||||
<p class="text-muted-foreground text-[0.8rem]">Admins have full access to the admin panel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
|
||||
@@ -10,10 +10,15 @@ test('Update general configuration', async ({ page }) => {
|
||||
await page.getByLabel('Session Duration').fill('30');
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
|
||||
await expect(page.getByTestId('application-name')).toHaveText('Updated Name');
|
||||
await expect(page.getByRole('status')).toHaveText(
|
||||
'Application configuration updated successfully'
|
||||
);
|
||||
await expect(page.getByTestId('application-name')).toHaveText('Updated Name');
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByLabel('Name')).toHaveValue('Updated Name');
|
||||
await expect(page.getByLabel('Session Duration')).toHaveValue('30');
|
||||
});
|
||||
|
||||
test('Update application images', async ({ page }) => {
|
||||
|
||||
@@ -23,17 +23,18 @@ export const users = {
|
||||
|
||||
export const oidcClients = {
|
||||
nextcloud: {
|
||||
id: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
|
||||
name: 'Nextcloud',
|
||||
callbackUrl: 'http://nextcloud/auth/callback'
|
||||
},
|
||||
immich: {
|
||||
id: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
name: 'Immich',
|
||||
callbackUrl: 'http://immich/auth/callback'
|
||||
},
|
||||
immich: {
|
||||
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
||||
name: 'Immich',
|
||||
callbackUrl: 'http://immich/auth/callback'
|
||||
},
|
||||
pingvinShare: {
|
||||
name: 'Pingvin Share',
|
||||
callbackUrl: 'http://pingvin.share/auth/callback'
|
||||
callbackUrl: 'http://pingvin.share/auth/callback',
|
||||
secondCallbackUrl: 'http://pingvin.share/auth/callback2'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,7 +10,11 @@ test('Create OIDC client', async ({ page }) => {
|
||||
|
||||
await page.getByRole('button', { name: 'Add OIDC Client' }).click();
|
||||
await page.getByLabel('Name').fill(oidcClient.name);
|
||||
await page.getByLabel('Callback URL').fill(oidcClient.callbackUrl);
|
||||
|
||||
await page.getByTestId('callback-url-1').fill(oidcClient.callbackUrl);
|
||||
await page.getByRole('button', { name: 'Add another' }).click();
|
||||
await page.getByTestId('callback-url-2').fill(oidcClient.secondCallbackUrl!);
|
||||
|
||||
await page.getByLabel('logo').setInputFiles('tests/assets/pingvin-share-logo.png');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
@@ -20,7 +24,8 @@ test('Create OIDC client', async ({ page }) => {
|
||||
expect(clientId?.length).toBe(36);
|
||||
expect((await page.getByTestId('client-secret').textContent())?.length).toBe(32);
|
||||
await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name);
|
||||
await expect(page.getByLabel('Callback URL')).toHaveValue(oidcClient.callbackUrl);
|
||||
await expect(page.getByTestId('callback-url-1')).toHaveValue(oidcClient.callbackUrl);
|
||||
await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl!);
|
||||
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toBeVisible();
|
||||
await page.request
|
||||
.get(`/api/oidc/clients/${clientId}/logo`)
|
||||
@@ -32,7 +37,7 @@ test('Edit OIDC client', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
||||
|
||||
await page.getByLabel('Name').fill('Nextcloud updated');
|
||||
await page.getByLabel('Callback URL').fill('http://nextcloud-updated/auth/callback');
|
||||
await page.getByTestId('callback-url-1').fill('http://nextcloud-updated/auth/callback');
|
||||
await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user