mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-28 10:16:37 +00:00
Compare commits
21 Commits
v2.0.2
...
feat/email
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d40d30d87 | ||
|
|
d318b02ea0 | ||
|
|
dd8e4dec6c | ||
|
|
ca4e332964 | ||
|
|
20ee00df49 | ||
|
|
0a49c8b699 | ||
|
|
7d71191902 | ||
|
|
811e8772b6 | ||
|
|
0a94f0fd64 | ||
|
|
03f9be0d12 | ||
|
|
2f25861d15 | ||
|
|
2af70d9b4d | ||
|
|
5828fa5779 | ||
|
|
1a032a812e | ||
|
|
8c68b08c12 | ||
|
|
646f849441 | ||
|
|
20bbd4a06f | ||
|
|
2d7e2ec8df | ||
|
|
72009ced67 | ||
|
|
4881130ead | ||
|
|
d6a7b503ff |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,3 +1,20 @@
|
|||||||
|
## 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
|
## v2.0.2
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -48,8 +48,13 @@ func Bootstrap(ctx context.Context) error {
|
|||||||
return fmt.Errorf("failed to initialize application images: %w", err)
|
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
|
// Create all services
|
||||||
svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage)
|
svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage, scheduler)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize services: %w", err)
|
return fmt.Errorf("failed to initialize services: %w", err)
|
||||||
}
|
}
|
||||||
@@ -74,11 +79,7 @@ func Bootstrap(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
shutdownFns = append(shutdownFns, shutdownFn)
|
shutdownFns = append(shutdownFns, shutdownFn)
|
||||||
|
|
||||||
// Init the job scheduler
|
// Register scheduled jobs
|
||||||
scheduler, err := job.NewScheduler()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create job scheduler: %w", err)
|
|
||||||
}
|
|
||||||
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
|
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to register scheduled jobs: %w", err)
|
return fmt.Errorf("failed to register scheduled jobs: %w", err)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
|
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
|
||||||
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
|
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
|
||||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
|
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.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
||||||
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||||
@@ -84,6 +84,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||||
controller.NewVersionController(apiGroup, svc.versionService)
|
controller.NewVersionController(apiGroup, svc.versionService)
|
||||||
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
|
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
|
||||||
|
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
|
||||||
|
|
||||||
// Add test controller in non-production environments
|
// Add test controller in non-production environments
|
||||||
if !common.EnvConfig.AppEnv.IsProduction() {
|
if !common.EnvConfig.AppEnv.IsProduction() {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, http
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to register analytics job in scheduler: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
@@ -20,7 +21,6 @@ type services struct {
|
|||||||
jwtService *service.JwtService
|
jwtService *service.JwtService
|
||||||
webauthnService *service.WebAuthnService
|
webauthnService *service.WebAuthnService
|
||||||
scimService *service.ScimService
|
scimService *service.ScimService
|
||||||
scimSchedulerService *service.ScimSchedulerService
|
|
||||||
userService *service.UserService
|
userService *service.UserService
|
||||||
customClaimService *service.CustomClaimService
|
customClaimService *service.CustomClaimService
|
||||||
oidcService *service.OidcService
|
oidcService *service.OidcService
|
||||||
@@ -30,10 +30,12 @@ type services struct {
|
|||||||
versionService *service.VersionService
|
versionService *service.VersionService
|
||||||
fileStorage storage.FileStorage
|
fileStorage storage.FileStorage
|
||||||
appLockService *service.AppLockService
|
appLockService *service.AppLockService
|
||||||
|
userSignUpService *service.UserSignUpService
|
||||||
|
oneTimeAccessService *service.OneTimeAccessService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initializes all services
|
// 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 = &services{}
|
||||||
|
|
||||||
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
|
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.geoLiteService = service.NewGeoLiteService(httpClient)
|
||||||
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create JWT service: %w", err)
|
return nil, fmt.Errorf("failed to create JWT service: %w", err)
|
||||||
}
|
}
|
||||||
@@ -63,20 +65,19 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
|||||||
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
|
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, fileStorage)
|
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.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
|
||||||
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
||||||
svc.scimService = service.NewScimService(db, httpClient)
|
svc.userSignUpService = service.NewUserSignupService(db, svc.jwtService, svc.auditLogService, svc.appConfigService, svc.userService)
|
||||||
svc.scimSchedulerService, err = service.NewScimSchedulerService(ctx, svc.scimService)
|
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create SCIM scheduler service: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
svc.versionService = service.NewVersionService(httpClient)
|
svc.versionService = service.NewVersionService(httpClient)
|
||||||
|
|
||||||
|
|||||||
187
backend/internal/cmds/encryption_key_rotate.go
Normal file
187
backend/internal/cmds/encryption_key_rotate.go
Normal 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
|
||||||
|
}
|
||||||
89
backend/internal/cmds/encryption_key_rotate_test.go
Normal file
89
backend/internal/cmds/encryption_key_rotate_test.go
Normal 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))
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save the key
|
// Save the key
|
||||||
err = keyProvider.SaveKey(key)
|
err = keyProvider.SaveKey(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to store new key: %w", err)
|
return fmt.Errorf("failed to store new key: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantEr
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify key was created
|
// Verify key was created
|
||||||
key, err := keyProvider.LoadKey()
|
key, err := keyProvider.LoadKey(t.Context())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, key)
|
require.NotNil(t, key)
|
||||||
|
|
||||||
|
|||||||
@@ -70,10 +70,11 @@ type EnvConfigSchema struct {
|
|||||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||||
|
|
||||||
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
|
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
|
||||||
MetricsEnabled bool `env:"METRICS_ENABLED"`
|
MetricsEnabled bool `env:"METRICS_ENABLED"`
|
||||||
TracingEnabled bool `env:"TRACING_ENABLED"`
|
TracingEnabled bool `env:"TRACING_ENABLED"`
|
||||||
LogJSON bool `env:"LOG_JSON"`
|
LogJSON bool `env:"LOG_JSON"`
|
||||||
|
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var EnvConfig = defaultConfig()
|
var EnvConfig = defaultConfig()
|
||||||
|
|||||||
@@ -266,6 +266,13 @@ func (e *APIKeyNotFoundError) Error() string {
|
|||||||
}
|
}
|
||||||
func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
|
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{}
|
type APIKeyExpirationDateError struct{}
|
||||||
|
|
||||||
func (e *APIKeyExpirationDateError) Error() string {
|
func (e *APIKeyExpirationDateError) Error() string {
|
||||||
@@ -405,3 +412,13 @@ func (e *ImageNotFoundError) Error() string {
|
|||||||
func (e *ImageNotFoundError) HttpStatusCode() int {
|
func (e *ImageNotFoundError) HttpStatusCode() int {
|
||||||
return http.StatusNotFound
|
return http.StatusNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InvalidEmailVerificationTokenError struct{}
|
||||||
|
|
||||||
|
func (e *InvalidEmailVerificationTokenError) Error() string {
|
||||||
|
return "Invalid email verification token"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int {
|
||||||
|
return http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
|
|||||||
{
|
{
|
||||||
apiKeyGroup.GET("", uc.listApiKeysHandler)
|
apiKeyGroup.GET("", uc.listApiKeysHandler)
|
||||||
apiKeyGroup.POST("", uc.createApiKeyHandler)
|
apiKeyGroup.POST("", uc.createApiKeyHandler)
|
||||||
|
apiKeyGroup.POST("/:id/renew", uc.renewApiKeyHandler)
|
||||||
apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
|
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
|
// revokeApiKeyHandler godoc
|
||||||
// @Summary Revoke API key
|
// @Summary Revoke API key
|
||||||
// @Description Revoke (delete) an existing API key by ID
|
// @Description Revoke (delete) an existing API key by ID
|
||||||
|
|||||||
@@ -14,19 +14,17 @@ import (
|
|||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
||||||
defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
|
||||||
defaultSignupTokenDuration = time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewUserController creates a new controller for user management endpoints
|
// NewUserController creates a new controller for user management endpoints
|
||||||
// @Summary User management controller
|
// @Summary User management controller
|
||||||
// @Description Initializes all user-related API endpoints
|
// @Description Initializes all user-related API endpoints
|
||||||
// @Tags Users
|
// @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{
|
uc := UserController{
|
||||||
userService: userService,
|
userService: userService,
|
||||||
appConfigService: appConfigService,
|
oneTimeAccessService: oneTimeAccessService,
|
||||||
|
appConfigService: appConfigService,
|
||||||
}
|
}
|
||||||
|
|
||||||
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
|
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/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
||||||
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
||||||
|
|
||||||
group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler)
|
group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler)
|
||||||
group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler)
|
group.POST("/users/me/verify-email", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler)
|
||||||
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)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserController struct {
|
type UserController struct {
|
||||||
userService *service.UserService
|
userService *service.UserService
|
||||||
appConfigService *service.AppConfigService
|
oneTimeAccessService *service.OneTimeAccessService
|
||||||
|
appConfigService *service.AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUserGroupsHandler godoc
|
// getUserGroupsHandler godoc
|
||||||
@@ -342,7 +337,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
|
|||||||
ttl = defaultOneTimeAccessTokenDuration
|
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 {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -391,7 +386,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -424,7 +419,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
|
|||||||
if ttl <= 0 {
|
if ttl <= 0 {
|
||||||
ttl = defaultOneTimeAccessTokenDuration
|
ttl = defaultOneTimeAccessTokenDuration
|
||||||
}
|
}
|
||||||
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
|
err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -442,41 +437,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
|
|||||||
// @Router /api/one-time-access-token/{token} [post]
|
// @Router /api/one-time-access-token/{token} [post]
|
||||||
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||||
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
|
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
|
||||||
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent())
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -524,130 +485,6 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, userDto)
|
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
|
// updateUser is an internal helper method, not exposed as an API endpoint
|
||||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||||
var input dto.UserCreateDto
|
var input dto.UserCreateDto
|
||||||
@@ -714,3 +551,44 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context)
|
|||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
198
backend/internal/controller/user_signup_controller.go
Normal file
198
backend/internal/controller/user_signup_controller.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ type ApiKeyCreateDto struct {
|
|||||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiKeyRenewDto struct {
|
||||||
|
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type ApiKeyDto struct {
|
type ApiKeyDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type AppConfigVariableDto struct {
|
|||||||
type AppConfigUpdateDto struct {
|
type AppConfigUpdateDto struct {
|
||||||
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
|
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
|
||||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||||
|
HomePageURL string `json:"homePageUrl" binding:"required"`
|
||||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||||
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||||
@@ -53,4 +54,5 @@ type AppConfigUpdateDto struct {
|
|||||||
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
|
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
|
||||||
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
|
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
|
||||||
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
|
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
|
||||||
|
EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ type OidcDeviceAuthorizationRequestDto struct {
|
|||||||
ClientSecret string `form:"client_secret"`
|
ClientSecret string `form:"client_secret"`
|
||||||
ClientAssertion string `form:"client_assertion"`
|
ClientAssertion string `form:"client_assertion"`
|
||||||
ClientAssertionType string `form:"client_assertion_type"`
|
ClientAssertionType string `form:"client_assertion_type"`
|
||||||
|
Nonce string `form:"nonce"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcDeviceAuthorizationResponseDto struct {
|
type OidcDeviceAuthorizationResponseDto struct {
|
||||||
|
|||||||
17
backend/internal/dto/one_time_access_dto.go
Normal file
17
backend/internal/dto/one_time_access_dto.go
Normal 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"`
|
||||||
|
}
|
||||||
9
backend/internal/dto/signup_dto.go
Normal file
9
backend/internal/dto/signup_dto.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -4,35 +4,36 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin/binding"
|
"github.com/gin-gonic/gin/binding"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserDto struct {
|
type UserDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email *string `json:"email" `
|
Email *string `json:"email"`
|
||||||
FirstName string `json:"firstName"`
|
EmailVerified bool `json:"emailVerified"`
|
||||||
LastName *string `json:"lastName"`
|
FirstName string `json:"firstName"`
|
||||||
DisplayName string `json:"displayName"`
|
LastName *string `json:"lastName"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
DisplayName string `json:"displayName"`
|
||||||
Locale *string `json:"locale"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
Locale *string `json:"locale"`
|
||||||
UserGroups []UserGroupMinimalDto `json:"userGroups"`
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
LdapID *string `json:"ldapId"`
|
UserGroups []UserGroupMinimalDto `json:"userGroups"`
|
||||||
Disabled bool `json:"disabled"`
|
LdapID *string `json:"ldapId"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserCreateDto struct {
|
type UserCreateDto struct {
|
||||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
EmailVerified bool `json:"emailVerified"`
|
||||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||||
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
|
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
|
||||||
Locale *string `json:"locale"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Disabled bool `json:"disabled"`
|
Locale *string `json:"locale"`
|
||||||
UserGroupIds []string `json:"userGroupIds"`
|
Disabled bool `json:"disabled"`
|
||||||
LdapID string `json:"-"`
|
UserGroupIds []string `json:"userGroupIds"`
|
||||||
|
LdapID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u UserCreateDto) Validate() error {
|
func (u UserCreateDto) Validate() error {
|
||||||
@@ -46,28 +47,10 @@ func (u UserCreateDto) Validate() error {
|
|||||||
return e.Struct(u)
|
return e.Struct(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessTokenCreateDto struct {
|
type EmailVerificationDto struct {
|
||||||
UserID string `json:"userId"`
|
Token string `json:"token" binding:"required"`
|
||||||
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 UserUpdateUserGroupDto struct {
|
type UserUpdateUserGroupDto struct {
|
||||||
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
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"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service
|
|||||||
appConfig: appConfig,
|
appConfig: appConfig,
|
||||||
httpClient: httpClient,
|
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 {
|
type AnalyticsJob struct {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send every day at midnight
|
// 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 {
|
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
|
||||||
|
|||||||
@@ -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
|
// 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)
|
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
|
||||||
return errors.Join(
|
return errors.Join(
|
||||||
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
s.RegisterJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
||||||
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
||||||
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
||||||
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
s.RegisterJob(ctx, "ClearEmailVerificationTokens", def, jobs.clearEmailVerificationTokens, true),
|
||||||
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||||
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
|
s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||||
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, 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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ import (
|
|||||||
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
|
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
|
||||||
jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage}
|
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
|
// Only necessary for file system storage
|
||||||
if fileStorage.Type() == storage.TypeFileSystem {
|
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
|
return err
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
|
|||||||
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
|
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
|
||||||
|
|
||||||
// Run every 24 hours (and right away)
|
// 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 {
|
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
|
|||||||
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
||||||
|
|
||||||
// Register the job to run every hour
|
// 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 {
|
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package job
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
@@ -24,6 +25,26 @@ func NewScheduler() (*Scheduler, error) {
|
|||||||
}, nil
|
}, 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.
|
// Run the scheduler.
|
||||||
// This function blocks until the context is canceled.
|
// This function blocks until the context is canceled.
|
||||||
func (s *Scheduler) Run(ctx context.Context) error {
|
func (s *Scheduler) Run(ctx context.Context) error {
|
||||||
@@ -43,9 +64,10 @@ func (s *Scheduler) Run(ctx context.Context) error {
|
|||||||
return nil
|
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{
|
jobOptions := []gocron.JobOption{
|
||||||
gocron.WithContext(ctx),
|
gocron.WithContext(ctx),
|
||||||
|
gocron.WithName(name),
|
||||||
gocron.WithEventListeners(
|
gocron.WithEventListeners(
|
||||||
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
|
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
|
||||||
slog.Info("Starting job",
|
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, gocron.JobOption(gocron.WithStartImmediately()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jobOptions = append(jobOptions, extraOptions...)
|
||||||
|
|
||||||
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
|
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
25
backend/internal/job/scim_job.go
Normal file
25
backend/internal/job/scim_job.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
|
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
|
||||||
|
if common.EnvConfig.DisableRateLimiting == true {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Map to store the rate limiters per IP
|
// Map to store the rate limiters per IP
|
||||||
var clients = make(map[string]*client)
|
var clients = make(map[string]*client)
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type AppConfig struct {
|
|||||||
// General
|
// General
|
||||||
AppName AppConfigVariable `key:"appName,public"` // Public
|
AppName AppConfigVariable `key:"appName,public"` // Public
|
||||||
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
||||||
|
HomePageURL AppConfigVariable `key:"homePageUrl,public"` // Public
|
||||||
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
||||||
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||||
@@ -58,6 +59,7 @@ type AppConfig struct {
|
|||||||
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
|
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
|
||||||
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
|
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
|
||||||
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
|
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
|
||||||
|
EmailVerificationEnabled AppConfigVariable `key:"emailVerificationEnabled,public"` // Public
|
||||||
// LDAP
|
// LDAP
|
||||||
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
||||||
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
||||||
|
|||||||
13
backend/internal/model/email_verification_token.go
Normal file
13
backend/internal/model/email_verification_token.go
Normal 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
|
||||||
|
}
|
||||||
@@ -144,6 +144,7 @@ type OidcDeviceCode struct {
|
|||||||
DeviceCode string
|
DeviceCode string
|
||||||
UserCode string
|
UserCode string
|
||||||
Scope string
|
Scope string
|
||||||
|
Nonce string
|
||||||
ExpiresAt datatype.DateTime
|
ExpiresAt datatype.DateTime
|
||||||
IsAuthorized bool
|
IsAuthorized bool
|
||||||
|
|
||||||
|
|||||||
13
backend/internal/model/one_time_access_token.go
Normal file
13
backend/internal/model/one_time_access_token.go
Normal 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
|
||||||
|
}
|
||||||
@@ -40,14 +40,9 @@ func (e *EncryptedString) Scan(value any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
encBytes, err := base64.StdEncoding.DecodeString(raw)
|
decBytes, err := DecryptEncryptedStringWithKey(encStringKey, raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to decode encrypted string: %w", err)
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
decBytes, err := cryptoutils.Decrypt(encStringKey, encBytes, []byte(encryptedStringAAD))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to decrypt encrypted string: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*e = EncryptedString(decBytes)
|
*e = EncryptedString(decBytes)
|
||||||
@@ -59,19 +54,20 @@ func (e EncryptedString) Value() (driver.Value, error) {
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
encBytes, err := cryptoutils.Encrypt(encStringKey, []byte(e), []byte(encryptedStringAAD))
|
encValue, err := EncryptEncryptedStringWithKey(encStringKey, []byte(e))
|
||||||
if err != nil {
|
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 {
|
func (e EncryptedString) String() string {
|
||||||
return string(e)
|
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"
|
const info = "pocketid/encrypted_string"
|
||||||
r := hkdf.New(sha256.New, master, nil, []byte(info))
|
r := hkdf.New(sha256.New, master, nil, []byte(info))
|
||||||
|
|
||||||
@@ -82,8 +78,33 @@ func deriveEncryptedStringKey(master []byte) ([]byte, error) {
|
|||||||
return key, nil
|
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() {
|
func init() {
|
||||||
key, err := deriveEncryptedStringKey(common.EnvConfig.EncryptionKey)
|
key, err := DeriveEncryptedStringKey(common.EnvConfig.EncryptionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("failed to derive encrypted string key: %v", err))
|
panic(fmt.Sprintf("failed to derive encrypted string key: %v", err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,16 +14,17 @@ import (
|
|||||||
type User struct {
|
type User struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Username string `sortable:"true"`
|
Username string `sortable:"true"`
|
||||||
Email *string `sortable:"true"`
|
Email *string `sortable:"true"`
|
||||||
FirstName string `sortable:"true"`
|
EmailVerified bool `sortable:"true" filterable:"true"`
|
||||||
LastName string `sortable:"true"`
|
FirstName string `sortable:"true"`
|
||||||
DisplayName string `sortable:"true"`
|
LastName string `sortable:"true"`
|
||||||
IsAdmin bool `sortable:"true" filterable:"true"`
|
DisplayName string `sortable:"true"`
|
||||||
Locale *string
|
IsAdmin bool `sortable:"true" filterable:"true"`
|
||||||
LdapID *string
|
Locale *string
|
||||||
Disabled bool `sortable:"true" filterable:"true"`
|
LdapID *string
|
||||||
UpdatedAt *datatype.DateTime
|
Disabled bool `sortable:"true" filterable:"true"`
|
||||||
|
UpdatedAt *datatype.DateTime
|
||||||
|
|
||||||
CustomClaims []CustomClaim
|
CustomClaims []CustomClaim
|
||||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||||
@@ -93,13 +94,3 @@ func (u User) LastModified() time.Time {
|
|||||||
}
|
}
|
||||||
return u.CreatedAt.ToTime()
|
return u.CreatedAt.ToTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessToken struct {
|
|
||||||
Base
|
|
||||||
Token string
|
|
||||||
DeviceToken *string
|
|
||||||
ExpiresAt datatype.DateTime
|
|
||||||
|
|
||||||
UserID string
|
|
||||||
User User
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -72,6 +72,56 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
|
|||||||
return apiKey, token, nil
|
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 {
|
func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
|
||||||
var apiKey model.ApiKey
|
var apiKey model.ApiKey
|
||||||
err := s.db.
|
err := s.db.
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
|||||||
// General
|
// General
|
||||||
AppName: model.AppConfigVariable{Value: "Pocket ID"},
|
AppName: model.AppConfigVariable{Value: "Pocket ID"},
|
||||||
SessionDuration: model.AppConfigVariable{Value: "60"},
|
SessionDuration: model.AppConfigVariable{Value: "60"},
|
||||||
|
HomePageURL: model.AppConfigVariable{Value: "/settings/account"},
|
||||||
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
||||||
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
||||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||||
@@ -83,6 +84,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
|||||||
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
|
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
|
||||||
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
|
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
|
||||||
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
|
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
|
||||||
|
EmailVerificationEnabled: model.AppConfigVariable{Value: "false"},
|
||||||
// LDAP
|
// LDAP
|
||||||
LdapEnabled: model.AppConfigVariable{Value: "false"},
|
LdapEnabled: model.AppConfigVariable{Value: "false"},
|
||||||
LdapUrl: model.AppConfigVariable{},
|
LdapUrl: model.AppConfigVariable{},
|
||||||
|
|||||||
@@ -80,23 +80,25 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||||
},
|
},
|
||||||
Username: "tim",
|
Username: "tim",
|
||||||
Email: utils.Ptr("tim.cook@test.com"),
|
Email: utils.Ptr("tim.cook@test.com"),
|
||||||
FirstName: "Tim",
|
EmailVerified: true,
|
||||||
LastName: "Cook",
|
FirstName: "Tim",
|
||||||
DisplayName: "Tim Cook",
|
LastName: "Cook",
|
||||||
IsAdmin: true,
|
DisplayName: "Tim Cook",
|
||||||
|
IsAdmin: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||||
},
|
},
|
||||||
Username: "craig",
|
Username: "craig",
|
||||||
Email: utils.Ptr("craig.federighi@test.com"),
|
Email: utils.Ptr("craig.federighi@test.com"),
|
||||||
FirstName: "Craig",
|
EmailVerified: false,
|
||||||
LastName: "Federighi",
|
FirstName: "Craig",
|
||||||
DisplayName: "Craig Federighi",
|
LastName: "Federighi",
|
||||||
IsAdmin: false,
|
DisplayName: "Craig Federighi",
|
||||||
|
IsAdmin: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
@@ -354,17 +356,30 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey := model.ApiKey{
|
apiKeys := []model.ApiKey{
|
||||||
Base: model.Base{
|
{
|
||||||
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
|
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 {
|
for _, apiKey := range apiKeys {
|
||||||
return err
|
if err := tx.Create(&apiKey).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signupTokens := []model.SignupToken{
|
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{
|
keyValues := []model.KV{
|
||||||
{
|
{
|
||||||
Key: jwkutils.PrivateKeyDBKey,
|
Key: jwkutils.PrivateKeyDBKey,
|
||||||
@@ -526,7 +566,7 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reload the JWK
|
// Reload the JWK
|
||||||
if err := s.jwtService.LoadOrGenerateKey(); err != nil {
|
if err := s.jwtService.LoadOrGenerateKey(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
type NewLoginTemplateData struct {
|
||||||
IPAddress string
|
IPAddress string
|
||||||
Country string
|
Country string
|
||||||
@@ -70,5 +77,10 @@ type ApiKeyExpiringSoonTemplateData struct {
|
|||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EmailVerificationTemplateData struct {
|
||||||
|
UserFullName string
|
||||||
|
VerificationLink string
|
||||||
|
}
|
||||||
|
|
||||||
// this is list of all template paths used for preloading templates
|
// 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}
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ type JwtService struct {
|
|||||||
jwksEncoded []byte
|
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{}
|
service := &JwtService{}
|
||||||
|
|
||||||
err := service.init(db, appConfigService, &common.EnvConfig)
|
err := service.init(ctx, db, appConfigService, &common.EnvConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -67,16 +67,16 @@ func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService
|
|||||||
return service, nil
|
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.appConfigService = appConfigService
|
||||||
s.envConfig = envConfig
|
s.envConfig = envConfig
|
||||||
s.db = db
|
s.db = db
|
||||||
|
|
||||||
// Ensure keys are generated or loaded
|
// 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
|
// Get the key provider
|
||||||
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
|
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,7 +84,7 @@ func (s *JwtService) LoadOrGenerateKey() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try loading a key
|
// Try loading a key
|
||||||
key, err := keyProvider.LoadKey()
|
key, err := keyProvider.LoadKey(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load key: %w", err)
|
return fmt.Errorf("failed to load key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ func (s *JwtService) LoadOrGenerateKey() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save the newly-generated key
|
// Save the newly-generated key
|
||||||
err = keyProvider.SaveKey(s.privateKey)
|
err = keyProvider.SaveKey(ctx, s.privateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to save private key: %w", err)
|
return fmt.Errorf("failed to save private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func initJwtService(t *testing.T, db *gorm.DB, appConfig *AppConfigService, envC
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
service := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
return 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)
|
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfig.GetDbConfig().InstanceID.Value)
|
||||||
require.NoError(t, err, "Failed to init key provider")
|
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")
|
require.NoError(t, err, "Failed to save key")
|
||||||
|
|
||||||
kid, ok := key.KeyID()
|
kid, ok := key.KeyID()
|
||||||
@@ -93,7 +93,7 @@ func TestJwtService_Init(t *testing.T) {
|
|||||||
// Verify the key has been persisted in the database
|
// Verify the key has been persisted in the database
|
||||||
keyProvider, err := jwkutils.GetKeyProvider(db, mockEnvConfig, mockConfig.GetDbConfig().InstanceID.Value)
|
keyProvider, err := jwkutils.GetKeyProvider(db, mockEnvConfig, mockConfig.GetDbConfig().InstanceID.Value)
|
||||||
require.NoError(t, err, "Failed to init key provider")
|
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.NoError(t, err, "Failed to load key from provider")
|
||||||
require.NotNil(t, key, "Key should be present in the database")
|
require.NotNil(t, key, "Key should be present in the database")
|
||||||
|
|
||||||
|
|||||||
@@ -378,13 +378,14 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
}
|
}
|
||||||
|
|
||||||
newUser := dto.UserCreateDto{
|
newUser := dto.UserCreateDto{
|
||||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||||
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
|
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
|
||||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
EmailVerified: true,
|
||||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||||
IsAdmin: isAdmin,
|
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||||
LdapID: ldapId,
|
IsAdmin: isAdmin,
|
||||||
|
LdapID: ldapId,
|
||||||
}
|
}
|
||||||
|
|
||||||
if newUser.DisplayName == "" {
|
if newUser.DisplayName == "" {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type OidcService struct {
|
|||||||
auditLogService *AuditLogService
|
auditLogService *AuditLogService
|
||||||
customClaimService *CustomClaimService
|
customClaimService *CustomClaimService
|
||||||
webAuthnService *WebAuthnService
|
webAuthnService *WebAuthnService
|
||||||
|
scimService *ScimService
|
||||||
|
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
jwkCache *jwk.Cache
|
jwkCache *jwk.Cache
|
||||||
@@ -70,6 +71,7 @@ func NewOidcService(
|
|||||||
auditLogService *AuditLogService,
|
auditLogService *AuditLogService,
|
||||||
customClaimService *CustomClaimService,
|
customClaimService *CustomClaimService,
|
||||||
webAuthnService *WebAuthnService,
|
webAuthnService *WebAuthnService,
|
||||||
|
scimService *ScimService,
|
||||||
httpClient *http.Client,
|
httpClient *http.Client,
|
||||||
fileStorage storage.FileStorage,
|
fileStorage storage.FileStorage,
|
||||||
) (s *OidcService, err error) {
|
) (s *OidcService, err error) {
|
||||||
@@ -80,6 +82,7 @@ func NewOidcService(
|
|||||||
auditLogService: auditLogService,
|
auditLogService: auditLogService,
|
||||||
customClaimService: customClaimService,
|
customClaimService: customClaimService,
|
||||||
webAuthnService: webAuthnService,
|
webAuthnService: webAuthnService,
|
||||||
|
scimService: scimService,
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
fileStorage: fileStorage,
|
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
|
// 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 {
|
if err != nil {
|
||||||
return CreatedTokens{}, err
|
return CreatedTokens{}, err
|
||||||
}
|
}
|
||||||
@@ -1088,6 +1091,7 @@ func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, in
|
|||||||
return model.OidcClient{}, err
|
return model.OidcClient{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1278,6 +1282,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
|
|||||||
ExpiresAt: datatype.DateTime(time.Now().Add(DeviceCodeDuration)),
|
ExpiresAt: datatype.DateTime(time.Now().Add(DeviceCodeDuration)),
|
||||||
IsAuthorized: false,
|
IsAuthorized: false,
|
||||||
ClientID: client.ID,
|
ClientID: client.ID,
|
||||||
|
Nonce: input.Nonce,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.Create(deviceAuth).Error; err != nil {
|
if err := s.db.Create(deviceAuth).Error; err != nil {
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||||
})
|
})
|
||||||
mockJwtService, err := NewJwtService(db, mockConfig)
|
mockJwtService, err := NewJwtService(t.Context(), db, mockConfig)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create a mock HTTP client with custom transport to return the JWKS
|
// Create a mock HTTP client with custom transport to return the JWKS
|
||||||
|
|||||||
229
backend/internal/service/one_time_access_service.go
Normal file
229
backend/internal/service/one_time_access_service.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
@@ -32,6 +33,11 @@ const scimErrorBodyLimit = 4096
|
|||||||
|
|
||||||
type scimSyncAction int
|
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 (
|
const (
|
||||||
scimActionNone scimSyncAction = iota
|
scimActionNone scimSyncAction = iota
|
||||||
scimActionCreated
|
scimActionCreated
|
||||||
@@ -48,15 +54,16 @@ type scimSyncStats struct {
|
|||||||
// ScimService handles SCIM provisioning to external service providers.
|
// ScimService handles SCIM provisioning to external service providers.
|
||||||
type ScimService struct {
|
type ScimService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
scheduler Scheduler
|
||||||
httpClient *http.Client
|
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 {
|
if httpClient == nil {
|
||||||
httpClient = &http.Client{Timeout: 20 * time.Second}
|
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(
|
func (s *ScimService) GetServiceProvider(
|
||||||
@@ -132,6 +139,41 @@ func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProvider
|
|||||||
Error
|
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 {
|
func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID string) error {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
provider, err := s.GetServiceProvider(ctx, serviceProviderID)
|
provider, err := s.GetServiceProvider(ctx, serviceProviderID)
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ import (
|
|||||||
|
|
||||||
type UserGroupService struct {
|
type UserGroupService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
scimService *ScimService
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService) *UserGroupService {
|
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService {
|
||||||
return &UserGroupService{db: db, appConfigService: appConfigService}
|
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) {
|
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 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) {
|
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
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
return group, nil
|
return group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +174,8 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
|
|||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
return group, nil
|
return group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +238,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
|
|||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
return group, nil
|
return group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,5 +315,6 @@ func (s *UserGroupService) UpdateAllowedOidcClient(ctx context.Context, id strin
|
|||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
return group, nil
|
return group, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,11 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/url"
|
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
@@ -25,7 +23,6 @@ import (
|
|||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
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/storage"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"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"
|
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,10 +34,11 @@ type UserService struct {
|
|||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
customClaimService *CustomClaimService
|
customClaimService *CustomClaimService
|
||||||
appImagesService *AppImagesService
|
appImagesService *AppImagesService
|
||||||
|
scimService *ScimService
|
||||||
fileStorage storage.FileStorage
|
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{
|
return &UserService{
|
||||||
db: db,
|
db: db,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
@@ -49,6 +47,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
|
|||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
customClaimService: customClaimService,
|
customClaimService: customClaimService,
|
||||||
appImagesService: appImagesService,
|
appImagesService: appImagesService,
|
||||||
|
scimService: scimService,
|
||||||
fileStorage: fileStorage,
|
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)
|
return fmt.Errorf("failed to delete user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,15 +266,16 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
|||||||
}
|
}
|
||||||
|
|
||||||
user := model.User{
|
user := model.User{
|
||||||
FirstName: input.FirstName,
|
FirstName: input.FirstName,
|
||||||
LastName: input.LastName,
|
LastName: input.LastName,
|
||||||
DisplayName: input.DisplayName,
|
DisplayName: input.DisplayName,
|
||||||
Email: input.Email,
|
Email: input.Email,
|
||||||
Username: input.Username,
|
EmailVerified: input.EmailVerified,
|
||||||
IsAdmin: input.IsAdmin,
|
Username: input.Username,
|
||||||
Locale: input.Locale,
|
IsAdmin: input.IsAdmin,
|
||||||
Disabled: input.Disabled,
|
Locale: input.Locale,
|
||||||
UserGroups: userGroups,
|
Disabled: input.Disabled,
|
||||||
|
UserGroups: userGroups,
|
||||||
}
|
}
|
||||||
if input.LdapID != "" {
|
if input.LdapID != "" {
|
||||||
user.LdapID = &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
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,13 +417,20 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
|||||||
user.FirstName = updatedUser.FirstName
|
user.FirstName = updatedUser.FirstName
|
||||||
user.LastName = updatedUser.LastName
|
user.LastName = updatedUser.LastName
|
||||||
user.DisplayName = updatedUser.DisplayName
|
user.DisplayName = updatedUser.DisplayName
|
||||||
user.Email = updatedUser.Email
|
|
||||||
user.Username = updatedUser.Username
|
user.Username = updatedUser.Username
|
||||||
user.Locale = updatedUser.Locale
|
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
|
// Admin-only fields: Only allow updates when not updating own account
|
||||||
if !updateOwnUser {
|
if !updateOwnUser {
|
||||||
user.IsAdmin = updatedUser.IsAdmin
|
user.IsAdmin = updatedUser.IsAdmin
|
||||||
|
user.EmailVerified = updatedUser.EmailVerified
|
||||||
user.Disabled = updatedUser.Disabled
|
user.Disabled = updatedUser.Disabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,167 +456,10 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
|||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
return user, nil
|
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) {
|
func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroupIds []string) (user model.User, err error) {
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -663,50 +515,10 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
|
|||||||
return model.User{}, err
|
return model.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.scimService.ScheduleSync()
|
||||||
return user, nil
|
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 {
|
func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User, tx *gorm.DB) error {
|
||||||
var result struct {
|
var result struct {
|
||||||
Found bool
|
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 {
|
func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, userID string) error {
|
||||||
return tx.
|
err := tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Model(&model.User{}).
|
Model(&model.User{}).
|
||||||
Where("id = ?", userID).
|
Where("id = ?", userID).
|
||||||
Update("disabled", true).
|
Update("disabled", true).
|
||||||
Error
|
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) {
|
func (s *UserService) SendEmailVerification(ctx context.Context, userID string) error {
|
||||||
signupToken, err := NewSignupToken(ttl, usageLimit)
|
user, err := s.GetUser(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.SignupToken{}, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var userGroups []model.UserGroup
|
if user.Email == nil {
|
||||||
err = s.db.WithContext(ctx).
|
return &common.UserEmailNotSetError{}
|
||||||
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
|
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()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer tx.Rollback()
|
||||||
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 errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
if config.AllowUserSignups.Value != "open" && !tokenProvided {
|
return &common.InvalidEmailVerificationTokenError{}
|
||||||
return model.User{}, "", &common.OpenSignupDisabledError{}
|
} else if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var signupToken model.SignupToken
|
user, err := s.getUserInternal(ctx, emailVerificationToken.UserID, tx)
|
||||||
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)
|
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
return model.User{}, "", err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokenProvided {
|
err = tx.WithContext(ctx).Delete(&emailVerificationToken).Error
|
||||||
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 {
|
if err != nil {
|
||||||
return model.User{}, "", err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, accessToken, nil
|
return tx.Commit().Error
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
214
backend/internal/service/user_signup_service.go
Normal file
214
backend/internal/service/user_signup_service.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
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{}).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
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
var AccessTokenCookieName = "__Host-access_token"
|
var AccessTokenCookieName = "__Host-access_token"
|
||||||
var SessionIdCookieName = "__Host-session"
|
var SessionIdCookieName = "__Host-session"
|
||||||
var DeviceTokenCookieName = "__Host-device_token" //nolint:gosec
|
var DeviceTokenCookieName = "__Secure-device_token" //nolint:gosec
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {
|
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package jwk
|
package jwk
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||||
@@ -17,8 +18,8 @@ type KeyProviderOpts struct {
|
|||||||
|
|
||||||
type KeyProvider interface {
|
type KeyProvider interface {
|
||||||
Init(opts KeyProviderOpts) error
|
Init(opts KeyProviderOpts) error
|
||||||
LoadKey() (jwk.Key, error)
|
LoadKey(ctx context.Context) (jwk.Key, error)
|
||||||
SaveKey(key 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) {
|
func GetKeyProvider(db *gorm.DB, envConfig *common.EnvConfigSchema, instanceID string) (keyProvider KeyProvider, err error) {
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ func (f *KeyProviderDatabase) Init(opts KeyProviderOpts) error {
|
|||||||
return nil
|
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{
|
row := model.KV{
|
||||||
Key: PrivateKeyDBKey,
|
Key: PrivateKeyDBKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
err = f.db.WithContext(ctx).First(&row).Error
|
err = f.db.WithContext(ctx).First(&row).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -74,7 +74,7 @@ func (f *KeyProviderDatabase) LoadKey() (key jwk.Key, err error) {
|
|||||||
return key, nil
|
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
|
// Encode the key to JSON
|
||||||
data, err := EncodeJWKBytes(key)
|
data, err := EncodeJWKBytes(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -94,7 +94,7 @@ func (f *KeyProviderDatabase) SaveKey(key jwk.Key) error {
|
|||||||
Value: &encB64,
|
Value: &encB64,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
err = f.db.
|
err = f.db.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Load key when none exists
|
// Load key when none exists
|
||||||
loadedKey, err := provider.LoadKey()
|
loadedKey, err := provider.LoadKey(t.Context())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Nil(t, loadedKey, "Expected nil key when no key exists in database")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Save a key
|
// Save a key
|
||||||
err = provider.SaveKey(key)
|
err = provider.SaveKey(t.Context(), key)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Load the key
|
// Load the key
|
||||||
loadedKey, err := provider.LoadKey()
|
loadedKey, err := provider.LoadKey(t.Context())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotNil(t, loadedKey, "Expected non-nil key when key exists in database")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Attempt to load the key
|
// 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.Error(t, err, "Expected error when loading key with invalid base64")
|
||||||
require.ErrorContains(t, err, "not a valid base64-encoded value")
|
require.ErrorContains(t, err, "not a valid base64-encoded value")
|
||||||
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
|
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
|
||||||
@@ -140,7 +140,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Attempt to load the key
|
// 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.Error(t, err, "Expected error when loading key with invalid encrypted data")
|
||||||
require.ErrorContains(t, err, "failed to decrypt")
|
require.ErrorContains(t, err, "failed to decrypt")
|
||||||
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
|
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
|
||||||
@@ -158,7 +158,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = originalProvider.SaveKey(key)
|
err = originalProvider.SaveKey(t.Context(), key)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Now try to load with a different KEK
|
// Now try to load with a different KEK
|
||||||
@@ -171,7 +171,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Attempt to load the key with the wrong KEK
|
// 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.Error(t, err, "Expected error when loading key with wrong KEK")
|
||||||
require.ErrorContains(t, err, "failed to decrypt")
|
require.ErrorContains(t, err, "failed to decrypt")
|
||||||
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
|
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
|
||||||
@@ -206,7 +206,7 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Attempt to load the key
|
// 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.Error(t, err, "Expected error when loading invalid key data")
|
||||||
require.ErrorContains(t, err, "failed to parse")
|
require.ErrorContains(t, err, "failed to parse")
|
||||||
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
|
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
|
||||||
@@ -233,7 +233,7 @@ func TestKeyProviderDatabase_SaveKey(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Save the key
|
// Save the key
|
||||||
err = provider.SaveKey(key)
|
err = provider.SaveKey(t.Context(), key)
|
||||||
require.NoError(t, err, "Expected no error when saving key")
|
require.NoError(t, err, "Expected no error when saving key")
|
||||||
|
|
||||||
// Verify record exists in database
|
// Verify record exists in database
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -6,7 +6,6 @@ API KEY EXPIRING SOON
|
|||||||
Warning
|
Warning
|
||||||
|
|
||||||
Hello {{.Data.Name}},
|
Hello {{.Data.Name}},
|
||||||
This is a reminder that your API key {{.Data.APIKeyName}} will expire on
|
This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
|
||||||
{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
|
|
||||||
|
|
||||||
Please generate a new API key if you need continued access.{{end}}
|
Please generate a new API key if you need continued access.{{end}}
|
||||||
@@ -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>   </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>   ​</i><![endif]--></span></a></div></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -5,15 +5,13 @@ NEW SIGN-IN DETECTED
|
|||||||
|
|
||||||
Warning
|
Warning
|
||||||
|
|
||||||
Your {{.AppName}} account was recently accessed from a new IP address or
|
Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.
|
||||||
browser. If you recognize this activity, no further action is required.
|
|
||||||
|
|
||||||
DETAILS
|
DETAILS
|
||||||
|
|
||||||
Approximate Location
|
Approximate Location
|
||||||
|
|
||||||
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if
|
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
|
||||||
.Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
|
|
||||||
|
|
||||||
IP Address
|
IP Address
|
||||||
|
|
||||||
|
|||||||
@@ -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>   </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>   ​</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>   </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>   ​</i><![endif]--></span></a></div></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}
|
||||||
@@ -4,8 +4,7 @@
|
|||||||
YOUR LOGIN CODE
|
YOUR LOGIN CODE
|
||||||
|
|
||||||
Click the button below to sign in to {{.AppName}} with a 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
|
Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}}.
|
||||||
{{.Data.Code}}.
|
|
||||||
|
|
||||||
This code expires in {{.Data.ExpirationString}}.
|
This code expires in {{.Data.ExpirationString}}.
|
||||||
|
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE oidc_device_codes DROP COLUMN nonce;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE oidc_device_codes ADD COLUMN nonce VARCHAR(255);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- No-op on Postgres
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- No-op on Postgres
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE email_verification_tokens;
|
||||||
|
ALTER TABLE users DROP COLUMN email_verified;
|
||||||
@@ -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');
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE oidc_device_codes DROP COLUMN nonce;
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE oidc_device_codes ADD COLUMN nonce TEXT;
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- No-op
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
54
email-templates/emails/email-verification.tsx
Normal file
54
email-templates/emails/email-verification.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "Synchronizace SCIM byla úspěšně dokončena.",
|
"scim_sync_successful": "Synchronizace SCIM byla úspěšně dokončena.",
|
||||||
"save_and_sync": "Uložit a synchronizovat",
|
"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í?",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "SCIM-synkroniseringen er gennemført med succes.",
|
"scim_sync_successful": "SCIM-synkroniseringen er gennemført med succes.",
|
||||||
"save_and_sync": "Gem og synkroniser",
|
"save_and_sync": "Gem og synkroniser",
|
||||||
"scim_save_changes_description": "Du skal gemme ændringerne, før du starter en SCIM-synkronisering. Vil du gemme nu?",
|
"scim_save_changes_description": "Du skal gemme ændringerne, før du starter en SCIM-synkronisering. Vil du gemme nu?",
|
||||||
"scopes": "Omfang"
|
"scopes": "Omfang",
|
||||||
|
"issuer_url": "Udsteders URL"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "Die SCIM-Synchronisierung ist erfolgreich abgeschlossen worden.",
|
"scim_sync_successful": "Die SCIM-Synchronisierung ist erfolgreich abgeschlossen worden.",
|
||||||
"save_and_sync": "Speichern und synchronisieren",
|
"save_and_sync": "Speichern und synchronisieren",
|
||||||
"scim_save_changes_description": "Du musst die Änderungen speichern, bevor du eine SCIM-Synchronisierung startest. Willst du jetzt speichern?",
|
"scim_save_changes_description": "Du musst die Änderungen speichern, bevor du eine SCIM-Synchronisierung startest. Willst du jetzt speichern?",
|
||||||
"scopes": "Kopfsuchgeräte"
|
"scopes": "Kopfsuchgeräte",
|
||||||
|
"issuer_url": "Aussteller-URL"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,11 @@
|
|||||||
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys",
|
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys",
|
||||||
"passkey_was_previously_registered": "This passkey was previously registered",
|
"passkey_was_previously_registered": "This passkey was previously registered",
|
||||||
"authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms",
|
"authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms",
|
||||||
"authenticator_timed_out": "The authenticator timed out",
|
"webauthn_error_invalid_rp_id": "The configured relying party ID is invalid.",
|
||||||
|
"webauthn_error_invalid_domain": "The configured domain is invalid.",
|
||||||
|
"contact_administrator_to_fix": "Contact your administrator to fix this issue.",
|
||||||
|
"webauthn_operation_not_allowed_or_timed_out": "The operation was not allowed or timed out",
|
||||||
|
"webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.",
|
||||||
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
|
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
|
||||||
"sign_in_to": "Sign in to {name}",
|
"sign_in_to": "Sign in to {name}",
|
||||||
"client_not_found": "Client not found",
|
"client_not_found": "Client not found",
|
||||||
@@ -192,8 +196,6 @@
|
|||||||
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.",
|
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.",
|
||||||
"enable_self_account_editing": "Enable Self-Account Editing",
|
"enable_self_account_editing": "Enable Self-Account Editing",
|
||||||
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.",
|
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.",
|
||||||
"emails_verified": "Emails Verified",
|
|
||||||
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.",
|
|
||||||
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
|
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
|
||||||
"ldap_disabled_successfully": "LDAP disabled successfully",
|
"ldap_disabled_successfully": "LDAP disabled successfully",
|
||||||
"ldap_sync_finished": "LDAP sync finished",
|
"ldap_sync_finished": "LDAP sync finished",
|
||||||
@@ -498,5 +500,26 @@
|
|||||||
"scim_sync_successful": "The SCIM sync has been completed successfully.",
|
"scim_sync_successful": "The SCIM sync has been completed successfully.",
|
||||||
"save_and_sync": "Save and Sync",
|
"save_and_sync": "Save and Sync",
|
||||||
"scim_save_changes_description": "You have to save the changes before starting a SCIM sync. Do you want to save now?",
|
"scim_save_changes_description": "You have to save the changes before starting a SCIM sync. Do you want to save now?",
|
||||||
"scopes": "Scopes"
|
"scopes": "Scopes",
|
||||||
|
"issuer_url": "Issuer URL",
|
||||||
|
"smtp_field_required_when_other_provided": "Required when any SMTP setting is provided",
|
||||||
|
"smtp_field_required_when_email_enabled": "Required when email notifications are enabled",
|
||||||
|
"renew": "Renew",
|
||||||
|
"renew_api_key": "Renew API Key",
|
||||||
|
"renew_api_key_description": "Renewing the API key will generate a new key. Make sure to update any integrations using this key.",
|
||||||
|
"api_key_renewed": "API key renewed",
|
||||||
|
"app_config_home_page": "Home Page",
|
||||||
|
"app_config_home_page_description": "The page users are redirected to after signing in.",
|
||||||
|
"email_verification_warning": "Verify your email address",
|
||||||
|
"email_verification_warning_description": "Your email address is not verified yet. Please verify it as soon as possible.",
|
||||||
|
"email_verification": "Email Verification",
|
||||||
|
"email_verification_description": "Send a verification email to users when they sign up or change their email address.",
|
||||||
|
"email_verification_success_title": "Email Verified Successfully",
|
||||||
|
"email_verification_success_description": "Your email address has been verified successfully.",
|
||||||
|
"email_verification_error_title": "Email Verification Failed",
|
||||||
|
"mark_as_unverified": "Mark as unverified",
|
||||||
|
"mark_as_verified": "Mark as verified",
|
||||||
|
"email_verification_sent": "Verification email sent successfully.",
|
||||||
|
"emails_verified_by_default": "Emails verified by default",
|
||||||
|
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "La sincronización SCIM se ha completado correctamente.",
|
"scim_sync_successful": "La sincronización SCIM se ha completado correctamente.",
|
||||||
"save_and_sync": "Guardar y sincronizar",
|
"save_and_sync": "Guardar y sincronizar",
|
||||||
"scim_save_changes_description": "Debes guardar los cambios antes de iniciar una sincronización SCIM. ¿Deseas guardar ahora?",
|
"scim_save_changes_description": "Debes guardar los cambios antes de iniciar una sincronización SCIM. ¿Deseas guardar ahora?",
|
||||||
"scopes": "Ámbitos"
|
"scopes": "Ámbitos",
|
||||||
|
"issuer_url": "URL del emisor"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "SCIM-synkronointi on suoritettu onnistuneesti.",
|
"scim_sync_successful": "SCIM-synkronointi on suoritettu onnistuneesti.",
|
||||||
"save_and_sync": "Tallenna ja synkronoi",
|
"save_and_sync": "Tallenna ja synkronoi",
|
||||||
"scim_save_changes_description": "Sinun on tallennettava muutokset ennen SCIM-synkronoinnin aloittamista. Haluatko tallentaa nyt?",
|
"scim_save_changes_description": "Sinun on tallennettava muutokset ennen SCIM-synkronoinnin aloittamista. Haluatko tallentaa nyt?",
|
||||||
"scopes": "Käyttöalueet"
|
"scopes": "Käyttöalueet",
|
||||||
|
"issuer_url": "Julkaisijan URL-osoite"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "La synchronisation SCIM s'est bien passée.",
|
"scim_sync_successful": "La synchronisation SCIM s'est bien passée.",
|
||||||
"save_and_sync": "Enregistrer et synchroniser",
|
"save_and_sync": "Enregistrer et synchroniser",
|
||||||
"scim_save_changes_description": "Tu dois enregistrer les changements avant de lancer une synchronisation SCIM. Tu veux enregistrer maintenant ?",
|
"scim_save_changes_description": "Tu dois enregistrer les changements avant de lancer une synchronisation SCIM. Tu veux enregistrer maintenant ?",
|
||||||
"scopes": "Portées"
|
"scopes": "Portées",
|
||||||
|
"issuer_url": "URL de l'émetteur"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "La sincronizzazione SCIM è andata a buon fine.",
|
"scim_sync_successful": "La sincronizzazione SCIM è andata a buon fine.",
|
||||||
"save_and_sync": "Salva e sincronizza",
|
"save_and_sync": "Salva e sincronizza",
|
||||||
"scim_save_changes_description": "Devi salvare le modifiche prima di iniziare una sincronizzazione SCIM. Vuoi salvare adesso?",
|
"scim_save_changes_description": "Devi salvare le modifiche prima di iniziare una sincronizzazione SCIM. Vuoi salvare adesso?",
|
||||||
"scopes": "Scopi"
|
"scopes": "Scopi",
|
||||||
|
"issuer_url": "URL dell'emittente"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "SCIM同期が正常に完了しました。",
|
"scim_sync_successful": "SCIM同期が正常に完了しました。",
|
||||||
"save_and_sync": "保存と同期",
|
"save_and_sync": "保存と同期",
|
||||||
"scim_save_changes_description": "SCIM同期を開始する前に変更を保存する必要があります。今すぐ保存しますか?",
|
"scim_save_changes_description": "SCIM同期を開始する前に変更を保存する必要があります。今すぐ保存しますか?",
|
||||||
"scopes": "スコープ"
|
"scopes": "スコープ",
|
||||||
|
"issuer_url": "発行者URL"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "SCIM 동기화가 성공적으로 완료되었습니다.",
|
"scim_sync_successful": "SCIM 동기화가 성공적으로 완료되었습니다.",
|
||||||
"save_and_sync": "저장 및 동기화",
|
"save_and_sync": "저장 및 동기화",
|
||||||
"scim_save_changes_description": "SCIM 동기화를 시작하기 전에 변경 사항을 저장해야 합니다. 지금 저장하시겠습니까?",
|
"scim_save_changes_description": "SCIM 동기화를 시작하기 전에 변경 사항을 저장해야 합니다. 지금 저장하시겠습니까?",
|
||||||
"scopes": "범위"
|
"scopes": "범위",
|
||||||
|
"issuer_url": "발행자 URL"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "De SCIM-synchronisatie is goed gelukt.",
|
"scim_sync_successful": "De SCIM-synchronisatie is goed gelukt.",
|
||||||
"save_and_sync": "Opslaan en synchroniseren",
|
"save_and_sync": "Opslaan en synchroniseren",
|
||||||
"scim_save_changes_description": "Je moet de wijzigingen opslaan voordat je een SCIM-synchronisatie start. Wil je nu opslaan?",
|
"scim_save_changes_description": "Je moet de wijzigingen opslaan voordat je een SCIM-synchronisatie start. Wil je nu opslaan?",
|
||||||
"scopes": "Scopes"
|
"scopes": "Scopes",
|
||||||
|
"issuer_url": "URL van de uitgever"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "Synchronizacja SCIM została pomyślnie zakończona.",
|
"scim_sync_successful": "Synchronizacja SCIM została pomyślnie zakończona.",
|
||||||
"save_and_sync": "Zapisz i zsynchronizuj",
|
"save_and_sync": "Zapisz i zsynchronizuj",
|
||||||
"scim_save_changes_description": "Przed rozpoczęciem synchronizacji SCIM należy zapisać zmiany. Czy chcesz zapisać teraz?",
|
"scim_save_changes_description": "Przed rozpoczęciem synchronizacji SCIM należy zapisać zmiany. Czy chcesz zapisać teraz?",
|
||||||
"scopes": "Zakresy"
|
"scopes": "Zakresy",
|
||||||
|
"issuer_url": "Adres URL wystawcy"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "A sincronização SCIM foi concluída com sucesso.",
|
"scim_sync_successful": "A sincronização SCIM foi concluída com sucesso.",
|
||||||
"save_and_sync": "Salvar e sincronizar",
|
"save_and_sync": "Salvar e sincronizar",
|
||||||
"scim_save_changes_description": "Você precisa salvar as alterações antes de iniciar uma sincronização SCIM. Quer salvar agora?",
|
"scim_save_changes_description": "Você precisa salvar as alterações antes de iniciar uma sincronização SCIM. Quer salvar agora?",
|
||||||
"scopes": "Âmbitos"
|
"scopes": "Âmbitos",
|
||||||
|
"issuer_url": "URL do emissor"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "Синхронизация SCIM прошла без проблем.",
|
"scim_sync_successful": "Синхронизация SCIM прошла без проблем.",
|
||||||
"save_and_sync": "Сохранить и синхронизировать",
|
"save_and_sync": "Сохранить и синхронизировать",
|
||||||
"scim_save_changes_description": "Перед тем, как начать синхронизацию SCIM, нужно сохранить изменения. Хочешь сохранить сейчас?",
|
"scim_save_changes_description": "Перед тем, как начать синхронизацию SCIM, нужно сохранить изменения. Хочешь сохранить сейчас?",
|
||||||
"scopes": "Области применения"
|
"scopes": "Области применения",
|
||||||
|
"issuer_url": "URL эмитента"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "SCIM-synkroniseringen har slutförts.",
|
"scim_sync_successful": "SCIM-synkroniseringen har slutförts.",
|
||||||
"save_and_sync": "Spara och synkronisera",
|
"save_and_sync": "Spara och synkronisera",
|
||||||
"scim_save_changes_description": "Du måste spara ändringarna innan du startar en SCIM-synkronisering. Vill du spara nu?",
|
"scim_save_changes_description": "Du måste spara ändringarna innan du startar en SCIM-synkronisering. Vill du spara nu?",
|
||||||
"scopes": "Omfattning"
|
"scopes": "Omfattning",
|
||||||
|
"issuer_url": "Utfärdarens URL"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "SCIM senkronizasyonu başarıyla tamamlandı.",
|
"scim_sync_successful": "SCIM senkronizasyonu başarıyla tamamlandı.",
|
||||||
"save_and_sync": "Kaydet ve Senkronize Et",
|
"save_and_sync": "Kaydet ve Senkronize Et",
|
||||||
"scim_save_changes_description": "SCIM senkronizasyonunu başlatmadan önce değişiklikleri kaydetmeniz gerekir. Şimdi kaydetmek ister misiniz?",
|
"scim_save_changes_description": "SCIM senkronizasyonunu başlatmadan önce değişiklikleri kaydetmeniz gerekir. Şimdi kaydetmek ister misiniz?",
|
||||||
"scopes": "Kapsamlar"
|
"scopes": "Kapsamlar",
|
||||||
|
"issuer_url": "İhraççı URL"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "Синхронізація SCIM успішно завершена.",
|
"scim_sync_successful": "Синхронізація SCIM успішно завершена.",
|
||||||
"save_and_sync": "Зберегти та синхронізувати",
|
"save_and_sync": "Зберегти та синхронізувати",
|
||||||
"scim_save_changes_description": "Перед початком синхронізації SCIM необхідно зберегти зміни. Чи хочете ви зберегти зараз?",
|
"scim_save_changes_description": "Перед початком синхронізації SCIM необхідно зберегти зміни. Чи хочете ви зберегти зараз?",
|
||||||
"scopes": "Області застосування"
|
"scopes": "Області застосування",
|
||||||
|
"issuer_url": "URL емітента"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "Quá trình đồng bộ hóa SCIM đã hoàn tất thành công.",
|
"scim_sync_successful": "Quá trình đồng bộ hóa SCIM đã hoàn tất thành công.",
|
||||||
"save_and_sync": "Lưu và Đồng bộ hóa",
|
"save_and_sync": "Lưu và Đồng bộ hóa",
|
||||||
"scim_save_changes_description": "Bạn phải lưu các thay đổi trước khi bắt đầu đồng bộ hóa SCIM. Bạn có muốn lưu ngay bây giờ không?",
|
"scim_save_changes_description": "Bạn phải lưu các thay đổi trước khi bắt đầu đồng bộ hóa SCIM. Bạn có muốn lưu ngay bây giờ không?",
|
||||||
"scopes": "Phạm vi"
|
"scopes": "Phạm vi",
|
||||||
|
"issuer_url": "Địa chỉ URL của tổ chức phát hành"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"profile_picture": "头像",
|
"profile_picture": "头像",
|
||||||
"profile_picture_is_managed_by_ldap_server": "头像由 LDAP 服务器管理,无法在此处更改。",
|
"profile_picture_is_managed_by_ldap_server": "头像由 LDAP 服务器管理,无法在此处更改。",
|
||||||
"click_profile_picture_to_upload_custom": "点击头像来从文件中上传您的自定义头像。",
|
"click_profile_picture_to_upload_custom": "点击头像来从文件中上传您的自定义头像。",
|
||||||
"image_should_be_in_format": "图片应为 PNG、JPEG 或 WEBP 格式。",
|
"image_should_be_in_format": "图片格式支持 PNG、JPEG 或 WEBP。",
|
||||||
"items_per_page": "每页条数",
|
"items_per_page": "每页条数",
|
||||||
"no_items_found": "这里暂时空空如也",
|
"no_items_found": "这里暂时空空如也",
|
||||||
"select_items": "选择项目……",
|
"select_items": "选择项目……",
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"update_pocket_id": "更新 Pocket ID",
|
"update_pocket_id": "更新 Pocket ID",
|
||||||
"powered_by": "",
|
"powered_by": "",
|
||||||
"see_your_recent_account_activities": "查看您账户在配置的保留期内的活动记录。",
|
"see_your_recent_account_activities": "查看您账户的活动日志。日志保存期已在环境变量中配置。",
|
||||||
"time": "时间",
|
"time": "时间",
|
||||||
"event": "事件",
|
"event": "事件",
|
||||||
"approximate_location": "大致位置",
|
"approximate_location": "大致位置",
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "您确定要撤销 API 密钥 \"{apiKeyName}\" 吗?这将中断使用此密钥的任何集成。",
|
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "您确定要撤销 API 密钥 \"{apiKeyName}\" 吗?这将中断使用此密钥的任何集成。",
|
||||||
"last_used": "上次使用时间",
|
"last_used": "上次使用时间",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"images_updated_successfully": "图片更新成功。更新过程可能需要几分钟时间。",
|
"images_updated_successfully": "图片更新成功。可能需要几分钟生效。",
|
||||||
"general": "常规",
|
"general": "常规",
|
||||||
"configure_smtp_to_send_emails": "启用电子邮件通知,当检测到来自新设备或新位置的登录时提醒用户。",
|
"configure_smtp_to_send_emails": "启用电子邮件通知,当检测到来自新设备或新位置的登录时提醒用户。",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
@@ -306,16 +306,16 @@
|
|||||||
"client_secret": "客户端密钥",
|
"client_secret": "客户端密钥",
|
||||||
"show_more_details": "显示更多详情",
|
"show_more_details": "显示更多详情",
|
||||||
"allowed_user_groups": "允许的用户组",
|
"allowed_user_groups": "允许的用户组",
|
||||||
"allowed_user_groups_description": "选择允许其成员登录此客户端的用户组。",
|
"allowed_user_groups_description": "选择允许登录此客户端的用户组。",
|
||||||
"allowed_user_groups_status_unrestricted_description": "未应用任何用户组限制。任何用户均可登录此客户端。",
|
"allowed_user_groups_status_unrestricted_description": "未应用任何用户组限制。任何用户均可登录此客户端。",
|
||||||
"unrestrict": "解除限制",
|
"unrestrict": "取消限制",
|
||||||
"restrict": "限制",
|
"restrict": "限制",
|
||||||
"user_groups_restriction_updated_successfully": "用户组限制已成功更新",
|
"user_groups_restriction_updated_successfully": "用户组限制已成功更新",
|
||||||
"allowed_user_groups_updated_successfully": "已成功更新允许的用户组",
|
"allowed_user_groups_updated_successfully": "已成功更新允许的用户组",
|
||||||
"favicon": "网站图标",
|
"favicon": "网站图标",
|
||||||
"light_mode_logo": "浅色模式 Logo",
|
"light_mode_logo": "浅色模式 Logo",
|
||||||
"dark_mode_logo": "深色模式 Logo",
|
"dark_mode_logo": "深色模式 Logo",
|
||||||
"email_logo": "电子邮件徽标",
|
"email_logo": "电子邮件图标",
|
||||||
"background_image": "背景图片",
|
"background_image": "背景图片",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
"reset_profile_picture_question": "重置头像?",
|
"reset_profile_picture_question": "重置头像?",
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
"all_clients": "所有客户端",
|
"all_clients": "所有客户端",
|
||||||
"all_locations": "所有地方",
|
"all_locations": "所有地方",
|
||||||
"global_audit_log": "全局日志",
|
"global_audit_log": "全局日志",
|
||||||
"see_all_recent_account_activities": "查看所有用户在设定保留期内的账户活动。",
|
"see_all_recent_account_activities": "查看所有用户的活动日志。日志保存期已在环境变量中配置。",
|
||||||
"token_sign_in": "Token 登录",
|
"token_sign_in": "Token 登录",
|
||||||
"client_authorization": "客户端授权",
|
"client_authorization": "客户端授权",
|
||||||
"new_client_authorization": "首次客户端授权",
|
"new_client_authorization": "首次客户端授权",
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
"display_name": "显示名称",
|
"display_name": "显示名称",
|
||||||
"configure_application_images": "配置应用图标",
|
"configure_application_images": "配置应用图标",
|
||||||
"ui_config_disabled_info_title": "用户界面配置已禁用",
|
"ui_config_disabled_info_title": "用户界面配置已禁用",
|
||||||
"ui_config_disabled_info_description": "由于应用配置设置已通过环境变量设定,用户界面配置已禁用。某些设置可能无法编辑。",
|
"ui_config_disabled_info_description": "用户界面配置已被环境变量禁用。某些设置可能无法编辑。",
|
||||||
"logo_from_url_description": "粘贴直接图片URL(svg、png、webp格式)。<link href=\"https://selfh.st/icons\">可在Selfh.st图标库</link>或<link href=\"https://dashboardicons.com\">仪表盘图标库</link>中查找图标。",
|
"logo_from_url_description": "粘贴直接图片URL(svg、png、webp格式)。<link href=\"https://selfh.st/icons\">可在Selfh.st图标库</link>或<link href=\"https://dashboardicons.com\">仪表盘图标库</link>中查找图标。",
|
||||||
"invalid_url": "无效网址",
|
"invalid_url": "无效网址",
|
||||||
"require_user_email": "需要电子邮件地址",
|
"require_user_email": "需要电子邮件地址",
|
||||||
@@ -475,15 +475,15 @@
|
|||||||
"light": "浅色",
|
"light": "浅色",
|
||||||
"dark": "深色",
|
"dark": "深色",
|
||||||
"system": "系统",
|
"system": "系统",
|
||||||
"signup_token_user_groups_description": "自动将这些组分配给使用此令牌注册的用户。",
|
"signup_token_user_groups_description": "使用此令牌注册时的自动分配给用户的用户组。",
|
||||||
"allowed_oidc_clients": "允许的 OIDC 客户端",
|
"allowed_oidc_clients": "允许的 OIDC 客户端",
|
||||||
"allowed_oidc_clients_description": "选择允许此用户组成员登录的 OIDC 客户端。",
|
"allowed_oidc_clients_description": "此用户组成员有权访问的OIDC 客户端。",
|
||||||
"unrestrict_oidc_client": "解除限制 {clientName}",
|
"unrestrict_oidc_client": "取消限制 {clientName}",
|
||||||
"confirm_unrestrict_oidc_client_description": "您确定要解除对该 OIDC 客户端的限制吗? <b>{clientName}</b>?此操作将移除此客户端的所有组分配,任何用户均可登录。",
|
"confirm_unrestrict_oidc_client_description": "您确定要取消对该 OIDC 客户端的限制吗? <b>{clientName}</b>?此操作将移除此客户端的所有组分配,任何用户均可登录。",
|
||||||
"allowed_oidc_clients_updated_successfully": "允许的 OIDC 客户端已成功更新",
|
"allowed_oidc_clients_updated_successfully": "允许的 OIDC 客户端已成功更新",
|
||||||
"yes": "是的",
|
"yes": "是",
|
||||||
"no": "不",
|
"no": "否",
|
||||||
"restricted": "限制",
|
"restricted": "受限",
|
||||||
"scim_provisioning": "SCIM 配置",
|
"scim_provisioning": "SCIM 配置",
|
||||||
"scim_provisioning_description": "SCIM 配置功能可让您自动为 OIDC 客户端创建和删除用户及组。更多详情请参<link href='https://pocket-id.org/docs/configuration/scim'>阅文档</link>。",
|
"scim_provisioning_description": "SCIM 配置功能可让您自动为 OIDC 客户端创建和删除用户及组。更多详情请参<link href='https://pocket-id.org/docs/configuration/scim'>阅文档</link>。",
|
||||||
"scim_endpoint": "SCIM 端点",
|
"scim_endpoint": "SCIM 端点",
|
||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "SCIM 同步已成功完成。",
|
"scim_sync_successful": "SCIM 同步已成功完成。",
|
||||||
"save_and_sync": "保存并同步",
|
"save_and_sync": "保存并同步",
|
||||||
"scim_save_changes_description": "在开始 SCIM 同步之前,您必须先保存更改。是否现在保存?",
|
"scim_save_changes_description": "在开始 SCIM 同步之前,您必须先保存更改。是否现在保存?",
|
||||||
"scopes": "Scopes"
|
"scopes": "Scopes",
|
||||||
|
"issuer_url": "发行者网址"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,5 +498,6 @@
|
|||||||
"scim_sync_successful": "SCIM 同步已成功完成。",
|
"scim_sync_successful": "SCIM 同步已成功完成。",
|
||||||
"save_and_sync": "儲存與同步",
|
"save_and_sync": "儲存與同步",
|
||||||
"scim_save_changes_description": "您必須在開始 SCIM 同步前儲存變更。現在要儲存嗎?",
|
"scim_save_changes_description": "您必須在開始 SCIM 同步前儲存變更。現在要儲存嗎?",
|
||||||
"scopes": "範圍"
|
"scopes": "範圍",
|
||||||
|
"issuer_url": "發行者網址"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "2.0.2",
|
"version": "2.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import * as Alert from '$lib/components/ui/alert';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import UserService from '$lib/services/user-service';
|
||||||
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
|
import userStore from '$lib/stores/user-store';
|
||||||
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
|
import { LucideAlertTriangle, LucideCheckCircle2, LucideCircleX } from '@lucide/svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
let emailVerificationState = $state(page.url.searchParams.get('emailVerificationState'));
|
||||||
|
|
||||||
|
async function sendEmailVerification() {
|
||||||
|
await userService
|
||||||
|
.sendEmailVerification()
|
||||||
|
.then(() => {
|
||||||
|
toast.success(m.email_verification_sent());
|
||||||
|
})
|
||||||
|
.catch(axiosErrorToast);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDismiss() {
|
||||||
|
const url = new URL(page.url);
|
||||||
|
url.searchParams.delete('emailVerificationState');
|
||||||
|
history.replaceState(null, '', url.toString());
|
||||||
|
emailVerificationState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const user = get(userStore);
|
||||||
|
if (emailVerificationState === 'success' && user) {
|
||||||
|
user.emailVerified = true;
|
||||||
|
userStore.setUser(user);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if emailVerificationState}
|
||||||
|
{#if emailVerificationState === 'success'}
|
||||||
|
<Alert.Root variant="success" {onDismiss}>
|
||||||
|
<LucideCheckCircle2 class="size-4" />
|
||||||
|
<Alert.Title class="font-semibold">{m.email_verification_success_title()}</Alert.Title>
|
||||||
|
<Alert.Description class="text-sm">
|
||||||
|
{m.email_verification_success_description()}
|
||||||
|
</Alert.Description>
|
||||||
|
</Alert.Root>
|
||||||
|
{:else}
|
||||||
|
<Alert.Root variant="destructive" {onDismiss}>
|
||||||
|
<LucideCircleX class="size-4" />
|
||||||
|
<Alert.Title class="font-semibold">{m.email_verification_error_title()}</Alert.Title>
|
||||||
|
<Alert.Description class="text-sm">
|
||||||
|
{emailVerificationState}
|
||||||
|
</Alert.Description>
|
||||||
|
</Alert.Root>
|
||||||
|
{/if}
|
||||||
|
{:else if $userStore && $appConfigStore.emailVerificationEnabled && !$userStore.emailVerified}
|
||||||
|
<Alert.Root variant="warning" class="flex gap-3">
|
||||||
|
<LucideAlertTriangle class="size-4" />
|
||||||
|
<div class="md:flex md:w-full md:place-content-between">
|
||||||
|
<div>
|
||||||
|
<Alert.Title class="font-semibold">{m.email_verification_warning()}</Alert.Title>
|
||||||
|
<Alert.Description class="text-sm">
|
||||||
|
{m.email_verification_warning_description()}
|
||||||
|
</Alert.Description>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button class="mt-2 md:mt-0" usePromiseLoading onclick={sendEmailVerification}>
|
||||||
|
{m.send_email()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Alert.Root>
|
||||||
|
{/if}
|
||||||
@@ -31,19 +31,6 @@
|
|||||||
return new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
return new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (calendarDisplayDate) {
|
|
||||||
const newExternalDate = calendarDisplayDate.toDate(getLocalTimeZone());
|
|
||||||
if (!value || value.getTime() !== newExternalDate.getTime()) {
|
|
||||||
value = newExternalDate;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (value !== undefined) {
|
|
||||||
value = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (value) {
|
if (value) {
|
||||||
const newInternalCalendarDate = dateToCalendarDate(value);
|
const newInternalCalendarDate = dateToCalendarDate(value);
|
||||||
@@ -59,6 +46,17 @@
|
|||||||
|
|
||||||
function handleCalendarInteraction(newDateValue?: DateValue) {
|
function handleCalendarInteraction(newDateValue?: DateValue) {
|
||||||
open = false;
|
open = false;
|
||||||
|
calendarDisplayDate = newDateValue as CalendarDate | undefined;
|
||||||
|
if (calendarDisplayDate) {
|
||||||
|
const newExternalDate = calendarDisplayDate.toDate(getLocalTimeZone());
|
||||||
|
if (!value || value.getTime() !== newExternalDate.getTime()) {
|
||||||
|
value = newExternalDate;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (value !== undefined) {
|
||||||
|
value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const df = new DateFormatter(getLocale(), {
|
const df = new DateFormatter(getLocale(), {
|
||||||
@@ -89,8 +87,7 @@
|
|||||||
<Popover.Content class="w-auto p-0" align="start">
|
<Popover.Content class="w-auto p-0" align="start">
|
||||||
<Calendar
|
<Calendar
|
||||||
type="single"
|
type="single"
|
||||||
bind:value={calendarDisplayDate}
|
bind:value={() => calendarDisplayDate, (newValue) => handleCalendarInteraction(newValue)}
|
||||||
onValueChange={handleCalendarInteraction}
|
|
||||||
initialFocus
|
initialFocus
|
||||||
/>
|
/>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user