Compare commits

...

55 Commits
v2.0.0 ... main

Author SHA1 Message Date
Elias Schneider
adbdfcf9ff chore(translations): update translations via Crowdin (#1307) 2026-02-10 15:29:08 -06:00
Kyle Mendell
94a48977ba chore(deps): update dependenicies 2026-02-10 15:26:25 -06:00
dependabot[bot]
5ab0996475 chore(deps): bump axios from 1.13.2 to 1.13.5 in the npm_and_yarn group across 1 directory (#1309)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 15:25:21 -06:00
Kyle Mendell
60825c5743 chore: run formatter 2026-02-10 15:21:09 -06:00
Cheng Gu
310b81c277 feat: manageability of uncompressed geolite db file (#1234) 2026-02-10 21:17:06 +00:00
Elias Schneider
549b487663 chore(translations): update translations via Crowdin (#1271) 2026-02-04 02:21:13 -06:00
Yegor Pomortsev
6eebecd85a fix: decode URL-encoded client ID and secret in Basic auth (#1263) 2026-01-24 20:52:17 +00:00
Elias Schneider
1de231f1ff chore(translations): update translations via Crowdin (#1270) 2026-01-24 21:46:48 +01:00
Elias Schneider
aab7e364e8 fix: increase rate limit for frontend and api requests 2026-01-24 20:29:50 +01:00
Elias Schneider
56afebc242 feat: add support for HTTP/2 2026-01-24 18:24:34 +01:00
Elias Schneider
bb7b0d5608 fix: add type="url" to url inputs 2026-01-24 17:37:54 +01:00
Elias Schneider
80558c5625 chore(translations): add Norwegian language files 2026-01-24 17:33:58 +01:00
Moritz
a5629e63d2 fix: prevent deletion of OIDC provider logo for non admin/anonymous users (#1267) 2026-01-24 17:23:21 +01:00
github-actions[bot]
317879bb37 chore: update AAGUIDs (#1257)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2026-01-20 19:26:25 -06:00
Kyle Mendell
c62533d388 fix: ENCRYPTION_KEY needed for version and help commands (#1256) 2026-01-18 18:04:53 -06:00
Jasper Bernhardt
0978a89fcc feat: add VERSION_CHECK_DISABLED environment variable (#1254) 2026-01-18 17:28:24 -06:00
Kyle Mendell
53ef61a3e5 chore(translations): add Estonian files 2026-01-17 19:42:28 -06:00
Kyle Mendell
4811625cdd chore: upgrade deps 2026-01-15 18:15:41 -06:00
Kyle Mendell
9dbc02e568 chore(deps): bump devalue to 5.6.2 2026-01-15 18:14:00 -06:00
dependabot[bot]
43a1e4a25b chore(deps-dev): bump svelte from 5.46.1 to 5.46.4 in the npm_and_yarn group across 1 directory (#1242)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 20:32:39 +00:00
dependabot[bot]
e78b16d0c6 chore(deps-dev): bump @sveltejs/kit from 2.49.2 to 2.49.5 in the npm_and_yarn group across 1 directory (#1240)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 14:23:39 -06:00
Elias Schneider
1967de6828 chore(translations): update translations via Crowdin (#1233) 2026-01-14 01:17:08 -06:00
Elias Schneider
2c64bebf6a release: 2.2.0 2026-01-11 15:46:36 +01:00
Elias Schneider
2a11c3e609 fix: use user specific email verified claim instead of global one 2026-01-11 15:46:14 +01:00
Elias Schneider
a0ced2443c chore(translations): update translations via Crowdin (#1230) 2026-01-11 15:43:21 +01:00
Elias Schneider
746aa71d67 feat: add static api key env variable (#1229) 2026-01-11 15:36:27 +01:00
Elias Schneider
9ca3d33c88 feat: add environment variable to disable built-in rate limiting 2026-01-11 14:26:30 +01:00
Elias Schneider
4df4bcb645 fix: db version downgrades don't downgrade db schema 2026-01-11 14:14:44 +01:00
Elias Schneider
875c5b94a6 chore(translations): update translations via Crowdin (#1226) 2026-01-11 13:01:12 +01:00
Elias Schneider
0e2cdc393e fix: allow exchanging logic code if already authenticated 2026-01-11 12:59:31 +01:00
Elias Schneider
1e7442f5df feat: add support for email verification (#1223) 2026-01-11 12:31:26 +01:00
Elias Schneider
e955118a6f chore(translations): update translations via Crowdin (#1213) 2026-01-10 23:19:26 +01:00
Elias Schneider
811e8772b6 feat: add option to renew API key (#1214) 2026-01-09 12:08:58 +01:00
Elias Schneider
0a94f0fd64 feat: make home page URL configurable (#1215) 2026-01-07 22:01:51 +01:00
Elias Schneider
03f9be0d12 fix: login codes sent by an admin incorrectly requires a device token 2026-01-07 16:13:18 +01:00
Elias Schneider
2f25861d15 feat: improve passkey error messages 2026-01-07 11:30:37 +01:00
Elias Schneider
2af70d9b4d feat: add CLI command for encryption key rotation (#1209) 2026-01-07 09:34:23 +01:00
Elias Schneider
5828fa5779 fix: user can't update account if email is empty 2026-01-06 17:35:47 +01:00
Elias Schneider
1a032a812e fix: data import from sqlite to postgres fails because of wrong datatype 2026-01-06 16:08:49 +01:00
Elias Schneider
8c68b08c12 fix: allow changing "require email address" if no SMTP credentials present 2026-01-06 14:28:08 +01:00
Elias Schneider
646f849441 release: 2.1.0 2026-01-04 21:18:24 +01:00
Elias Schneider
20bbd4a06f chore(translations): update translations via Crowdin (#1189) 2026-01-04 21:17:49 +01:00
Justin Moy
2d7e2ec8df feat: process nonce within device authorization flow (#1185)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-01-04 18:18:17 +00:00
Kyle Mendell
72009ced67 feat: add issuer url to oidc client details list (#1197) 2026-01-04 19:04:16 +01:00
Elias Schneider
4881130ead refactor: run SCIM jobs in context of gocron instead of custom implementation 2026-01-04 19:00:18 +01:00
Elias Schneider
d6a7b503ff fix: invalid cookie name for email login code device token 2026-01-03 23:46:44 +01:00
Elias Schneider
3c3916536e release: 2.0.2 2026-01-03 15:16:46 +01:00
Elias Schneider
a24b2afb7b chore: add no-op migration to postgres 2026-01-03 15:12:14 +01:00
Elias Schneider
7c34501055 fix: localhost callback URLs with port don't match correctly 2026-01-03 15:07:56 +01:00
Elias Schneider
ba00f40bd4 fix: allow version downgrade database is dirty 2026-01-03 15:06:39 +01:00
Elias Schneider
2f651adf3b fix: migration fails if users exist with no email address 2026-01-03 15:06:34 +01:00
Elias Schneider
f42ba3bbef release: 2.0.1 2026-01-02 23:50:35 +01:00
Elias Schneider
2341da99e9 fix: restore old input input field size 2026-01-02 23:49:41 +01:00
Elias Schneider
2cce200892 fix: admins imported from LDAP lose admin privileges 2026-01-02 23:42:25 +01:00
Elias Schneider
cd2e9f3a2a chore(docker): bump image tag to v2 2026-01-02 19:21:58 +01:00
174 changed files with 4868 additions and 1429 deletions

View File

@@ -1 +1 @@
2.0.0
2.2.0

View File

@@ -1,3 +1,71 @@
## v2.2.0
### Bug Fixes
- allow changing "require email address" if no SMTP credentials present ([8c68b08](https://github.com/pocket-id/pocket-id/commit/8c68b08c12ba371deda61662e3d048d63d07c56f) by @stonith404)
- data import from sqlite to postgres fails because of wrong datatype ([1a032a8](https://github.com/pocket-id/pocket-id/commit/1a032a812ef78b250a898d14bec73a8ef7a7859a) by @stonith404)
- user can't update account if email is empty ([5828fa5](https://github.com/pocket-id/pocket-id/commit/5828fa57791314594625d52475733dce23cc2fcc) by @stonith404)
- login codes sent by an admin incorrectly requires a device token ([03f9be0](https://github.com/pocket-id/pocket-id/commit/03f9be0d125732e02a8e2c5390d9e6d0c74ce957) by @stonith404)
- allow exchanging logic code if already authenticated ([0e2cdc3](https://github.com/pocket-id/pocket-id/commit/0e2cdc393e34276bb3b8ea318cdc7261de3f2dec) by @stonith404)
- db version downgrades don't downgrade db schema ([4df4bcb](https://github.com/pocket-id/pocket-id/commit/4df4bcb6451b4bf88093e04f3222c8737f2c7be3) by @stonith404)
- use user specific email verified claim instead of global one ([2a11c3e](https://github.com/pocket-id/pocket-id/commit/2a11c3e60942d45c2e5b422d99945bce65a622a2) by @stonith404)
### Features
- add CLI command for encryption key rotation ([#1209](https://github.com/pocket-id/pocket-id/pull/1209) by @stonith404)
- improve passkey error messages ([2f25861](https://github.com/pocket-id/pocket-id/commit/2f25861d15aefa868042e70d3e21b7b38a6ae679) by @stonith404)
- make home page URL configurable ([#1215](https://github.com/pocket-id/pocket-id/pull/1215) by @stonith404)
- add option to renew API key ([#1214](https://github.com/pocket-id/pocket-id/pull/1214) by @stonith404)
- add support for email verification ([#1223](https://github.com/pocket-id/pocket-id/pull/1223) by @stonith404)
- add environment variable to disable built-in rate limiting ([9ca3d33](https://github.com/pocket-id/pocket-id/commit/9ca3d33c8897cf49a871783058205bb180529cd2) by @stonith404)
- add static api key env variable ([#1229](https://github.com/pocket-id/pocket-id/pull/1229) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.1.0...v2.2.0
## v2.1.0
### Bug Fixes
- invalid cookie name for email login code device token ([d6a7b50](https://github.com/pocket-id/pocket-id/commit/d6a7b503ff4571b1291a55a569add3374f5e2d5b) by @stonith404)
### Features
- add issuer url to oidc client details list ([#1197](https://github.com/pocket-id/pocket-id/pull/1197) by @kmendell)
- process nonce within device authorization flow ([#1185](https://github.com/pocket-id/pocket-id/pull/1185) by @justincmoy)
### Other
- run SCIM jobs in context of gocron instead of custom implementation ([4881130](https://github.com/pocket-id/pocket-id/commit/4881130eadcef0642f8a87650b7c36fda453b51b) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.0.2...v2.1.0
## v2.0.2
### Bug Fixes
- migration fails if users exist with no email address ([2f651ad](https://github.com/pocket-id/pocket-id/commit/2f651adf3b4e8d689461da2083c3afcb1eb1d477) by @stonith404)
- allow version downgrade database is dirty ([ba00f40](https://github.com/pocket-id/pocket-id/commit/ba00f40bd4b06f31d251599fcb1db63e902a6987) by @stonith404)
- localhost callback URLs with port don't match correctly ([7c34501](https://github.com/pocket-id/pocket-id/commit/7c345010556f11a593948b2a1ae558b7a8003696) by @stonith404)
### Other
- add no-op migration to postgres ([a24b2af](https://github.com/pocket-id/pocket-id/commit/a24b2afb7b8165bed05976058a8ae797adc245df) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.0.1...v2.0.2
## v2.0.1
### Bug Fixes
- admins imported from LDAP lose admin privileges ([2cce200](https://github.com/pocket-id/pocket-id/commit/2cce2008928081b5e0f0e6bcbc3f43816f082de9) by @stonith404)
- restore old input input field size ([2341da9](https://github.com/pocket-id/pocket-id/commit/2341da99e9716686cf28dd0680d751ae9da0fadc) by @stonith404)
### Other
- bump image tag to `v2` ([cd2e9f3](https://github.com/pocket-id/pocket-id/commit/cd2e9f3a2ad753815ef8da998f9b54853d953a2a) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.0.0...v2.0.1
## v2.0.0
### Bug Fixes

View File

@@ -4,6 +4,6 @@ package frontend
import "github.com/gin-gonic/gin"
func RegisterFrontend(router *gin.Engine) error {
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error {
return ErrFrontendNotIncluded
}

View File

@@ -52,7 +52,7 @@ func init() {
}
}
func RegisterFrontend(router *gin.Engine) error {
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
return fmt.Errorf("failed to create sub FS: %w", err)
@@ -61,7 +61,7 @@ func RegisterFrontend(router *gin.Engine) error {
cacheMaxAge := time.Hour * 24
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
router.NoRoute(func(c *gin.Context) {
handler := func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if strings.HasSuffix(path, "/") {
@@ -97,7 +97,9 @@ func RegisterFrontend(router *gin.Engine) error {
// Serve other static assets with caching
c.Request.URL.Path = "/" + path
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
router.NoRoute(rateLimitMiddleware, handler)
return nil
}

View File

@@ -48,8 +48,13 @@ func Bootstrap(ctx context.Context) error {
return fmt.Errorf("failed to initialize application images: %w", err)
}
scheduler, err := job.NewScheduler()
if err != nil {
return fmt.Errorf("failed to create job scheduler: %w", err)
}
// Create all services
svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage)
svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage, scheduler)
if err != nil {
return fmt.Errorf("failed to initialize services: %w", err)
}
@@ -74,11 +79,7 @@ func Bootstrap(ctx context.Context) error {
}
shutdownFns = append(shutdownFns, shutdownFn)
// Init the job scheduler
scheduler, err := job.NewScheduler()
if err != nil {
return fmt.Errorf("failed to create job scheduler: %w", err)
}
// Register scheduled jobs
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
if err != nil {
return fmt.Errorf("failed to register scheduled jobs: %w", err)

View File

@@ -15,6 +15,8 @@ import (
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"golang.org/x/time/rate"
"gorm.io/gorm"
@@ -51,8 +53,6 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
r.Use(otelgin.Middleware(common.Name))
}
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
// Setup global middleware
r.Use(middleware.HeadMiddleware())
r.Use(middleware.NewCacheControlMiddleware().Add())
@@ -60,7 +60,8 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
r.Use(middleware.NewCspMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
err := frontend.RegisterFrontend(r)
frontendRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(100*time.Millisecond), 300)
err := frontend.RegisterFrontend(r, frontendRateLimitMiddleware)
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil {
@@ -71,12 +72,14 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
apiRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 100)
// Set up API routes
apiGroup := r.Group("/api", rateLimitMiddleware)
apiGroup := r.Group("/api", apiRateLimitMiddleware)
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.appConfigService)
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.appConfigService)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
@@ -84,6 +87,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
// Add test controller in non-production environments
if !common.EnvConfig.AppEnv.IsProduction() {
@@ -93,18 +97,23 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
}
// Set up base routes
baseGroup := r.Group("/", rateLimitMiddleware)
baseGroup := r.Group("/", apiRateLimitMiddleware)
controller.NewWellKnownController(baseGroup, svc.jwtService)
// Set up healthcheck routes
// These are not rate-limited
controller.NewHealthzController(r)
var protocols http.Protocols
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)
// Set up the server
srv := &http.Server{
MaxHeaderBytes: 1 << 20,
ReadHeaderTimeout: 10 * time.Second,
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
Protocols: &protocols,
Handler: h2c.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// HEAD requests don't get matched by Gin routes, so we convert them to GET
// middleware.HeadMiddleware will convert them back to HEAD later
if req.Method == http.MethodHead {
@@ -114,7 +123,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
}
r.ServeHTTP(w, req)
}),
}), &http2.Server{}),
}
// Set up the listener

View File

@@ -35,6 +35,10 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, http
if err != nil {
return fmt.Errorf("failed to register analytics job in scheduler: %w", err)
}
err = scheduler.RegisterScimJobs(ctx, svc.scimService)
if err != nil {
return fmt.Errorf("failed to register SCIM scheduler job: %w", err)
}
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"github.com/pocket-id/pocket-id/backend/internal/job"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/service"
@@ -20,7 +21,6 @@ type services struct {
jwtService *service.JwtService
webauthnService *service.WebAuthnService
scimService *service.ScimService
scimSchedulerService *service.ScimSchedulerService
userService *service.UserService
customClaimService *service.CustomClaimService
oidcService *service.OidcService
@@ -30,10 +30,12 @@ type services struct {
versionService *service.VersionService
fileStorage storage.FileStorage
appLockService *service.AppLockService
userSignUpService *service.UserSignUpService
oneTimeAccessService *service.OneTimeAccessService
}
// Initializes all services
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage) (svc *services, err error) {
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage, scheduler *job.Scheduler) (svc *services, err error) {
svc = &services{}
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
@@ -52,7 +54,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.geoLiteService = service.NewGeoLiteService(httpClient)
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
svc.jwtService, err = service.NewJwtService(db, svc.appConfigService)
svc.jwtService, err = service.NewJwtService(ctx, db, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create JWT service: %w", err)
}
@@ -63,21 +65,25 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
}
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, httpClient, fileStorage)
svc.scimService = service.NewScimService(db, scheduler, httpClient)
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, svc.scimService, httpClient, fileStorage)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
}
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, fileStorage)
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.scimService = service.NewScimService(db, httpClient)
svc.scimSchedulerService, err = service.NewScimSchedulerService(ctx, svc.scimService)
svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService)
if err != nil {
return nil, fmt.Errorf("failed to create SCIM scheduler service: %w", err)
return nil, fmt.Errorf("failed to create API key service: %w", err)
}
svc.userSignUpService = service.NewUserSignupService(db, svc.jwtService, svc.auditLogService, svc.appConfigService, svc.userService)
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.versionService = service.NewVersionService(httpClient)
return svc, nil

View File

@@ -0,0 +1,187 @@
package cmds
import (
"context"
"errors"
"fmt"
"os"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/spf13/cobra"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
type encryptionKeyRotateFlags struct {
NewKey string
Yes bool
}
func init() {
var flags encryptionKeyRotateFlags
encryptionKeyRotateCmd := &cobra.Command{
Use: "encryption-key-rotate",
Short: "Re-encrypts data using a new encryption key",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := bootstrap.NewDatabase()
if err != nil {
return err
}
return encryptionKeyRotate(cmd.Context(), flags, db, &common.EnvConfig)
},
}
encryptionKeyRotateCmd.Flags().StringVar(&flags.NewKey, "new-key", "", "New encryption key to re-encrypt data with")
encryptionKeyRotateCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Do not prompt for confirmation")
rootCmd.AddCommand(encryptionKeyRotateCmd)
}
func encryptionKeyRotate(ctx context.Context, flags encryptionKeyRotateFlags, db *gorm.DB, envConfig *common.EnvConfigSchema) error {
oldKey := envConfig.EncryptionKey
newKey := []byte(flags.NewKey)
if len(newKey) == 0 {
return errors.New("new encryption key is required (--new-key)")
}
if len(newKey) < 16 {
return errors.New("new encryption key must be at least 16 bytes long")
}
if !flags.Yes {
fmt.Println("WARNING: Rotating the encryption key will re-encrypt secrets in the database. Pocket-ID must be restarted with the new ENCRYPTION_KEY after rotation is complete.")
ok, err := utils.PromptForConfirmation("Continue")
if err != nil {
return err
}
if !ok {
fmt.Println("Aborted")
os.Exit(1)
}
}
appConfigService, err := service.NewAppConfigService(ctx, db)
if err != nil {
return fmt.Errorf("failed to create app config service: %w", err)
}
instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Derive the encryption keys used for the JWK encryption
oldKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: oldKey}, instanceID)
if err != nil {
return fmt.Errorf("failed to derive old key encryption key: %w", err)
}
newKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: newKey}, instanceID)
if err != nil {
return fmt.Errorf("failed to derive new key encryption key: %w", err)
}
// Derive the encryption keys used for EncryptedString fields
oldEncKey, err := datatype.DeriveEncryptedStringKey(oldKey)
if err != nil {
return fmt.Errorf("failed to derive old encrypted string key: %w", err)
}
newEncKey, err := datatype.DeriveEncryptedStringKey(newKey)
if err != nil {
return fmt.Errorf("failed to derive new encrypted string key: %w", err)
}
err = db.Transaction(func(tx *gorm.DB) error {
err = rotateSigningKeyEncryption(ctx, tx, oldKek, newKek)
if err != nil {
return err
}
err = rotateScimTokens(tx, oldEncKey, newEncKey)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
fmt.Println("Encryption key rotation completed successfully.")
fmt.Println("Restart pocket-id with the new ENCRYPTION_KEY to use the rotated data.")
return nil
}
func rotateSigningKeyEncryption(ctx context.Context, db *gorm.DB, oldKek []byte, newKek []byte) error {
oldProvider := &jwkutils.KeyProviderDatabase{}
err := oldProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: oldKek,
})
if err != nil {
return fmt.Errorf("failed to init key provider with old encryption key: %w", err)
}
key, err := oldProvider.LoadKey(ctx)
if err != nil {
return fmt.Errorf("failed to load signing key using old encryption key: %w", err)
}
if key == nil {
return nil
}
newProvider := &jwkutils.KeyProviderDatabase{}
err = newProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: newKek,
})
if err != nil {
return fmt.Errorf("failed to init key provider with new encryption key: %w", err)
}
if err := newProvider.SaveKey(ctx, key); err != nil {
return fmt.Errorf("failed to store signing key with new encryption key: %w", err)
}
return nil
}
type scimTokenRow struct {
ID string
Token string
}
func rotateScimTokens(db *gorm.DB, oldEncKey []byte, newEncKey []byte) error {
var rows []scimTokenRow
err := db.Model(&model.ScimServiceProvider{}).Select("id, token").Scan(&rows).Error
if err != nil {
return fmt.Errorf("failed to list SCIM service providers: %w", err)
}
for _, row := range rows {
if row.Token == "" {
continue
}
decBytes, err := datatype.DecryptEncryptedStringWithKey(oldEncKey, row.Token)
if err != nil {
return fmt.Errorf("failed to decrypt SCIM token for provider %s: %w", row.ID, err)
}
encValue, err := datatype.EncryptEncryptedStringWithKey(newEncKey, decBytes)
if err != nil {
return fmt.Errorf("failed to encrypt SCIM token for provider %s: %w", row.ID, err)
}
err = db.Model(&model.ScimServiceProvider{}).Where("id = ?", row.ID).Update("token", encValue).Error
if err != nil {
return fmt.Errorf("failed to update SCIM token for provider %s: %w", row.ID, err)
}
}
return nil
}

View File

@@ -0,0 +1,89 @@
package cmds
import (
"testing"
"time"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/service"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestEncryptionKeyRotate(t *testing.T) {
oldKey := []byte("old-encryption-key-123456")
newKey := []byte("new-encryption-key-654321")
envConfig := &common.EnvConfigSchema{
EncryptionKey: oldKey,
}
db := testingutils.NewDatabaseForTest(t)
appConfigService, err := service.NewAppConfigService(t.Context(), db)
require.NoError(t, err)
instanceID := appConfigService.GetDbConfig().InstanceID.Value
oldKek, err := jwkutils.LoadKeyEncryptionKey(envConfig, instanceID)
require.NoError(t, err)
oldProvider := &jwkutils.KeyProviderDatabase{}
require.NoError(t, oldProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: oldKek,
}))
signingKey, err := jwkutils.GenerateKey("RS256", "")
require.NoError(t, err)
require.NoError(t, oldProvider.SaveKey(t.Context(), signingKey))
oldEncKey, err := datatype.DeriveEncryptedStringKey(oldKey)
require.NoError(t, err)
encToken, err := datatype.EncryptEncryptedStringWithKey(oldEncKey, []byte("scim-token-123"))
require.NoError(t, err)
err = db.Exec(
`INSERT INTO scim_service_providers (id, created_at, endpoint, token, oidc_client_id) VALUES (?, ?, ?, ?, ?)`,
"scim-1",
time.Now(),
"https://example.com/scim",
encToken,
"client-1",
).Error
require.NoError(t, err)
flags := encryptionKeyRotateFlags{
NewKey: string(newKey),
Yes: true,
}
require.NoError(t, encryptionKeyRotate(t.Context(), flags, db, envConfig))
newKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: newKey}, instanceID)
require.NoError(t, err)
newProvider := &jwkutils.KeyProviderDatabase{}
require.NoError(t, newProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: newKek,
}))
rotatedKey, err := newProvider.LoadKey(t.Context())
require.NoError(t, err)
require.NotNil(t, rotatedKey)
var storedToken string
err = db.Model(&model.ScimServiceProvider{}).Where("id = ?", "scim-1").Pluck("token", &storedToken).Error
require.NoError(t, err)
newEncKey, err := datatype.DeriveEncryptedStringKey(newKey)
require.NoError(t, err)
decBytes, err := datatype.DecryptEncryptedStringWithKey(newEncKey, storedToken)
require.NoError(t, err)
assert.Equal(t, "scim-token-123", string(decBytes))
}

View File

@@ -102,7 +102,7 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
}
// Save the key
err = keyProvider.SaveKey(key)
err = keyProvider.SaveKey(ctx, key)
if err != nil {
return fmt.Errorf("failed to store new key: %w", err)
}

View File

@@ -104,7 +104,7 @@ func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantEr
require.NoError(t, err)
// Verify key was created
key, err := keyProvider.LoadKey()
key, err := keyProvider.LoadKey(t.Context())
require.NoError(t, err)
require.NotNil(t, key)

View File

@@ -49,6 +49,9 @@ type EnvConfigSchema struct {
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
VersionCheckDisabled bool `env:"VERSION_CHECK_DISABLED"`
StaticApiKey string `env:"STATIC_API_KEY" options:"file"`
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
UploadPath string `env:"UPLOAD_PATH"`
@@ -126,6 +129,10 @@ func parseEnvConfig() error {
// ValidateEnvConfig checks the EnvConfig for required fields and valid values
func ValidateEnvConfig(config *EnvConfigSchema) error {
if shouldSkipEnvValidation(os.Args) {
return nil
}
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
}
@@ -199,10 +206,25 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
}
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
return errors.New("STATIC_API_KEY must be at least 16 characters long")
}
return nil
}
func shouldSkipEnvValidation(args []string) bool {
for _, arg := range args[1:] {
switch arg {
case "-h", "--help", "help", "version":
return true
}
}
return false
}
// prepareEnvConfig processes special options for EnvConfig fields
func prepareEnvConfig(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem()

View File

@@ -266,6 +266,13 @@ func (e *APIKeyNotFoundError) Error() string {
}
func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
type APIKeyNotExpiredError struct{}
func (e *APIKeyNotExpiredError) Error() string {
return "API Key is not expired yet"
}
func (e *APIKeyNotExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
type APIKeyExpirationDateError struct{}
func (e *APIKeyExpirationDateError) Error() string {
@@ -405,3 +412,13 @@ func (e *ImageNotFoundError) Error() string {
func (e *ImageNotFoundError) HttpStatusCode() int {
return http.StatusNotFound
}
type InvalidEmailVerificationTokenError struct{}
func (e *InvalidEmailVerificationTokenError) Error() string {
return "Invalid email verification token"
}
func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int {
return http.StatusBadRequest
}

View File

@@ -30,6 +30,7 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
{
apiKeyGroup.GET("", uc.listApiKeysHandler)
apiKeyGroup.POST("", uc.createApiKeyHandler)
apiKeyGroup.POST("/:id/renew", uc.renewApiKeyHandler)
apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
}
}
@@ -101,6 +102,41 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
})
}
// renewApiKeyHandler godoc
// @Summary Renew API key
// @Description Renew an existing API key by ID
// @Tags API Keys
// @Param id path string true "API Key ID"
// @Success 200 {object} dto.ApiKeyResponseDto "Renewed API key with new token"
// @Router /api/api-keys/{id}/renew [post]
func (c *ApiKeyController) renewApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID")
apiKeyID := ctx.Param("id")
var input dto.ApiKeyRenewDto
if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
_ = ctx.Error(err)
return
}
apiKey, token, err := c.apiKeyService.RenewApiKey(ctx.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime())
if err != nil {
_ = ctx.Error(err)
return
}
var apiKeyDto dto.ApiKeyDto
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusOK, dto.ApiKeyResponseDto{
ApiKey: apiKeyDto,
Token: token,
})
}
// revokeApiKeyHandler godoc
// @Summary Revoke API key
// @Description Revoke (delete) an existing API key by ID

View File

@@ -47,7 +47,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
@@ -164,7 +164,7 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
// Client id and secret can also be passed over the Authorization header
if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request)
}
tokens, err := oc.oidcService.CreateTokens(c.Request.Context(), input)
@@ -322,7 +322,7 @@ func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
creds service.ClientAuthCredentials
ok bool
)
creds.ClientID, creds.ClientSecret, ok = c.Request.BasicAuth()
creds.ClientID, creds.ClientSecret, ok = utils.OAuthClientBasicAuth(c.Request)
if !ok {
// If there's no basic auth, check if we have a bearer token
bearer, ok := utils.BearerAuth(c.Request)
@@ -659,7 +659,7 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
// Client id and secret can also be passed over the Authorization header
if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request)
}
response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input)

View File

@@ -14,19 +14,17 @@ import (
"golang.org/x/time/rate"
)
const (
defaultOneTimeAccessTokenDuration = 15 * time.Minute
defaultSignupTokenDuration = time.Hour
)
const defaultOneTimeAccessTokenDuration = 15 * time.Minute
// NewUserController creates a new controller for user management endpoints
// @Summary User management controller
// @Description Initializes all user-related API endpoints
// @Tags Users
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, appConfigService *service.AppConfigService) {
uc := UserController{
userService: userService,
appConfigService: appConfigService,
userService: userService,
oneTimeAccessService: oneTimeAccessService,
appConfigService: appConfigService,
}
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
@@ -54,17 +52,14 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler)
group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler)
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), uc.deleteSignupTokenHandler)
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), uc.signupHandler)
group.POST("/signup/setup", uc.signUpInitialAdmin)
group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler)
group.POST("/users/me/verify-email", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler)
}
type UserController struct {
userService *service.UserService
appConfigService *service.AppConfigService
userService *service.UserService
oneTimeAccessService *service.OneTimeAccessService
appConfigService *service.AppConfigService
}
// getUserGroupsHandler godoc
@@ -342,7 +337,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
ttl = defaultOneTimeAccessTokenDuration
}
}
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
if err != nil {
_ = c.Error(err)
return
@@ -391,7 +386,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(
return
}
deviceToken, err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
if err != nil {
_ = c.Error(err)
return
@@ -424,7 +419,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
if err != nil {
_ = c.Error(err)
return
@@ -442,41 +437,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
// @Router /api/one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
// signUpInitialAdmin godoc
// @Summary Sign up initial admin user
// @Description Sign up and generate setup access token for initial admin user
// @Tags Users
// @Accept json
// @Produce json
// @Param body body dto.SignUpDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/signup/setup [post]
func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
user, token, err := uc.userService.SignUpInitialAdmin(c.Request.Context(), input)
user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
@@ -524,130 +485,6 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
c.JSON(http.StatusOK, userDto)
}
// createSignupTokenHandler godoc
// @Summary Create signup token
// @Description Create a new signup token that allows user registration
// @Tags Users
// @Accept json
// @Produce json
// @Param token body dto.SignupTokenCreateDto true "Signup token information"
// @Success 201 {object} dto.SignupTokenDto
// @Router /api/signup-tokens [post]
func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
var input dto.SignupTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
ttl := input.TTL.Duration
if ttl <= 0 {
ttl = defaultSignupTokenDuration
}
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
if err != nil {
_ = c.Error(err)
return
}
var tokenDto dto.SignupTokenDto
err = dto.MapStruct(signupToken, &tokenDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, tokenDto)
}
// listSignupTokensHandler godoc
// @Summary List signup tokens
// @Description Get a paginated list of signup tokens
// @Tags Users
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
// @Router /api/signup-tokens [get]
func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return
}
var tokensDto []dto.SignupTokenDto
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{
Data: tokensDto,
Pagination: pagination,
})
}
// deleteSignupTokenHandler godoc
// @Summary Delete signup token
// @Description Delete a signup token by ID
// @Tags Users
// @Param id path string true "Token ID"
// @Success 204 "No Content"
// @Router /api/signup-tokens/{id} [delete]
func (uc *UserController) deleteSignupTokenHandler(c *gin.Context) {
tokenID := c.Param("id")
err := uc.userService.DeleteSignupToken(c.Request.Context(), tokenID)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// signupWithTokenHandler godoc
// @Summary Sign up
// @Description Create a new user account
// @Tags Users
// @Accept json
// @Produce json
// @Param user body dto.SignUpDto true "User information"
// @Success 201 {object} dto.SignUpDto
// @Router /api/signup [post]
func (uc *UserController) signupHandler(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
user, accessToken, err := uc.userService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
if err != nil {
_ = c.Error(err)
return
}
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, userDto)
}
// updateUser is an internal helper method, not exposed as an API endpoint
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto
@@ -714,3 +551,44 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context)
c.Status(http.StatusNoContent)
}
// sendEmailVerificationHandler godoc
// @Summary Send email verification
// @Description Send an email verification to the currently authenticated user
// @Tags Users
// @Produce json
// @Success 204 "No Content"
// @Router /api/users/me/send-email-verification [post]
func (uc *UserController) sendEmailVerificationHandler(c *gin.Context) {
userID := c.GetString("userID")
if err := uc.userService.SendEmailVerification(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// verifyEmailHandler godoc
// @Summary Verify email
// @Description Verify the currently authenticated user's email using a verification token
// @Tags Users
// @Param body body dto.EmailVerificationDto true "Email verification token"
// @Success 204 "No Content"
// @Router /api/users/me/verify-email [post]
func (uc *UserController) verifyEmailHandler(c *gin.Context) {
var input dto.EmailVerificationDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
userID := c.GetString("userID")
if err := uc.userService.VerifyEmail(c.Request.Context(), userID, input.Token); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -0,0 +1,198 @@
package controller
import (
"net/http"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"golang.org/x/time/rate"
)
const defaultSignupTokenDuration = time.Hour
// NewUserSignupController creates a new controller for user signup and signup token management
// @Summary User signup and signup token management controller
// @Description Initializes all user signup-related API endpoints
// @Tags Users
func NewUserSignupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userSignUpService *service.UserSignUpService, appConfigService *service.AppConfigService) {
usc := UserSignupController{
userSignUpService: userSignUpService,
appConfigService: appConfigService,
}
group.POST("/signup-tokens", authMiddleware.Add(), usc.createSignupTokenHandler)
group.GET("/signup-tokens", authMiddleware.Add(), usc.listSignupTokensHandler)
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), usc.deleteSignupTokenHandler)
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), usc.signupHandler)
group.POST("/signup/setup", usc.signUpInitialAdmin)
}
type UserSignupController struct {
userSignUpService *service.UserSignUpService
appConfigService *service.AppConfigService
}
// signUpInitialAdmin godoc
// @Summary Sign up initial admin user
// @Description Sign up and generate setup access token for initial admin user
// @Tags Users
// @Accept json
// @Produce json
// @Param body body dto.SignUpDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/signup/setup [post]
func (usc *UserSignupController) signUpInitialAdmin(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
user, token, err := usc.userSignUpService.SignUpInitialAdmin(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(usc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
// createSignupTokenHandler godoc
// @Summary Create signup token
// @Description Create a new signup token that allows user registration
// @Tags Users
// @Accept json
// @Produce json
// @Param token body dto.SignupTokenCreateDto true "Signup token information"
// @Success 201 {object} dto.SignupTokenDto
// @Router /api/signup-tokens [post]
func (usc *UserSignupController) createSignupTokenHandler(c *gin.Context) {
var input dto.SignupTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
ttl := input.TTL.Duration
if ttl <= 0 {
ttl = defaultSignupTokenDuration
}
signupToken, err := usc.userSignUpService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
if err != nil {
_ = c.Error(err)
return
}
var tokenDto dto.SignupTokenDto
err = dto.MapStruct(signupToken, &tokenDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, tokenDto)
}
// listSignupTokensHandler godoc
// @Summary List signup tokens
// @Description Get a paginated list of signup tokens
// @Tags Users
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
// @Router /api/signup-tokens [get]
func (usc *UserSignupController) listSignupTokensHandler(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := usc.userSignUpService.ListSignupTokens(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return
}
var tokensDto []dto.SignupTokenDto
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{
Data: tokensDto,
Pagination: pagination,
})
}
// deleteSignupTokenHandler godoc
// @Summary Delete signup token
// @Description Delete a signup token by ID
// @Tags Users
// @Param id path string true "Token ID"
// @Success 204 "No Content"
// @Router /api/signup-tokens/{id} [delete]
func (usc *UserSignupController) deleteSignupTokenHandler(c *gin.Context) {
tokenID := c.Param("id")
err := usc.userSignUpService.DeleteSignupToken(c.Request.Context(), tokenID)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// signupWithTokenHandler godoc
// @Summary Sign up
// @Description Create a new user account
// @Tags Users
// @Accept json
// @Produce json
// @Param user body dto.SignUpDto true "User information"
// @Success 201 {object} dto.SignUpDto
// @Router /api/signup [post]
func (usc *UserSignupController) signupHandler(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
user, accessToken, err := usc.userSignUpService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
if err != nil {
_ = c.Error(err)
return
}
maxAge := int(usc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, userDto)
}

View File

@@ -10,6 +10,10 @@ type ApiKeyCreateDto struct {
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type ApiKeyRenewDto struct {
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type ApiKeyDto struct {
ID string `json:"id"`
Name string `json:"name"`

View File

@@ -14,6 +14,7 @@ type AppConfigVariableDto struct {
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
SessionDuration string `json:"sessionDuration" binding:"required"`
HomePageURL string `json:"homePageUrl" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"`
DisableAnimations string `json:"disableAnimations" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
@@ -53,4 +54,5 @@ type AppConfigUpdateDto struct {
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"`
}

View File

@@ -139,6 +139,7 @@ type OidcDeviceAuthorizationRequestDto struct {
ClientSecret string `form:"client_secret"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
Nonce string `form:"nonce"`
}
type OidcDeviceAuthorizationResponseDto struct {

View File

@@ -0,0 +1,17 @@
package dto
import "github.com/pocket-id/pocket-id/backend/internal/utils"
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email" unorm:"nfc"`
RedirectPath string `json:"redirectPath"`
}
type OneTimeAccessEmailAsAdminDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}

View File

@@ -0,0 +1,9 @@
package dto
type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
}

View File

@@ -4,35 +4,36 @@ import (
"errors"
"github.com/gin-gonic/gin/binding"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type UserDto struct {
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email" `
FirstName string `json:"firstName"`
LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupMinimalDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName"`
LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupMinimalDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
UserGroupIds []string `json:"userGroupIds"`
LdapID string `json:"-"`
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
UserGroupIds []string `json:"userGroupIds"`
LdapID string `json:"-"`
}
func (u UserCreateDto) Validate() error {
@@ -46,28 +47,10 @@ func (u UserCreateDto) Validate() error {
return e.Struct(u)
}
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email" unorm:"nfc"`
RedirectPath string `json:"redirectPath"`
}
type OneTimeAccessEmailAsAdminDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
type EmailVerificationDto struct {
Token string `json:"token" binding:"required"`
}
type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"`
}
type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
}

View File

@@ -28,7 +28,7 @@ func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service
appConfig: appConfig,
httpClient: httpClient,
}
return s.registerJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
}
type AnalyticsJob struct {

View File

@@ -22,7 +22,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
}
// Send every day at midnight
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
}
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {

View File

@@ -21,13 +21,14 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
return errors.Join(
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
s.RegisterJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
s.RegisterJob(ctx, "ClearEmailVerificationTokens", def, jobs.clearEmailVerificationTokens, true),
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
s.RegisterJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
)
}
@@ -135,3 +136,16 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return nil
}
// ClearEmailVerificationTokens deletes email verification tokens that have expired
func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.EmailVerificationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired email verification tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired email verification tokens", slog.Int64("count", st.RowsAffected))
return nil
}

View File

@@ -19,11 +19,11 @@ import (
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage}
err := s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
err := s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
// Only necessary for file system storage
if fileStorage.Type() == storage.TypeFileSystem {
err = errors.Join(err, s.registerJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, true))
err = errors.Join(err, s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, true))
}
return err

View File

@@ -23,7 +23,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Run every 24 hours (and right away)
return s.registerJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
}
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {

View File

@@ -18,7 +18,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour
return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
return s.RegisterJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
}
func (j *LdapJobs) syncLdap(ctx context.Context) error {

View File

@@ -2,6 +2,7 @@ package job
import (
"context"
"errors"
"fmt"
"log/slog"
@@ -24,6 +25,26 @@ func NewScheduler() (*Scheduler, error) {
}, nil
}
func (s *Scheduler) RemoveJob(name string) error {
jobs := s.scheduler.Jobs()
var errs []error
for _, job := range jobs {
if job.Name() == name {
err := s.scheduler.RemoveJob(job.ID())
if err != nil {
errs = append(errs, fmt.Errorf("failed to unqueue job %q with ID %q: %w", name, job.ID().String(), err))
}
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
// Run the scheduler.
// This function blocks until the context is canceled.
func (s *Scheduler) Run(ctx context.Context) error {
@@ -43,9 +64,10 @@ func (s *Scheduler) Run(ctx context.Context) error {
return nil
}
func (s *Scheduler) registerJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool) error {
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error {
jobOptions := []gocron.JobOption{
gocron.WithContext(ctx),
gocron.WithName(name),
gocron.WithEventListeners(
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
slog.Info("Starting job",
@@ -73,6 +95,8 @@ func (s *Scheduler) registerJob(ctx context.Context, name string, def gocron.Job
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
}
jobOptions = append(jobOptions, extraOptions...)
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
if err != nil {

View File

@@ -0,0 +1,25 @@
package job
import (
"context"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
type ScimJobs struct {
scimService *service.ScimService
}
func (s *Scheduler) RegisterScimJobs(ctx context.Context, scimService *service.ScimService) error {
jobs := &ScimJobs{scimService: scimService}
// Register the job to run every hour
return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, true)
}
func (j *ScimJobs) SyncScim(ctx context.Context) error {
return j.scimService.SyncAll(ctx)
}

View File

@@ -34,7 +34,7 @@ func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
}
func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
apiKey := c.GetHeader("X-API-KEY")
apiKey := c.GetHeader("X-API-Key")
user, err := m.apiKeyService.ValidateApiKey(c.Request.Context(), apiKey)
if err != nil {

View File

@@ -17,6 +17,12 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
}
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
if common.EnvConfig.DisableRateLimiting {
return func(c *gin.Context) {
c.Next()
}
}
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex

View File

@@ -36,6 +36,7 @@ type AppConfig struct {
// General
AppName AppConfigVariable `key:"appName,public"` // Public
SessionDuration AppConfigVariable `key:"sessionDuration"`
HomePageURL AppConfigVariable `key:"homePageUrl,public"` // Public
EmailsVerified AppConfigVariable `key:"emailsVerified"`
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
@@ -58,6 +59,7 @@ type AppConfig struct {
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
EmailVerificationEnabled AppConfigVariable `key:"emailVerificationEnabled,public"` // Public
// LDAP
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
LdapUrl AppConfigVariable `key:"ldapUrl"`

View File

@@ -0,0 +1,13 @@
package model
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type EmailVerificationToken struct {
Base
Token string
ExpiresAt datatype.DateTime
UserID string
User User
}

View File

@@ -144,6 +144,7 @@ type OidcDeviceCode struct {
DeviceCode string
UserCode string
Scope string
Nonce string
ExpiresAt datatype.DateTime
IsAuthorized bool

View File

@@ -0,0 +1,13 @@
package model
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type OneTimeAccessToken struct {
Base
Token string
DeviceToken *string
ExpiresAt datatype.DateTime
UserID string
User User
}

View File

@@ -40,14 +40,9 @@ func (e *EncryptedString) Scan(value any) error {
return nil
}
encBytes, err := base64.StdEncoding.DecodeString(raw)
decBytes, err := DecryptEncryptedStringWithKey(encStringKey, raw)
if err != nil {
return fmt.Errorf("failed to decode encrypted string: %w", err)
}
decBytes, err := cryptoutils.Decrypt(encStringKey, encBytes, []byte(encryptedStringAAD))
if err != nil {
return fmt.Errorf("failed to decrypt encrypted string: %w", err)
return err
}
*e = EncryptedString(decBytes)
@@ -59,19 +54,20 @@ func (e EncryptedString) Value() (driver.Value, error) {
return "", nil
}
encBytes, err := cryptoutils.Encrypt(encStringKey, []byte(e), []byte(encryptedStringAAD))
encValue, err := EncryptEncryptedStringWithKey(encStringKey, []byte(e))
if err != nil {
return nil, fmt.Errorf("failed to encrypt string: %w", err)
return nil, err
}
return base64.StdEncoding.EncodeToString(encBytes), nil
return encValue, nil
}
func (e EncryptedString) String() string {
return string(e)
}
func deriveEncryptedStringKey(master []byte) ([]byte, error) {
// DeriveEncryptedStringKey derives a key for encrypting EncryptedString values from the master key.
func DeriveEncryptedStringKey(master []byte) ([]byte, error) {
const info = "pocketid/encrypted_string"
r := hkdf.New(sha256.New, master, nil, []byte(info))
@@ -82,8 +78,33 @@ func deriveEncryptedStringKey(master []byte) ([]byte, error) {
return key, nil
}
// DecryptEncryptedStringWithKey decrypts an EncryptedString value using the derived key.
func DecryptEncryptedStringWithKey(key []byte, encoded string) ([]byte, error) {
encBytes, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("failed to decode encrypted string: %w", err)
}
decBytes, err := cryptoutils.Decrypt(key, encBytes, []byte(encryptedStringAAD))
if err != nil {
return nil, fmt.Errorf("failed to decrypt encrypted string: %w", err)
}
return decBytes, nil
}
// EncryptEncryptedStringWithKey encrypts an EncryptedString value using the derived key.
func EncryptEncryptedStringWithKey(key []byte, plaintext []byte) (string, error) {
encBytes, err := cryptoutils.Encrypt(key, plaintext, []byte(encryptedStringAAD))
if err != nil {
return "", fmt.Errorf("failed to encrypt string: %w", err)
}
return base64.StdEncoding.EncodeToString(encBytes), nil
}
func init() {
key, err := deriveEncryptedStringKey(common.EnvConfig.EncryptionKey)
key, err := DeriveEncryptedStringKey(common.EnvConfig.EncryptionKey)
if err != nil {
panic(fmt.Sprintf("failed to derive encrypted string key: %v", err))
}

View File

@@ -14,16 +14,17 @@ import (
type User struct {
Base
Username string `sortable:"true"`
Email *string `sortable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
DisplayName string `sortable:"true"`
IsAdmin bool `sortable:"true" filterable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true" filterable:"true"`
UpdatedAt *datatype.DateTime
Username string `sortable:"true"`
Email *string `sortable:"true"`
EmailVerified bool `sortable:"true" filterable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
DisplayName string `sortable:"true"`
IsAdmin bool `sortable:"true" filterable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true" filterable:"true"`
UpdatedAt *datatype.DateTime
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -93,13 +94,3 @@ func (u User) LastModified() time.Time {
}
return u.CreatedAt.ToTime()
}
type OneTimeAccessToken struct {
Base
Token string
DeviceToken *string
ExpiresAt datatype.DateTime
UserID string
User User
}

View File

@@ -16,13 +16,25 @@ import (
"gorm.io/gorm/clause"
)
const staticApiKeyUserID = "00000000-0000-0000-0000-000000000000"
type ApiKeyService struct {
db *gorm.DB
emailService *EmailService
}
func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService {
return &ApiKeyService{db: db, emailService: emailService}
func NewApiKeyService(ctx context.Context, db *gorm.DB, emailService *EmailService) (*ApiKeyService, error) {
s := &ApiKeyService{db: db, emailService: emailService}
if common.EnvConfig.StaticApiKey == "" {
err := s.deleteStaticApiKeyUser(ctx)
if err != nil {
return nil, err
}
}
return s, nil
}
func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.ApiKey, utils.PaginationResponse, error) {
@@ -72,6 +84,56 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
return apiKey, token, nil
}
func (s *ApiKeyService) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (model.ApiKey, string, error) {
// Check if expiration is in the future
if !expiration.After(time.Now()) {
return model.ApiKey{}, "", &common.APIKeyExpirationDateError{}
}
tx := s.db.Begin()
defer tx.Rollback()
var apiKey model.ApiKey
err := tx.
WithContext(ctx).
Model(&model.ApiKey{}).
Where("id = ? AND user_id = ?", apiKeyID, userID).
First(&apiKey).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.ApiKey{}, "", &common.APIKeyNotFoundError{}
}
return model.ApiKey{}, "", err
}
// Only allow renewal if the key has already expired
if apiKey.ExpiresAt.ToTime().After(time.Now()) {
return model.ApiKey{}, "", &common.APIKeyNotExpiredError{}
}
// Generate a secure random API key
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return model.ApiKey{}, "", err
}
apiKey.Key = utils.CreateSha256Hash(token)
apiKey.ExpiresAt = datatype.DateTime(expiration)
err = tx.WithContext(ctx).Save(&apiKey).Error
if err != nil {
return model.ApiKey{}, "", err
}
if err := tx.Commit().Error; err != nil {
return model.ApiKey{}, "", err
}
return apiKey, token, nil
}
func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
var apiKey model.ApiKey
err := s.db.
@@ -94,6 +156,10 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode
return model.User{}, &common.NoAPIKeyProvidedError{}
}
if common.EnvConfig.StaticApiKey != "" && apiKey == common.EnvConfig.StaticApiKey {
return s.initStaticApiKeyUser(ctx)
}
now := time.Now()
hashedKey := utils.CreateSha256Hash(apiKey)
@@ -167,3 +233,47 @@ func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey
Update("expiration_email_sent", true).
Error
}
func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {
err = s.db.
WithContext(ctx).
First(&user, "id = ?", staticApiKeyUserID).
Error
if err == nil {
return user, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, err
}
usernameSuffix, err := utils.GenerateRandomAlphanumericString(6)
if err != nil {
return model.User{}, err
}
user = model.User{
Base: model.Base{
ID: staticApiKeyUserID,
},
FirstName: "Static API User",
Username: "static-api-user-" + usernameSuffix,
DisplayName: "Static API User",
IsAdmin: true,
}
err = s.db.
WithContext(ctx).
Create(&user).
Error
return user, err
}
func (s *ApiKeyService) deleteStaticApiKeyUser(ctx context.Context) error {
return s.db.
WithContext(ctx).
Delete(&model.User{}, "id = ?", staticApiKeyUserID).
Error
}

View File

@@ -61,6 +61,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
// General
AppName: model.AppConfigVariable{Value: "Pocket ID"},
SessionDuration: model.AppConfigVariable{Value: "60"},
HomePageURL: model.AppConfigVariable{Value: "/settings/account"},
EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
@@ -83,6 +84,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
EmailVerificationEnabled: model.AppConfigVariable{Value: "false"},
// LDAP
LdapEnabled: model.AppConfigVariable{Value: "false"},
LdapUrl: model.AppConfigVariable{},

View File

@@ -80,23 +80,25 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
},
Username: "tim",
Email: utils.Ptr("tim.cook@test.com"),
FirstName: "Tim",
LastName: "Cook",
DisplayName: "Tim Cook",
IsAdmin: true,
Username: "tim",
Email: utils.Ptr("tim.cook@test.com"),
EmailVerified: true,
FirstName: "Tim",
LastName: "Cook",
DisplayName: "Tim Cook",
IsAdmin: true,
},
{
Base: model.Base{
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
},
Username: "craig",
Email: utils.Ptr("craig.federighi@test.com"),
FirstName: "Craig",
LastName: "Federighi",
DisplayName: "Craig Federighi",
IsAdmin: false,
Username: "craig",
Email: utils.Ptr("craig.federighi@test.com"),
EmailVerified: false,
FirstName: "Craig",
LastName: "Federighi",
DisplayName: "Craig Federighi",
IsAdmin: false,
},
{
Base: model.Base{
@@ -354,17 +356,30 @@ func (s *TestService) SeedDatabase(baseURL string) error {
return err
}
apiKey := model.ApiKey{
Base: model.Base{
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
apiKeys := []model.ApiKey{
{
Base: model.Base{
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
},
Name: "Test API Key",
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
UserID: users[0].ID,
ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
},
{
Base: model.Base{
ID: "98900330-7a7b-48fe-881b-2cc6ad049976",
},
Name: "Expired API Key",
Key: "141ff8ac9db640ba93630099de83d0ead8e7ac673e3a7d31b4fd7ff2252e6389",
UserID: users[0].ID,
ExpiresAt: datatype.DateTime(time.Now().Add(-20 * 24 * time.Hour)),
},
Name: "Test API Key",
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
UserID: users[0].ID,
ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
}
if err := tx.Create(&apiKey).Error; err != nil {
return err
for _, apiKey := range apiKeys {
if err := tx.Create(&apiKey).Error; err != nil {
return err
}
}
signupTokens := []model.SignupToken{
@@ -414,6 +429,31 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
emailVerificationTokens := []model.EmailVerificationToken{
{
Base: model.Base{
ID: "ef9ca469-b178-4857-bd39-26639dca45de",
},
Token: "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup",
ExpiresAt: datatype.DateTime(time.Now().Add(2 * time.Hour)),
UserID: users[1].ID,
},
{
Base: model.Base{
ID: "a3dcb4d2-7f3c-4e8a-9f4d-5b6c7d8e9f00",
},
Token: "EXPIRED1234567890ABCDE",
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Hour)),
UserID: users[1].ID,
},
}
for _, token := range emailVerificationTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
keyValues := []model.KV{
{
Key: jwkutils.PrivateKeyDBKey,
@@ -526,7 +566,7 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
}
// Reload the JWK
if err := s.jwtService.LoadOrGenerateKey(); err != nil {
if err := s.jwtService.LoadOrGenerateKey(ctx); err != nil {
return err
}

View File

@@ -49,6 +49,13 @@ var ApiKeyExpiringSoonTemplate = email.Template[ApiKeyExpiringSoonTemplateData]{
},
}
var EmailVerificationTemplate = email.Template[EmailVerificationTemplateData]{
Path: "email-verification",
Title: func(data *email.TemplateData[EmailVerificationTemplateData]) string {
return "Verify your " + data.AppName + " email address"
},
}
type NewLoginTemplateData struct {
IPAddress string
Country string
@@ -70,5 +77,10 @@ type ApiKeyExpiringSoonTemplateData struct {
ExpiresAt time.Time
}
type EmailVerificationTemplateData struct {
UserFullName string
VerificationLink string
}
// this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path}
var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path, EmailVerificationTemplate.Path}

View File

@@ -2,6 +2,7 @@ package service
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"errors"
@@ -22,6 +23,8 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
)
const maxTotalSize = 300 * 1024 * 1024 // 300 MB limit for total decompressed size
type GeoLiteService struct {
httpClient *http.Client
disableUpdater bool
@@ -151,7 +154,22 @@ func (s *GeoLiteService) isDatabaseUpToDate() bool {
// extractDatabase extracts the database file from the tar.gz archive directly to the target location.
func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
gzr, err := gzip.NewReader(reader)
// Check for gzip magic number
buf := make([]byte, 2)
_, err := io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("failed to read magic number: %w", err)
}
// Check if the file starts with the gzip magic number
isGzip := buf[0] == 0x1f && buf[1] == 0x8b
if !isGzip {
// If not gzip, assume it's a regular database file
return s.writeDatabaseFile(io.MultiReader(bytes.NewReader(buf), reader))
}
gzr, err := gzip.NewReader(io.MultiReader(bytes.NewReader(buf), reader))
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
@@ -160,7 +178,6 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
tarReader := tar.NewReader(gzr)
var totalSize int64
const maxTotalSize = 300 * 1024 * 1024 // 300 MB limit for total decompressed size
// Iterate over the files in the tar archive
for {
@@ -222,3 +239,47 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
return errors.New("GeoLite2-City.mmdb not found in archive")
}
func (s *GeoLiteService) writeDatabaseFile(reader io.Reader) error {
baseDir := filepath.Dir(common.EnvConfig.GeoLiteDBPath)
tmpFile, err := os.CreateTemp(baseDir, "geolite.*.mmdb.tmp")
if err != nil {
return fmt.Errorf("failed to create temporary database file: %w", err)
}
defer tmpFile.Close()
// Limit the amount we read to maxTotalSize.
// We read one extra byte to detect if the source is larger than the limit.
limitReader := io.LimitReader(reader, maxTotalSize+1)
// Write the file contents directly to the temporary file
written, err := io.Copy(tmpFile, limitReader)
if err != nil {
os.Remove(tmpFile.Name())
return fmt.Errorf("failed to write database file: %w", err)
}
if written > maxTotalSize {
os.Remove(tmpFile.Name())
return errors.New("total database size exceeds maximum allowed limit")
}
// Validate the downloaded database file
if db, err := maxminddb.Open(tmpFile.Name()); err == nil {
db.Close()
} else {
os.Remove(tmpFile.Name())
return fmt.Errorf("failed to open downloaded database file: %w", err)
}
// Ensure atomic replacement of the old database file
s.mutex.Lock()
err = os.Rename(tmpFile.Name(), common.EnvConfig.GeoLiteDBPath)
s.mutex.Unlock()
if err != nil {
os.Remove(tmpFile.Name())
return fmt.Errorf("failed to replace database file: %w", err)
}
return nil
}

View File

@@ -56,10 +56,10 @@ type JwtService struct {
jwksEncoded []byte
}
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
func NewJwtService(ctx context.Context, db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
service := &JwtService{}
err := service.init(db, appConfigService, &common.EnvConfig)
err := service.init(ctx, db, appConfigService, &common.EnvConfig)
if err != nil {
return nil, err
}
@@ -67,16 +67,16 @@ func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService
return service, nil
}
func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
func (s *JwtService) init(ctx context.Context, db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
s.appConfigService = appConfigService
s.envConfig = envConfig
s.db = db
// Ensure keys are generated or loaded
return s.LoadOrGenerateKey()
return s.LoadOrGenerateKey(ctx)
}
func (s *JwtService) LoadOrGenerateKey() error {
func (s *JwtService) LoadOrGenerateKey(ctx context.Context) error {
// Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
if err != nil {
@@ -84,7 +84,7 @@ func (s *JwtService) LoadOrGenerateKey() error {
}
// Try loading a key
key, err := keyProvider.LoadKey()
key, err := keyProvider.LoadKey(ctx)
if err != nil {
return fmt.Errorf("failed to load key: %w", err)
}
@@ -105,7 +105,7 @@ func (s *JwtService) LoadOrGenerateKey() error {
}
// Save the newly-generated key
err = keyProvider.SaveKey(s.privateKey)
err = keyProvider.SaveKey(ctx, s.privateKey)
if err != nil {
return fmt.Errorf("failed to save private key: %w", err)
}

View File

@@ -38,7 +38,7 @@ func initJwtService(t *testing.T, db *gorm.DB, appConfig *AppConfigService, envC
t.Helper()
service := &JwtService{}
err := service.init(db, appConfig, envConfig)
err := service.init(t.Context(), db, appConfig, envConfig)
require.NoError(t, err, "Failed to initialize JWT service")
return service
@@ -65,7 +65,7 @@ func saveKeyToDatabase(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSch
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfig.GetDbConfig().InstanceID.Value)
require.NoError(t, err, "Failed to init key provider")
err = keyProvider.SaveKey(key)
err = keyProvider.SaveKey(t.Context(), key)
require.NoError(t, err, "Failed to save key")
kid, ok := key.KeyID()
@@ -93,7 +93,7 @@ func TestJwtService_Init(t *testing.T) {
// Verify the key has been persisted in the database
keyProvider, err := jwkutils.GetKeyProvider(db, mockEnvConfig, mockConfig.GetDbConfig().InstanceID.Value)
require.NoError(t, err, "Failed to init key provider")
key, err := keyProvider.LoadKey()
key, err := keyProvider.LoadKey(t.Context())
require.NoError(t, err, "Failed to load key from provider")
require.NotNil(t, key, "Key should be present in the database")

View File

@@ -378,13 +378,14 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
}
newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
IsAdmin: isAdmin,
LdapID: ldapId,
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
EmailVerified: true,
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
IsAdmin: isAdmin,
LdapID: ldapId,
}
if newUser.DisplayName == "" {

View File

@@ -56,6 +56,7 @@ type OidcService struct {
auditLogService *AuditLogService
customClaimService *CustomClaimService
webAuthnService *WebAuthnService
scimService *ScimService
httpClient *http.Client
jwkCache *jwk.Cache
@@ -70,6 +71,7 @@ func NewOidcService(
auditLogService *AuditLogService,
customClaimService *CustomClaimService,
webAuthnService *WebAuthnService,
scimService *ScimService,
httpClient *http.Client,
fileStorage storage.FileStorage,
) (s *OidcService, err error) {
@@ -80,6 +82,7 @@ func NewOidcService(
auditLogService: auditLogService,
customClaimService: customClaimService,
webAuthnService: webAuthnService,
scimService: scimService,
httpClient: httpClient,
fileStorage: fileStorage,
}
@@ -311,7 +314,7 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
}
// Explicitly use the input clientID for the audience claim to ensure consistency
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "")
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, deviceAuth.Nonce)
if err != nil {
return CreatedTokens{}, err
}
@@ -1088,6 +1091,7 @@ func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, in
return model.OidcClient{}, err
}
s.scimService.ScheduleSync()
return client, nil
}
@@ -1278,6 +1282,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
ExpiresAt: datatype.DateTime(time.Now().Add(DeviceCodeDuration)),
IsAuthorized: false,
ClientID: client.ID,
Nonce: input.Nonce,
}
if err := s.db.Create(deviceAuth).Error; err != nil {
@@ -1895,7 +1900,7 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
claims["sub"] = user.ID
if slices.Contains(scopes, "email") {
claims["email"] = user.Email
claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
claims["email_verified"] = user.EmailVerified
}
if slices.Contains(scopes, "groups") {

View File

@@ -160,7 +160,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
})
mockJwtService, err := NewJwtService(db, mockConfig)
mockJwtService, err := NewJwtService(t.Context(), db, mockConfig)
require.NoError(t, err)
// Create a mock HTTP client with custom transport to return the JWKS

View File

@@ -0,0 +1,229 @@
package service
import (
"context"
"errors"
"log/slog"
"net/url"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type OneTimeAccessService struct {
db *gorm.DB
userService *UserService
appConfigService *AppConfigService
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
}
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *OneTimeAccessService {
return &OneTimeAccessService{
db: db,
userService: userService,
appConfigService: appConfigService,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
}
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
if isDisabled {
return &common.OneTimeAccessDisabledError{}
}
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false)
return err
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
if isDisabled {
return "", &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Do not return error if user not found to prevent email enumeration
return "", nil
} else if err != nil {
return "", err
}
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true)
if err != nil {
return "", err
} else if deviceToken == nil {
return "", errors.New("device token expected but not returned")
}
return *deviceToken, nil
}
func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.userService.GetUser(ctx, userID)
if err != nil {
return nil, err
}
if user.Email == nil {
return nil, &common.UserEmailNotSetError{}
}
oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
span := trace.SpanFromContext(ctx)
innerCtx := trace.ContextWithSpan(context.Background(), span)
link := common.EnvConfig.AppURL + "/lc"
linkWithCode := link + "/" + oneTimeAccessToken
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
}
errInternal := SendEmail(innerCtx, s.emailService, email.Address{
Name: user.FullName(),
Email: *user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: oneTimeAccessToken,
LoginLink: link,
LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(ttl),
})
if errInternal != nil {
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email))
return
}
}()
return deviceToken, nil
}
func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
return token, err
}
func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
if err != nil {
return "", nil, err
}
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
if err != nil {
return "", nil, err
}
return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
}
func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var oneTimeAccessToken model.OneTimeAccessToken
err := tx.
WithContext(ctx).
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
Preload("User").
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&oneTimeAccessToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
return model.User{}, "", &common.DeviceCodeInvalid{}
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
if err != nil {
return model.User{}, "", err
}
err = tx.
WithContext(ctx).
Delete(&oneTimeAccessToken).
Error
if err != nil {
return model.User{}, "", err
}
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return oneTimeAccessToken.User, accessToken, nil
}
func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if ttl <= 15*time.Minute {
tokenLength = 6
}
token, err := utils.GenerateRandomUnambiguousString(tokenLength)
if err != nil {
return nil, err
}
var deviceToken *string
if withDeviceToken {
dt, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
deviceToken = &dt
}
now := time.Now().Round(time.Second)
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: token,
DeviceToken: deviceToken,
}
return o, nil
}

View File

@@ -1,136 +0,0 @@
package service
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"gorm.io/gorm"
)
// ScimSchedulerService schedules and triggers periodic synchronization
// of SCIM service providers. Each provider is tracked independently,
// and sync operations are run at or after their scheduled time.
type ScimSchedulerService struct {
scimService *ScimService
providerSyncTime map[string]time.Time
mu sync.RWMutex
}
func NewScimSchedulerService(ctx context.Context, scimService *ScimService) (*ScimSchedulerService, error) {
s := &ScimSchedulerService{
scimService: scimService,
providerSyncTime: make(map[string]time.Time),
}
err := s.start(ctx)
return s, err
}
// ScheduleSync forces the given provider to be synced soon by
// moving its next scheduled time to 5 minutes from now.
func (s *ScimSchedulerService) ScheduleSync(providerID string) {
s.setSyncTime(providerID, 5*time.Minute)
}
// start initializes the scheduler and begins the synchronization loop.
// Syncs happen every hour by default, but ScheduleSync can be called to schedule a sync sooner.
func (s *ScimSchedulerService) start(ctx context.Context) error {
if err := s.refreshProviders(ctx); err != nil {
return err
}
go func() {
const (
syncCheckInterval = 5 * time.Second
providerRefreshDelay = time.Minute
)
ticker := time.NewTicker(syncCheckInterval)
defer ticker.Stop()
lastProviderRefresh := time.Now()
for {
select {
case <-ctx.Done():
return
// Runs every 5 seconds to check if any provider is due for sync
case <-ticker.C:
now := time.Now()
if now.Sub(lastProviderRefresh) >= providerRefreshDelay {
err := s.refreshProviders(ctx)
if err != nil {
slog.Error("Error refreshing SCIM service providers",
slog.Any("error", err),
)
} else {
lastProviderRefresh = now
}
}
var due []string
s.mu.RLock()
for providerID, syncTime := range s.providerSyncTime {
if !syncTime.After(now) {
due = append(due, providerID)
}
}
s.mu.RUnlock()
s.syncProviders(ctx, due)
}
}
}()
return nil
}
func (s *ScimSchedulerService) refreshProviders(ctx context.Context) error {
providers, err := s.scimService.ListServiceProviders(ctx)
if err != nil {
return err
}
inAHour := time.Now().Add(time.Hour)
s.mu.Lock()
for _, provider := range providers {
if _, exists := s.providerSyncTime[provider.ID]; !exists {
s.providerSyncTime[provider.ID] = inAHour
}
}
s.mu.Unlock()
return nil
}
func (s *ScimSchedulerService) syncProviders(ctx context.Context, providerIDs []string) {
for _, providerID := range providerIDs {
err := s.scimService.SyncServiceProvider(ctx, providerID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Remove the provider from the schedule if it no longer exists
s.mu.Lock()
delete(s.providerSyncTime, providerID)
s.mu.Unlock()
} else {
slog.Error("Error syncing SCIM client",
slog.String("provider_id", providerID),
slog.Any("error", err),
)
}
continue
}
// A successful sync schedules the next sync in an hour
s.setSyncTime(providerID, time.Hour)
}
}
func (s *ScimSchedulerService) setSyncTime(providerID string, t time.Duration) {
s.mu.Lock()
s.providerSyncTime[providerID] = time.Now().Add(t)
s.mu.Unlock()
}

View File

@@ -15,6 +15,7 @@ import (
"strings"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -32,6 +33,11 @@ const scimErrorBodyLimit = 4096
type scimSyncAction int
type Scheduler interface {
RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error
RemoveJob(name string) error
}
const (
scimActionNone scimSyncAction = iota
scimActionCreated
@@ -48,15 +54,16 @@ type scimSyncStats struct {
// ScimService handles SCIM provisioning to external service providers.
type ScimService struct {
db *gorm.DB
scheduler Scheduler
httpClient *http.Client
}
func NewScimService(db *gorm.DB, httpClient *http.Client) *ScimService {
func NewScimService(db *gorm.DB, scheduler Scheduler, httpClient *http.Client) *ScimService {
if httpClient == nil {
httpClient = &http.Client{Timeout: 20 * time.Second}
}
return &ScimService{db: db, httpClient: httpClient}
return &ScimService{db: db, scheduler: scheduler, httpClient: httpClient}
}
func (s *ScimService) GetServiceProvider(
@@ -132,6 +139,41 @@ func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProvider
Error
}
//nolint:contextcheck
func (s *ScimService) ScheduleSync() {
jobName := "ScheduledScimSync"
start := time.Now().Add(5 * time.Minute)
_ = s.scheduler.RemoveJob(jobName)
err := s.scheduler.RegisterJob(
context.Background(), jobName,
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, false)
if err != nil {
slog.Error("Failed to schedule SCIM sync", slog.Any("error", err))
}
}
func (s *ScimService) SyncAll(ctx context.Context) error {
providers, err := s.ListServiceProviders(ctx)
if err != nil {
return err
}
var errs []error
for _, provider := range providers {
if ctx.Err() != nil {
errs = append(errs, ctx.Err())
break
}
if err := s.SyncServiceProvider(ctx, provider.ID); err != nil {
errs = append(errs, fmt.Errorf("failed to sync SCIM provider %s: %w", provider.ID, err))
}
}
return errors.Join(errs...)
}
func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID string) error {
start := time.Now()
provider, err := s.GetServiceProvider(ctx, serviceProviderID)

View File

@@ -16,11 +16,12 @@ import (
type UserGroupService struct {
db *gorm.DB
scimService *ScimService
appConfigService *AppConfigService
}
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService) *UserGroupService {
return &UserGroupService{db: db, appConfigService: appConfigService}
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService {
return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService}
}
func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
@@ -90,7 +91,13 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
return err
}
return tx.Commit().Error
err = tx.Commit().Error
if err != nil {
return err
}
s.scimService.ScheduleSync()
return nil
}
func (s *UserGroupService) Create(ctx context.Context, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
@@ -118,6 +125,8 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
}
return model.UserGroup{}, err
}
s.scimService.ScheduleSync()
return group, nil
}
@@ -165,6 +174,8 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
} else if err != nil {
return model.UserGroup{}, err
}
s.scimService.ScheduleSync()
return group, nil
}
@@ -227,6 +238,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
return model.UserGroup{}, err
}
s.scimService.ScheduleSync()
return group, nil
}
@@ -303,5 +315,6 @@ func (s *UserGroupService) UpdateAllowedOidcClient(ctx context.Context, id strin
return model.UserGroup{}, err
}
s.scimService.ScheduleSync()
return group, nil
}

View File

@@ -9,13 +9,11 @@ import (
"io"
"io/fs"
"log/slog"
"net/url"
"path"
"strings"
"time"
"github.com/google/uuid"
"go.opentelemetry.io/otel/trace"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -25,7 +23,6 @@ import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
)
@@ -37,10 +34,11 @@ type UserService struct {
appConfigService *AppConfigService
customClaimService *CustomClaimService
appImagesService *AppImagesService
scimService *ScimService
fileStorage storage.FileStorage
}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, fileStorage storage.FileStorage) *UserService {
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
return &UserService{
db: db,
jwtService: jwtService,
@@ -49,6 +47,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
appConfigService: appConfigService,
customClaimService: customClaimService,
appImagesService: appImagesService,
scimService: scimService,
fileStorage: fileStorage,
}
}
@@ -226,6 +225,7 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
return fmt.Errorf("failed to delete user: %w", err)
}
s.scimService.ScheduleSync()
return nil
}
@@ -266,15 +266,16 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
}
user := model.User{
FirstName: input.FirstName,
LastName: input.LastName,
DisplayName: input.DisplayName,
Email: input.Email,
Username: input.Username,
IsAdmin: input.IsAdmin,
Locale: input.Locale,
Disabled: input.Disabled,
UserGroups: userGroups,
FirstName: input.FirstName,
LastName: input.LastName,
DisplayName: input.DisplayName,
Email: input.Email,
EmailVerified: input.EmailVerified,
Username: input.Username,
IsAdmin: input.IsAdmin,
Locale: input.Locale,
Disabled: input.Disabled,
UserGroups: userGroups,
}
if input.LdapID != "" {
user.LdapID = &input.LdapID
@@ -309,6 +310,7 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
}
}
s.scimService.ScheduleSync()
return user, nil
}
@@ -415,13 +417,20 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName
user.DisplayName = updatedUser.DisplayName
user.Email = updatedUser.Email
user.Username = updatedUser.Username
user.Locale = updatedUser.Locale
if (user.Email == nil && updatedUser.Email != nil) || (user.Email != nil && updatedUser.Email != nil && *user.Email != *updatedUser.Email) {
// Email has changed, reset email verification status
user.EmailVerified = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
}
user.Email = updatedUser.Email
// Admin-only fields: Only allow updates when not updating own account
if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin
user.EmailVerified = updatedUser.EmailVerified
user.Disabled = updatedUser.Disabled
}
}
@@ -447,167 +456,10 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, err
}
s.scimService.ScheduleSync()
return user, nil
}
func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
if isDisabled {
return &common.OneTimeAccessDisabledError{}
}
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, true)
return err
}
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
if isDisabled {
return "", &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Do not return error if user not found to prevent email enumeration
return "", nil
} else if err != nil {
return "", err
}
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true)
if err != nil {
return "", err
} else if deviceToken == nil {
return "", errors.New("device token expected but not returned")
}
return *deviceToken, nil
}
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.GetUser(ctx, userID)
if err != nil {
return nil, err
}
if user.Email == nil {
return nil, &common.UserEmailNotSetError{}
}
oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
span := trace.SpanFromContext(ctx)
innerCtx := trace.ContextWithSpan(context.Background(), span)
link := common.EnvConfig.AppURL + "/lc"
linkWithCode := link + "/" + oneTimeAccessToken
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
}
errInternal := SendEmail(innerCtx, s.emailService, email.Address{
Name: user.FullName(),
Email: *user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: oneTimeAccessToken,
LoginLink: link,
LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(ttl),
})
if errInternal != nil {
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email))
return
}
}()
return deviceToken, nil
}
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
return token, err
}
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
if err != nil {
return "", nil, err
}
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
if err != nil {
return "", nil, err
}
return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
}
func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var oneTimeAccessToken model.OneTimeAccessToken
err := tx.
WithContext(ctx).
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
Preload("User").
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&oneTimeAccessToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
return model.User{}, "", &common.DeviceCodeInvalid{}
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
if err != nil {
return model.User{}, "", err
}
err = tx.
WithContext(ctx).
Delete(&oneTimeAccessToken).
Error
if err != nil {
return model.User{}, "", err
}
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return oneTimeAccessToken.User, accessToken, nil
}
func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroupIds []string) (user model.User, err error) {
tx := s.db.Begin()
defer func() {
@@ -663,50 +515,10 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
return model.User{}, err
}
s.scimService.ScheduleSync()
return user, nil
}
func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.SignUpDto) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var userCount int64
if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil {
return model.User{}, "", err
}
if userCount != 0 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
userToCreate := dto.UserCreateDto{
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
}
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
if err != nil {
return model.User{}, "", err
}
token, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
}
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return user, token, nil
}
func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User, tx *gorm.DB) error {
var result struct {
Found bool
@@ -753,180 +565,87 @@ func (s *UserService) ResetProfilePicture(ctx context.Context, userID string) er
}
func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, userID string) error {
return tx.
err := tx.
WithContext(ctx).
Model(&model.User{}).
Where("id = ?", userID).
Update("disabled", true).
Error
if err != nil {
return err
}
s.scimService.ScheduleSync()
return nil
}
func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) {
signupToken, err := NewSignupToken(ttl, usageLimit)
func (s *UserService) SendEmailVerification(ctx context.Context, userID string) error {
user, err := s.GetUser(ctx, userID)
if err != nil {
return model.SignupToken{}, err
return err
}
var userGroups []model.UserGroup
err = s.db.WithContext(ctx).
Where("id IN ?", userGroupIDs).
Find(&userGroups).
Error
if err != nil {
return model.SignupToken{}, err
}
signupToken.UserGroups = userGroups
err = s.db.WithContext(ctx).Create(signupToken).Error
if err != nil {
return model.SignupToken{}, err
if user.Email == nil {
return &common.UserEmailNotSetError{}
}
return *signupToken, nil
randomToken, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return err
}
expiration := time.Now().Add(24 * time.Hour)
emailVerificationToken := &model.EmailVerificationToken{
UserID: user.ID,
Token: randomToken,
ExpiresAt: datatype.DateTime(expiration),
}
err = s.db.WithContext(ctx).Create(emailVerificationToken).Error
if err != nil {
return err
}
return SendEmail(ctx, s.emailService, email.Address{
Name: user.FullName(),
Email: *user.Email,
}, EmailVerificationTemplate, &EmailVerificationTemplateData{
UserFullName: user.FullName(),
VerificationLink: common.EnvConfig.AppURL + "/verify-email?token=" + emailVerificationToken.Token,
})
}
func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAddress, userAgent string) (model.User, string, error) {
func (s *UserService) VerifyEmail(ctx context.Context, userID string, token string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
defer tx.Rollback()
tokenProvided := signupData.Token != ""
var emailVerificationToken model.EmailVerificationToken
err := tx.WithContext(ctx).Where("token = ? AND user_id = ? AND expires_at > ?",
token, userID, datatype.DateTime(time.Now())).First(&emailVerificationToken).Error
config := s.appConfigService.GetDbConfig()
if config.AllowUserSignups.Value != "open" && !tokenProvided {
return model.User{}, "", &common.OpenSignupDisabledError{}
if errors.Is(err, gorm.ErrRecordNotFound) {
return &common.InvalidEmailVerificationTokenError{}
} else if err != nil {
return err
}
var signupToken model.SignupToken
var userGroupIDs []string
if tokenProvided {
err := tx.
WithContext(ctx).
Preload("UserGroups").
Where("token = ?", signupData.Token).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&signupToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
if !signupToken.IsValid() {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
for _, group := range signupToken.UserGroups {
userGroupIDs = append(userGroupIDs, group.ID)
}
}
userToCreate := dto.UserCreateDto{
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
UserGroupIds: userGroupIDs,
}
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
user, err := s.getUserInternal(ctx, emailVerificationToken.UserID, tx)
if err != nil {
return model.User{}, "", err
return err
}
accessToken, err := s.jwtService.GenerateAccessToken(user)
user.EmailVerified = true
user.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
err = tx.WithContext(ctx).Save(&user).Error
if err != nil {
return model.User{}, "", err
return err
}
if tokenProvided {
s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
"signupToken": signupToken.Token,
}, tx)
signupToken.UsageCount++
err = tx.WithContext(ctx).Save(&signupToken).Error
if err != nil {
return model.User{}, "", err
}
} else {
s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
"method": "open_signup",
}, tx)
}
err = tx.Commit().Error
err = tx.WithContext(ctx).Delete(&emailVerificationToken).Error
if err != nil {
return model.User{}, "", err
return err
}
return user, accessToken, nil
}
func (s *UserService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
var tokens []model.SignupToken
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{})
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
return tokens, pagination, err
}
func (s *UserService) DeleteSignupToken(ctx context.Context, tokenID string) error {
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
}
func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if ttl <= 15*time.Minute {
tokenLength = 6
}
token, err := utils.GenerateRandomUnambiguousString(tokenLength)
if err != nil {
return nil, err
}
var deviceToken *string
if withDeviceToken {
dt, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
deviceToken = &dt
}
now := time.Now().Round(time.Second)
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: token,
DeviceToken: deviceToken,
}
return o, nil
}
func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, error) {
// Generate a random token
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
now := time.Now().Round(time.Second)
token := &model.SignupToken{
Token: randomString,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
UsageLimit: usageLimit,
UsageCount: 0,
}
return token, nil
return tx.Commit().Error
}

View File

@@ -0,0 +1,216 @@
package service
import (
"context"
"errors"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type UserSignUpService struct {
db *gorm.DB
userService *UserService
jwtService *JwtService
auditLogService *AuditLogService
appConfigService *AppConfigService
}
func NewUserSignupService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService, userService *UserService) *UserSignUpService {
return &UserSignUpService{
db: db,
jwtService: jwtService,
auditLogService: auditLogService,
appConfigService: appConfigService,
userService: userService,
}
}
func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
tokenProvided := signupData.Token != ""
config := s.appConfigService.GetDbConfig()
if config.AllowUserSignups.Value != "open" && !tokenProvided {
return model.User{}, "", &common.OpenSignupDisabledError{}
}
var signupToken model.SignupToken
var userGroupIDs []string
if tokenProvided {
err := tx.
WithContext(ctx).
Preload("UserGroups").
Where("token = ?", signupData.Token).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&signupToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
if !signupToken.IsValid() {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
for _, group := range signupToken.UserGroups {
userGroupIDs = append(userGroupIDs, group.ID)
}
}
userToCreate := dto.UserCreateDto{
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
UserGroupIds: userGroupIDs,
EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(),
}
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
if err != nil {
return model.User{}, "", err
}
accessToken, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
}
if tokenProvided {
s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
"signupToken": signupToken.Token,
}, tx)
signupToken.UsageCount++
err = tx.WithContext(ctx).Save(&signupToken).Error
if err != nil {
return model.User{}, "", err
}
} else {
s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
"method": "open_signup",
}, tx)
}
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return user, accessToken, nil
}
func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData dto.SignUpDto) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var userCount int64
if err := tx.WithContext(ctx).Model(&model.User{}).
Where("id != ?", staticApiKeyUserID).
Count(&userCount).Error; err != nil {
return model.User{}, "", err
}
if userCount != 0 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
userToCreate := dto.UserCreateDto{
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
}
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
if err != nil {
return model.User{}, "", err
}
token, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
}
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return user, token, nil
}
func (s *UserSignUpService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
var tokens []model.SignupToken
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{})
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
return tokens, pagination, err
}
func (s *UserSignUpService) DeleteSignupToken(ctx context.Context, tokenID string) error {
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
}
func (s *UserSignUpService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) {
signupToken, err := NewSignupToken(ttl, usageLimit)
if err != nil {
return model.SignupToken{}, err
}
var userGroups []model.UserGroup
err = s.db.WithContext(ctx).
Where("id IN ?", userGroupIDs).
Find(&userGroups).
Error
if err != nil {
return model.SignupToken{}, err
}
signupToken.UserGroups = userGroups
err = s.db.WithContext(ctx).Create(signupToken).Error
if err != nil {
return model.SignupToken{}, err
}
return *signupToken, nil
}
func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, error) {
// Generate a random token
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
now := time.Now().Round(time.Second)
token := &model.SignupToken{
Token: randomString,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
UsageLimit: usageLimit,
UsageCount: 0,
}
return token, nil
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
@@ -31,6 +32,10 @@ func NewVersionService(httpClient *http.Client) *VersionService {
}
func (s *VersionService) GetLatestVersion(ctx context.Context) (string, error) {
if common.EnvConfig.VersionCheckDisabled {
return "", nil
}
version, err := s.cache.GetOrFetch(ctx, func(ctx context.Context) (string, error) {
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

View File

@@ -17,31 +17,38 @@ func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL
// time of the request for loopback IP redirect URIs, to accommodate
// clients that obtain an available ephemeral port from the operating
// system at the time of the request.
loopbackRedirect := ""
loopbackCallbackURLWithoutPort := ""
u, _ := url.Parse(inputCallbackURL)
if u != nil && u.Scheme == "http" {
host := u.Hostname()
ip := net.ParseIP(host)
if host == "localhost" || (ip != nil && ip.IsLoopback()) {
loopbackRedirect = u.String()
u.Host = host
inputCallbackURL = u.String()
loopbackCallbackURLWithoutPort = u.String()
}
}
for _, pattern := range urls {
// Try the original callback first
matches, err := matchCallbackURL(pattern, inputCallbackURL)
if err != nil {
return "", err
} else if !matches {
continue
}
if matches {
return inputCallbackURL, nil
}
if loopbackRedirect != "" {
return loopbackRedirect, nil
// If we have a loopback variant, try that too
if loopbackCallbackURLWithoutPort != "" {
matches, err = matchCallbackURL(pattern, loopbackCallbackURLWithoutPort)
if err != nil {
return "", err
}
if matches {
return inputCallbackURL, nil
}
}
return inputCallbackURL, nil
}
return "", nil

View File

@@ -392,6 +392,13 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
expectedURL: "http://127.0.0.1:8080/callback",
expectMatch: true,
},
{
name: "127.0.0.1 with same port - exact match",
urls: []string{"http://127.0.0.1:8080/callback"},
inputCallbackURL: "http://127.0.0.1:8080/callback",
expectedURL: "http://127.0.0.1:8080/callback",
expectMatch: true,
},
{
name: "127.0.0.1 with different port",
urls: []string{"http://127.0.0.1/callback"},

View File

@@ -8,7 +8,7 @@ import (
var AccessTokenCookieName = "__Host-access_token"
var SessionIdCookieName = "__Host-session"
var DeviceTokenCookieName = "__Host-device_token" //nolint:gosec
var DeviceTokenCookieName = "__Secure-device_token" //nolint:gosec
func init() {
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {

View File

@@ -35,10 +35,17 @@ func MigrateDatabase(sqlDb *sql.DB) error {
return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion)
}
slog.Info("Fetching migrations from GitHub to handle possible downgrades")
return migrateDatabaseFromGitHub(sqlDb, requiredVersion)
return migrateDatabaseFromGitHub(sqlDb, requiredVersion, currentVersion)
}
if err := m.Migrate(requiredVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) {
err = m.Migrate(requiredVersion)
if err != nil {
if errors.Is(err, migrate.ErrNoChange) {
return nil
}
if errors.As(err, &migrate.ErrDirty{}) {
return fmt.Errorf("database migration failed. Please create an issue on GitHub and temporarely downgrade to the previous version: %w", err)
}
return fmt.Errorf("failed to apply embedded migrations: %w", err)
}
return nil
@@ -85,7 +92,7 @@ func newMigrationDriver(sqlDb *sql.DB, dbProvider common.DbProvider) (driver dat
}
// migrateDatabaseFromGitHub applies database migrations fetched from GitHub to handle downgrades.
func migrateDatabaseFromGitHub(sqlDb *sql.DB, version uint) error {
func migrateDatabaseFromGitHub(sqlDb *sql.DB, requiredVersion uint, currentVersion uint) error {
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider)
@@ -98,9 +105,18 @@ func migrateDatabaseFromGitHub(sqlDb *sql.DB, version uint) error {
return fmt.Errorf("failed to create GitHub migration instance: %w", err)
}
if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
// Reset the dirty state before forcing the version
if err := m.Force(int(currentVersion)); err != nil { //nolint:gosec
return fmt.Errorf("failed to force database version: %w", err)
}
if err := m.Migrate(requiredVersion); err != nil {
if errors.Is(err, migrate.ErrNoChange) {
return nil
}
return fmt.Errorf("failed to apply GitHub migrations: %w", err)
}
return nil
}

View File

@@ -2,6 +2,7 @@ package utils
import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -21,6 +22,27 @@ func BearerAuth(r *http.Request) (string, bool) {
return "", false
}
// OAuthClientBasicAuth returns the OAuth client ID and secret provided in the request's
// Authorization header, if present. See RFC 6749, Section 2.3.
func OAuthClientBasicAuth(r *http.Request) (clientID, clientSecret string, ok bool) {
clientID, clientSecret, ok = r.BasicAuth()
if !ok {
return "", "", false
}
clientID, err := url.QueryUnescape(clientID)
if err != nil {
return "", "", false
}
clientSecret, err = url.QueryUnescape(clientSecret)
if err != nil {
return "", "", false
}
return clientID, clientSecret, true
}
// SetCacheControlHeader sets the Cache-Control header for the response.
func SetCacheControlHeader(ctx *gin.Context, maxAge, staleWhileRevalidate time.Duration) {
_, ok := ctx.GetQuery("skipCache")

View File

@@ -63,3 +63,62 @@ func TestBearerAuth(t *testing.T) {
})
}
}
func TestOAuthClientBasicAuth(t *testing.T) {
tests := []struct {
name string
authHeader string
expectedClientID string
expectedClientSecret string
expectedOk bool
}{
{
name: "Valid client ID and secret in header (example from RFC 6749)",
authHeader: "Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3",
expectedClientID: "s6BhdRkqt3",
expectedClientSecret: "7Fjfp0ZBr1KtDRbnfVdmIw",
expectedOk: true,
},
{
name: "Valid client ID and secret in header (escaped values)",
authHeader: "Basic ZTUwOTcyYmQtNmUzMi00OTU3LWJhZmMtMzU0MTU3ZjI1NDViOislMjUlMjYlMkIlQzIlQTMlRTIlODIlQUM=",
expectedClientID: "e50972bd-6e32-4957-bafc-354157f2545b",
// This is the example string from RFC 6749, Appendix B.
expectedClientSecret: " %&+£€",
expectedOk: true,
},
{
name: "Empty auth header",
authHeader: "",
expectedClientID: "",
expectedClientSecret: "",
expectedOk: false,
},
{
name: "Basic prefix only",
authHeader: "Basic ",
expectedClientID: "",
expectedClientSecret: "",
expectedOk: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com", nil)
require.NoError(t, err, "Failed to create request")
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
clientId, clientSecret, ok := OAuthClientBasicAuth(req)
assert.Equal(t, tt.expectedOk, ok)
if tt.expectedOk {
assert.Equal(t, tt.expectedClientID, clientId)
assert.Equal(t, tt.expectedClientSecret, clientSecret)
}
})
}
}

View File

@@ -1,6 +1,7 @@
package jwk
import (
"context"
"fmt"
"github.com/lestrrat-go/jwx/v3/jwk"
@@ -17,8 +18,8 @@ type KeyProviderOpts struct {
type KeyProvider interface {
Init(opts KeyProviderOpts) error
LoadKey() (jwk.Key, error)
SaveKey(key jwk.Key) error
LoadKey(ctx context.Context) (jwk.Key, error)
SaveKey(ctx context.Context, key jwk.Key) error
}
func GetKeyProvider(db *gorm.DB, envConfig *common.EnvConfigSchema, instanceID string) (keyProvider KeyProvider, err error) {

View File

@@ -33,12 +33,12 @@ func (f *KeyProviderDatabase) Init(opts KeyProviderOpts) error {
return nil
}
func (f *KeyProviderDatabase) LoadKey() (key jwk.Key, err error) {
func (f *KeyProviderDatabase) LoadKey(ctx context.Context) (key jwk.Key, err error) {
row := model.KV{
Key: PrivateKeyDBKey,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err = f.db.WithContext(ctx).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -74,7 +74,7 @@ func (f *KeyProviderDatabase) LoadKey() (key jwk.Key, err error) {
return key, nil
}
func (f *KeyProviderDatabase) SaveKey(key jwk.Key) error {
func (f *KeyProviderDatabase) SaveKey(ctx context.Context, key jwk.Key) error {
// Encode the key to JSON
data, err := EncodeJWKBytes(key)
if err != nil {
@@ -94,7 +94,7 @@ func (f *KeyProviderDatabase) SaveKey(key jwk.Key) error {
Value: &encB64,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err = f.db.
WithContext(ctx).

View File

@@ -59,7 +59,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err)
// Load key when none exists
loadedKey, err := provider.LoadKey()
loadedKey, err := provider.LoadKey(t.Context())
require.NoError(t, err)
assert.Nil(t, loadedKey, "Expected nil key when no key exists in database")
})
@@ -76,11 +76,11 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err)
// Save a key
err = provider.SaveKey(key)
err = provider.SaveKey(t.Context(), key)
require.NoError(t, err)
// Load the key
loadedKey, err := provider.LoadKey()
loadedKey, err := provider.LoadKey(t.Context())
require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when key exists in database")
@@ -114,7 +114,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err)
// Attempt to load the key
loadedKey, err := provider.LoadKey()
loadedKey, err := provider.LoadKey(t.Context())
require.Error(t, err, "Expected error when loading key with invalid base64")
require.ErrorContains(t, err, "not a valid base64-encoded value")
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
@@ -140,7 +140,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err)
// Attempt to load the key
loadedKey, err := provider.LoadKey()
loadedKey, err := provider.LoadKey(t.Context())
require.Error(t, err, "Expected error when loading key with invalid encrypted data")
require.ErrorContains(t, err, "failed to decrypt")
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
@@ -158,7 +158,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
})
require.NoError(t, err)
err = originalProvider.SaveKey(key)
err = originalProvider.SaveKey(t.Context(), key)
require.NoError(t, err)
// Now try to load with a different KEK
@@ -171,7 +171,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err)
// Attempt to load the key with the wrong KEK
loadedKey, err := differentProvider.LoadKey()
loadedKey, err := differentProvider.LoadKey(t.Context())
require.Error(t, err, "Expected error when loading key with wrong KEK")
require.ErrorContains(t, err, "failed to decrypt")
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
@@ -206,7 +206,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err)
// Attempt to load the key
loadedKey, err := provider.LoadKey()
loadedKey, err := provider.LoadKey(t.Context())
require.Error(t, err, "Expected error when loading invalid key data")
require.ErrorContains(t, err, "failed to parse")
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
@@ -233,7 +233,7 @@ func TestKeyProviderDatabase_SaveKey(t *testing.T) {
require.NoError(t, err)
// Save the key
err = provider.SaveKey(key)
err = provider.SaveKey(t.Context(), key)
require.NoError(t, err, "Expected no error when saving key")
// Verify record exists in database

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table><!--/$--></body></html>{{end}}
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -6,7 +6,6 @@ API KEY EXPIRING SOON
Warning
Hello {{.Data.Name}},
This is a reminder that your API key {{.Data.APIKeyName}} will expire on
{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
Please generate a new API key if you need continued access.{{end}}

View File

@@ -0,0 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Email Verification</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.UserFullName}}<!-- -->, <br/>Click the button below to verify your email address for <!-- -->{{.AppName}}<!-- -->. This link will expire in 24 hours.<br/></p><div style="text-align:center"><a href="{{.Data.VerificationLink}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Verify</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -0,0 +1,10 @@
{{define "root"}}{{.AppName}}
EMAIL VERIFICATION
Hello {{.Data.UserFullName}},
Click the button below to verify your email address for {{.AppName}}. This link will expire in 24 hours.
Verify {{.Data.VerificationLink}}{{end}}

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your <!-- -->{{.AppName}}<!-- --> account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.</p><h4 style="font-size:1rem;font-weight:bold;margin:30px 0 10px 0">Details</h4><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Approximate Location</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table><!--/$--></body></html>{{end}}
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your <!-- -->{{.AppName}}<!-- --> account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.</p><h4 style="font-size:1rem;font-weight:bold;margin:30px 0 10px 0">Details</h4><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Approximate Location</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -5,15 +5,13 @@ NEW SIGN-IN DETECTED
Warning
Your {{.AppName}} account was recently accessed from a new IP address or
browser. If you recognize this activity, no further action is required.
Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.
DETAILS
Approximate Location
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if
.Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
IP Address

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Click the button below to sign in to <!-- -->{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table><!--/$--></body></html>{{end}}
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Click the button below to sign in to <!-- -->{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -4,8 +4,7 @@
YOUR LOGIN CODE
Click the button below to sign in to {{.AppName}} with a login code.
Or visit {{.Data.LoginLink}} {{.Data.LoginLink}} and enter the code
{{.Data.Code}}.
Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}}.
This code expires in {{.Data.ExpirationString}}.

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your email setup is working correctly!</p></div></td></tr></tbody></table><!--/$--></body></html>{{end}}
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your email setup is working correctly!</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET value = 'ldapAttributeAdminGroup' WHERE value = 'ldapAdminGroupName';

View File

@@ -0,0 +1,8 @@
UPDATE app_config_variables
SET key = 'ldapAdminGroupName'
WHERE key = 'ldapAttributeAdminGroup'
AND NOT EXISTS (
SELECT 1
FROM app_config_variables
WHERE key = 'ldapAdminGroupName'
);

View File

@@ -0,0 +1 @@
-- No-op on Postgres

View File

@@ -0,0 +1 @@
-- No-op on Postgres

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_device_codes DROP COLUMN nonce;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_device_codes ADD COLUMN nonce VARCHAR(255);

View File

@@ -0,0 +1 @@
-- No-op on Postgres

View File

@@ -0,0 +1 @@
-- No-op on Postgres

View File

@@ -0,0 +1,2 @@
DROP TABLE email_verification_tokens;
ALTER TABLE users DROP COLUMN email_verified;

View File

@@ -0,0 +1,17 @@
CREATE TABLE email_verification_tokens
(
id UUID PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE
);
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users
SET email_verified = EXISTS (SELECT 1
FROM app_config_variables
WHERE key = 'emailsVerified'
AND value = 'true');

View File

@@ -10,8 +10,8 @@ CREATE TABLE users_new
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
username TEXT COLLATE NOCASE NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
first_name TEXT,
email TEXT UNIQUE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
display_name TEXT NOT NULL,
is_admin BOOLEAN DEFAULT FALSE NOT NULL,

View File

@@ -0,0 +1,7 @@
PRAGMA foreign_keys= OFF;
BEGIN;
UPDATE app_config_variables SET value = 'ldapAttributeAdminGroup' WHERE value = 'ldapAdminGroupName';
COMMIT;
PRAGMA foreign_keys= ON;

View File

@@ -0,0 +1,14 @@
PRAGMA foreign_keys= OFF;
BEGIN;
UPDATE app_config_variables
SET key = 'ldapAdminGroupName'
WHERE key = 'ldapAttributeAdminGroup'
AND NOT EXISTS (
SELECT 1
FROM app_config_variables
WHERE key = 'ldapAdminGroupName'
);
COMMIT;
PRAGMA foreign_keys= ON;

View File

@@ -0,0 +1,52 @@
PRAGMA foreign_keys= OFF;
BEGIN;
CREATE TABLE users_new
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
updated_at DATETIME,
username TEXT COLLATE NOCASE NOT NULL UNIQUE,
email TEXT UNIQUE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
display_name TEXT NOT NULL,
is_admin BOOLEAN DEFAULT FALSE NOT NULL,
ldap_id TEXT UNIQUE,
locale TEXT,
disabled BOOLEAN DEFAULT FALSE NOT NULL
);
INSERT INTO users_new (
id,
created_at,
updated_at,
username,
email,
first_name,
last_name,
display_name,
is_admin,
ldap_id,
locale,
disabled
) SELECT
id,
created_at,
updated_at,
username,
email,
first_name,
last_name,
display_name,
is_admin,
ldap_id,
locale,
disabled FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
COMMIT;
PRAGMA foreign_keys= ON;

View File

@@ -0,0 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_device_codes DROP COLUMN nonce;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_device_codes ADD COLUMN nonce TEXT;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,51 @@
PRAGMA foreign_keys=OFF;
BEGIN;
CREATE TABLE oidc_clients_dg_tmp
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
name TEXT,
secret TEXT,
callback_urls BLOB,
image_type TEXT,
created_by_id TEXT REFERENCES users ON DELETE SET NULL,
is_public BOOLEAN DEFAULT FALSE,
pkce_enabled BOOLEAN DEFAULT FALSE,
logout_callback_urls BLOB,
credentials BLOB,
launch_url TEXT,
requires_reauthentication BOOLEAN NOT NULL DEFAULT FALSE,
dark_image_type TEXT,
is_group_restricted BOOLEAN NOT NULL DEFAULT 0
);
INSERT INTO oidc_clients_dg_tmp (
id, created_at, name, secret, callback_urls, image_type, created_by_id,
is_public, pkce_enabled, logout_callback_urls, credentials, launch_url,
requires_reauthentication, dark_image_type, is_group_restricted
)
SELECT
id,
created_at,
name,
secret,
callback_urls,
image_type,
created_by_id,
is_public,
pkce_enabled,
logout_callback_urls,
credentials,
launch_url,
requires_reauthentication,
dark_image_type,
is_group_restricted
FROM oidc_clients;
DROP TABLE oidc_clients;
ALTER TABLE oidc_clients_dg_tmp RENAME TO oidc_clients;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,8 @@
PRAGMA foreign_keys= OFF;
BEGIN;
DROP TABLE email_verification_tokens;
ALTER TABLE users DROP COLUMN email_verified;
COMMIT;
PRAGMA foreign_keys= ON;

View File

@@ -0,0 +1,24 @@
PRAGMA foreign_keys= OFF;
BEGIN;
CREATE TABLE email_verification_tokens
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
user_id TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users
SET email_verified =EXISTS (SELECT 1
FROM app_config_variables
WHERE key = 'emailsVerified'
AND value = 'true');
COMMIT;
PRAGMA foreign_keys= ON;

View File

@@ -1,6 +1,6 @@
services:
pocket-id:
image: ghcr.io/pocket-id/pocket-id:v1
image: ghcr.io/pocket-id/pocket-id:v2
restart: unless-stopped
env_file: .env
ports:

View File

@@ -0,0 +1,54 @@
import { Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import { Button } from "../components/button";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
interface EmailVerificationData {
userFullName: string;
verificationLink: string;
}
interface EmailVerificationProps {
logoURL: string;
appName: string;
data: EmailVerificationData;
}
export const EmailVerification = ({
logoURL,
appName,
data,
}: EmailVerificationProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="Email Verification" />
<Text>
Hello {data.userFullName}, <br />
Click the button below to verify your email address for {appName}. This
link will expire in 24 hours.
<br />
</Text>
<Button href={data.verificationLink}>Verify</Button>
</BaseTemplate>
);
export default EmailVerification;
EmailVerification.TemplateProps = {
...sharedTemplateProps,
data: {
userFullName: "{{.Data.UserFullName}}",
verificationLink: "{{.Data.VerificationLink}}",
},
};
EmailVerification.PreviewProps = {
...sharedPreviewProps,
data: {
userFullName: "Tim Cook",
verificationLink:
"https://localhost:1411/user/verify-email?code=abcdefg12345",
},
};

View File

@@ -46,7 +46,11 @@
"authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče",
"passkey_was_previously_registered": "Tento přístupový klíč byl již dříve zaregistrován",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Autentikátor nepodporuje žádný z požadovaných algoritmů",
"authenticator_timed_out": "Vypršel časový limit autentifikátoru",
"webauthn_error_invalid_rp_id": "Nakonfigurované ID spoléhající strany je neplatné.",
"webauthn_error_invalid_domain": "Nakonfigurovaná doména je neplatná.",
"contact_administrator_to_fix": "Kontaktujte svého správce, aby tento problém vyřešil.",
"webauthn_operation_not_allowed_or_timed_out": "Operace nebyla povolena nebo vypršela časová lhůta.",
"webauthn_not_supported_by_browser": "Tento prohlížeč nepodporuje přístupové klíče. Použijte prosím alternativní způsob přihlášení.",
"critical_error_occurred_contact_administrator": "Došlo k kritické chybě. Obraťte se na správce.",
"sign_in_to": "Přihlásit se k {name}",
"client_not_found": "Klient nebyl nalezen",
@@ -192,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Doba trvání relace v minutách, než se uživatel musí znovu přihlásit.",
"enable_self_account_editing": "Povolit úpravy vlastního účtu",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Zda by uživatelé měli mít možnost upravit vlastní údaje o účtu.",
"emails_verified": "E-mail ověřen",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Zda má být e-mail uživatele označen jako ověřený pro OIDC klienty.",
"ldap_configuration_updated_successfully": "Nastavení LDAP bylo úspěšně aktualizováno",
"ldap_disabled_successfully": "LDAP úspěšně zakázán",
"ldap_sync_finished": "LDAP synchronizace dokončena",
@@ -498,5 +500,26 @@
"scim_sync_successful": "Synchronizace SCIM byla úspěšně dokončena.",
"save_and_sync": "Uložit a synchronizovat",
"scim_save_changes_description": "Před spuštěním synchronizace SCIM je nutné uložit změny. Chcete uložit nyní?",
"scopes": "Rozsah"
"scopes": "Rozsah",
"issuer_url": "URL vydavatele",
"smtp_field_required_when_other_provided": "Vyžadováno, pokud je zadáno jakékoli nastavení SMTP",
"smtp_field_required_when_email_enabled": "Vyžadováno, pokud jsou povolena e-mailová oznámení",
"renew": "Obnovit",
"renew_api_key": "Obnovit klíč API",
"renew_api_key_description": "Obnovením klíče API se vygeneruje nový klíč. Nezapomeňte aktualizovat všechny integrace, které tento klíč používají.",
"api_key_renewed": "API klíč obnoven",
"app_config_home_page": "Domovská stránka",
"app_config_home_page_description": "Stránka, na kterou jsou uživatelé přesměrováni po přihlášení.",
"email_verification_warning": "Ověřte svou e-mailovou adresu",
"email_verification_warning_description": "Vaše e-mailová adresa ještě nebyla ověřena. Ověřte ji prosím co nejdříve.",
"email_verification": "Ověření e-mailu",
"email_verification_description": "Po odeslání registrace nebo změně e-mailové adresy zašlete uživatelům ověřovací e-mail.",
"email_verification_success_title": "E-mail byl úspěšně ověřen",
"email_verification_success_description": "Vaše e-mailová adresa byla úspěšně ověřena.",
"email_verification_error_title": "Ověření e-mailu se nezdařilo",
"mark_as_unverified": "Označit jako neověřené",
"mark_as_verified": "Označit jako ověřené",
"email_verification_sent": "Ověřovací e-mail byl úspěšně odeslán.",
"emails_verified_by_default": "E-maily ověřené ve výchozím nastavení",
"emails_verified_by_default_description": "Pokud je tato funkce povolena, budou e-mailové adresy uživatelů při registraci nebo při změně e-mailové adresy automaticky označeny jako ověřené."
}

Some files were not shown because too many files have changed in this diff Show More