diff --git a/backend/internal/appconfig/testing_unit.go b/backend/internal/appconfig/testing_unit.go index 2fe4444d..9c3a833b 100644 --- a/backend/internal/appconfig/testing_unit.go +++ b/backend/internal/appconfig/testing_unit.go @@ -3,23 +3,16 @@ // This file contains utils for unit tests and it's only built when the "unit" tag is set package appconfig -import ( - "sync/atomic" - - "github.com/pocket-id/pocket-id/backend/internal/model" -) - // NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values -func NewTestAppConfigService(config *model.AppConfig) *AppConfigService { +func NewTestAppConfigService(config *AppConfigModel) *AppConfigService { if config == nil { // If there's no config, set the default one - config = getDefaultDbConfig() + config = getDefaultConfig() } service := &AppConfigService{ - dbConfig: atomic.Pointer[model.AppConfig]{}, + envConfig: config, } - service.dbConfig.Store(config) return service } diff --git a/backend/internal/job/api_key_expiry_job.go b/backend/internal/job/api_key_expiry_job.go index 9ca13c40..7209e2b5 100644 --- a/backend/internal/job/api_key_expiry_job.go +++ b/backend/internal/job/api_key_expiry_job.go @@ -31,8 +31,13 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *a } func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error { + dbConfig, err := j.appConfigService.GetConfig(ctx) + if err != nil { + return fmt.Errorf("error load app config: %w", err) + } + // Skip if the feature is disabled - if !j.appConfigService.GetDbConfig().EmailApiKeyExpirationEnabled.IsTrue() { + if !dbConfig.EmailApiKeyExpirationEnabled.IsTrue() { return nil } diff --git a/backend/internal/job/ldap_job.go b/backend/internal/job/ldap_job.go index 24ac3a24..ca749a52 100644 --- a/backend/internal/job/ldap_job.go +++ b/backend/internal/job/ldap_job.go @@ -2,6 +2,7 @@ package job import ( "context" + "fmt" "time" "github.com/pocket-id/pocket-id/backend/internal/appconfig" @@ -21,7 +22,12 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L } func (j *LdapJobs) syncLdap(ctx context.Context) error { - if !j.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { + dbConfig, err := j.appConfigService.GetConfig(ctx) + if err != nil { + return fmt.Errorf("error load app config: %w", err) + } + + if !dbConfig.LdapEnabled.IsTrue() { return nil } diff --git a/backend/internal/middleware/auth_middleware_test.go b/backend/internal/middleware/auth_middleware_test.go index 948a9951..568b29c6 100644 --- a/backend/internal/middleware/auth_middleware_test.go +++ b/backend/internal/middleware/auth_middleware_test.go @@ -49,7 +49,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) { authMiddleware := NewAuthMiddleware(apiKeyModule, userService, jwtService) user := createUserForAuthMiddlewareTest(t, db) - jwtToken, err := jwtService.GenerateAccessToken(user, "") + jwtToken, err := jwtService.GenerateAccessToken(user, "", time.Hour) require.NoError(t, err) apiKeyToken := "middleware-test-api-key-raw-token" diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go index 5e995ac3..678c70da 100644 --- a/backend/internal/service/audit_log_service.go +++ b/backend/internal/service/audit_log_service.go @@ -65,7 +65,7 @@ func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, } // CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before -func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog { +func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, dbConfig *appconfig.AppConfigModel) model.AuditLog { createdAuditLog, ok := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx) if !ok { // At this point the transaction has been canceled already, and error has been logged @@ -91,7 +91,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres } // If the user hasn't logged in from the same device before and email notifications are enabled, send an email - if s.appConfigService.GetDbConfig().EmailLoginNotificationEnabled.IsTrue() && count <= 1 { + if dbConfig.EmailLoginNotificationEnabled.IsTrue() && count <= 1 { go func() { // This runs in background, so use a context without cancellation (or it would be stopped when the request ends) // We still want to have a context derived from the request's to carry over tracing info diff --git a/backend/internal/service/jwt_service.go b/backend/internal/service/jwt_service.go index 7dc8e5ce..6c4659d0 100644 --- a/backend/internal/service/jwt_service.go +++ b/backend/internal/service/jwt_service.go @@ -184,12 +184,11 @@ func (s *JwtService) SetKey(privateKey jwk.Key) error { return nil } -func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) { - +func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) { now := time.Now() token, err := jwt.NewBuilder(). Subject(user.ID). - Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())). + Expiration(now.Add(sessionDuration)). IssuedAt(now). Issuer(s.envConfig.AppURL). JwtID(uuid.New().String()). diff --git a/backend/internal/service/jwt_service_test.go b/backend/internal/service/jwt_service_test.go index 45a7776c..646b4fab 100644 --- a/backend/internal/service/jwt_service_test.go +++ b/backend/internal/service/jwt_service_test.go @@ -88,9 +88,7 @@ func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig * } func TestJwtService_Init(t *testing.T) { - mockConfig := appconfig.NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes - }) + mockConfig := appconfig.NewTestAppConfigService(nil) t.Run("should generate new key when none exists", func(t *testing.T) { db := testutils.NewDatabaseForTest(t) @@ -193,9 +191,7 @@ func TestJwtService_Init(t *testing.T) { } func TestJwtService_GetPublicJWK(t *testing.T) { - mockConfig := appconfig.NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes - }) + mockConfig := appconfig.NewTestAppConfigService(nil) db := testutils.NewDatabaseForTest(t) mockEnvConfig := newTestEnvConfig() instanceID := newInstanceID(t, db) @@ -311,9 +307,8 @@ func TestJwtService_GetPublicJWK(t *testing.T) { } func TestGenerateVerifyAccessToken(t *testing.T) { - mockConfig := appconfig.NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes - }) + const sessionDuration = time.Hour + mockConfig := appconfig.NewTestAppConfigService(nil) db, envConfig := newTestDbAndEnv(t) instanceID := newInstanceID(t, db) @@ -326,7 +321,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { IsAdmin: false, } - tokenString, err := service.GenerateAccessToken(user, "") + tokenString, err := service.GenerateAccessToken(user, "", sessionDuration) require.NoError(t, err, "Failed to generate access token") assert.NotEmpty(t, tokenString, "Token should not be empty") @@ -367,7 +362,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { IsAdmin: true, } - tokenString, err := service.GenerateAccessToken(adminUser, "") + tokenString, err := service.GenerateAccessToken(adminUser, "", sessionDuration) require.NoError(t, err, "Failed to generate access token") claims, err := service.VerifyAccessToken(tokenString) @@ -390,7 +385,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { Base: model.Base{ID: "user-with-auth-method"}, } - tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant) + tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant, sessionDuration) require.NoError(t, err, "Failed to generate access token") claims, err := service.VerifyAccessToken(tokenString) @@ -401,29 +396,6 @@ func TestGenerateVerifyAccessToken(t *testing.T) { assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match") }) - t.Run("uses session duration from config", func(t *testing.T) { - customMockConfig := appconfig.NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes - }) - service, _, _ := setupJwtService(t, instanceID, customMockConfig) - - user := model.User{ - Base: model.Base{ID: "user456"}, - } - - tokenString, err := service.GenerateAccessToken(user, "") - require.NoError(t, err, "Failed to generate access token") - - claims, err := service.VerifyAccessToken(tokenString) - require.NoError(t, err, "Failed to verify generated token") - - expectedExp := time.Now().Add(30 * time.Minute) - expiration, ok := claims.Expiration() - assert.True(t, ok, "Expiration not found in token") - timeDiff := expectedExp.Sub(expiration).Minutes() - assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 30 minutes") - }) - t.Run("works with Ed25519 keys", func(t *testing.T) { origKeyID := createEdDSAKeyJWK(t, db, instanceID, envConfig, mockConfig) service := initJwtService(t, db, instanceID, mockConfig, envConfig) @@ -438,7 +410,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { IsAdmin: true, } - tokenString, err := service.GenerateAccessToken(user, "") + tokenString, err := service.GenerateAccessToken(user, "", sessionDuration) require.NoError(t, err, "Failed to generate access token with Ed25519 key") assert.NotEmpty(t, tokenString, "Token should not be empty") @@ -476,7 +448,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { IsAdmin: true, } - tokenString, err := service.GenerateAccessToken(user, "") + tokenString, err := service.GenerateAccessToken(user, "", sessionDuration) require.NoError(t, err, "Failed to generate access token with ECDSA key") assert.NotEmpty(t, tokenString, "Token should not be empty") @@ -514,7 +486,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { IsAdmin: true, } - tokenString, err := service.GenerateAccessToken(user, "") + tokenString, err := service.GenerateAccessToken(user, "", sessionDuration) require.NoError(t, err, "Failed to generate access token with RSA key") assert.NotEmpty(t, tokenString, "Token should not be empty") diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index eb6235a9..40ca74eb 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -36,7 +36,7 @@ type LdapService struct { userService *UserService groupService *UserGroupService fileStorage storage.FileStorage - clientFactory func(ctx context.Context) (ldapClient, error) + clientFactory func(dbConfig *appconfig.AppConfigModel) (ldapClient, error) } type savePicture struct { @@ -84,12 +84,7 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *appc return service } -func (s *LdapService) createClient(ctx context.Context) (ldapClient, error) { - dbConfig, err := appconfig.FromCtx(ctx) - if err != nil { - return nil, fmt.Errorf("error loading app configuration: %w", err) - } - +func (s *LdapService) createClient(dbConfig *appconfig.AppConfigModel) (ldapClient, error) { if !dbConfig.LdapEnabled.IsTrue() { return nil, fmt.Errorf("LDAP is not enabled") } @@ -111,15 +106,20 @@ func (s *LdapService) createClient(ctx context.Context) (ldapClient, error) { } func (s *LdapService) SyncAll(ctx context.Context) error { + dbConfig, err := appconfig.FromCtx(ctx) + if err != nil { + return fmt.Errorf("error loading app configuration: %w", err) + } + // Setup LDAP connection - client, err := s.clientFactory() + client, err := s.clientFactory(dbConfig) if err != nil { return fmt.Errorf("failed to create LDAP client: %w", err) } defer client.Close() // First, we fetch all users and group from LDAP, which is our "desired state" - desiredState, err := s.fetchDesiredState(ctx, client) + desiredState, err := s.fetchDesiredState(ctx, client, dbConfig) if err != nil { return fmt.Errorf("failed to fetch LDAP state: %w", err) } @@ -132,7 +132,7 @@ func (s *LdapService) SyncAll(ctx context.Context) error { defer tx.Rollback() // Reconcile users - savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs) + savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs, dbConfig) if err != nil { return fmt.Errorf("failed to sync users: %w", err) } @@ -171,7 +171,7 @@ func (s *LdapService) SyncAll(ctx context.Context) error { return nil } -func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) (ldapDesiredState, error) { +func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient, dbConfig *appconfig.AppConfigModel) (ldapDesiredState, error) { // Fetch users first so we can use their DNs when resolving group members users, userIDs, usernamesByDN, err := s.fetchUsersFromLDAP(ctx, client) if err != nil { @@ -179,7 +179,7 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) } // Then fetch groups to complete the desired LDAP state snapshot - groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN) + groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN, dbConfig) if err != nil { return ldapDesiredState{}, err } @@ -187,7 +187,7 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) // Apply user admin flags from the desired group membership snapshot. // This intentionally uses the configured group member attribute rather than // relying on a user-side reverse-membership attribute such as memberOf. - s.applyAdminGroupMembership(users, groups) + s.applyAdminGroupMembership(users, groups, dbConfig) return ldapDesiredState{ users: users, @@ -197,15 +197,14 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) }, nil } -func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) { - dbConfig := s.appConfigService.GetDbConfig() +func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup, dbConfig *appconfig.AppConfigModel) { if dbConfig.LdapAdminGroupName == "" { return } adminUsernames := make(map[string]struct{}) for _, group := range desiredGroups { - if group.input.Name != dbConfig.LdapAdminGroupName { + if group.input.Name != string(dbConfig.LdapAdminGroupName) { continue } @@ -220,21 +219,19 @@ func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, } } -func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) { - dbConfig := s.appConfigService.GetDbConfig() - +func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string, dbConfig *appconfig.AppConfigModel) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) { // Query LDAP for all groups we want to manage searchAttrs := []string{ - dbConfig.LdapAttributeGroupName.Value, - dbConfig.LdapAttributeGroupUniqueIdentifier.Value, - dbConfig.LdapAttributeGroupMember.Value, + dbConfig.LdapAttributeGroupName.String(), + dbConfig.LdapAttributeGroupUniqueIdentifier.String(), + dbConfig.LdapAttributeGroupMember.String(), } searchReq := ldap.NewSearchRequest( - dbConfig.LdapBase.Value, + dbConfig.LdapBase.String(), ldap.ScopeWholeSubtree, 0, 0, 0, false, - dbConfig.LdapUserGroupSearchFilter.Value, + dbConfig.LdapUserGroupSearchFilter.String(), searchAttrs, []ldap.Control{}, ) @@ -248,21 +245,21 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient desiredGroups = make([]ldapDesiredGroup, 0, len(result.Entries)) for _, value := range result.Entries { - ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value)) + ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.String())) // Skip groups without a valid LDAP ID if ldapID == "" { - slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value)) + slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.String())) continue } ldapGroupIDs[ldapID] = struct{}{} // Get group members and add to the correct Group - groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value) + groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.String()) memberUsernames := make([]string, 0, len(groupMembers)) for _, member := range groupMembers { - username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN, dbConfig.LdapAttributeUserUsername.Value) + username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN, dbConfig.LdapAttributeUserUsername.String()) if username == "" { continue } @@ -271,8 +268,8 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient } syncGroup := dto.UserGroupCreateDto{ - Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), - FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), + Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()), + FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()), LdapID: ldapID, } dto.Normalize(&syncGroup) @@ -504,9 +501,7 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG } //nolint:gocognit -func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}) (savePictures []savePicture, deleteFiles []string, err error) { - dbConfig := s.appConfigService.GetDbConfig() - +func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, dbConfig *appconfig.AppConfigModel) (savePictures []savePicture, deleteFiles []string, err error) { // Load the current LDAP-managed state from the database ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx) if err != nil { diff --git a/backend/internal/service/ldap_service_test.go b/backend/internal/service/ldap_service_test.go index 13e24633..3e8fb5ca 100644 --- a/backend/internal/service/ldap_service_test.go +++ b/backend/internal/service/ldap_service_test.go @@ -145,8 +145,8 @@ func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) { // Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408. func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) { appCfg := defaultTestLDAPAppConfig() - appCfg.LdapUserGroupSearchFilter = model.AppConfigVariable{Value: "(objectClass=posixGroup)"} - appCfg.LdapAttributeGroupMember = model.AppConfigVariable{Value: "memberUid"} + appCfg.LdapUserGroupSearchFilter = "(objectClass=posixGroup)" + appCfg.LdapAttributeGroupMember = "memberUid" service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient( ldapSearchResult( @@ -238,7 +238,7 @@ func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) { func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) { tests := []struct { name string - appConfig *model.AppConfig + appConfig *appconfig.AppConfigModel groupEntry *ldap.Entry groupName string groupLookup string @@ -256,10 +256,10 @@ func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) { }, { name: "configured group name attribute differs from DN RDN", - appConfig: func() *model.AppConfig { + appConfig: func() *appconfig.AppConfigModel { cfg := defaultTestLDAPAppConfig() - cfg.LdapAttributeGroupName = model.AppConfigVariable{Value: "displayName"} - cfg.LdapAdminGroupName = model.AppConfigVariable{Value: "pocketid.admin"} + cfg.LdapAttributeGroupName = "displayName" + cfg.LdapAdminGroupName = "pocketid.admin" return cfg }(), groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{ @@ -309,7 +309,7 @@ func newTestLdapService(t *testing.T, client ldapClient) (*LdapService, *gorm.DB return newTestLdapServiceWithAppConfig(t, defaultTestLDAPAppConfig(), client) } -func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConfig, client ldapClient) (*LdapService, *gorm.DB) { +func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *appconfig.AppConfigModel, client ldapClient) (*LdapService, *gorm.DB) { t.Helper() db := testutils.NewDatabaseForTest(t) @@ -333,32 +333,32 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf ) service := NewLdapService(db, &http.Client{}, appConfig, userService, groupService, fileStorage) - service.clientFactory = func() (ldapClient, error) { + service.clientFactory = func(dbConfig *appconfig.AppConfigModel) (ldapClient, error) { return client, nil } return service, db } -func defaultTestLDAPAppConfig() *model.AppConfig { - return &model.AppConfig{ - RequireUserEmail: model.AppConfigVariable{Value: "false"}, - LdapEnabled: model.AppConfigVariable{Value: "true"}, - LdapBase: model.AppConfigVariable{Value: "dc=example,dc=com"}, - LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"}, - LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"}, - LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"}, - LdapAttributeUserUsername: model.AppConfigVariable{Value: "uid"}, - LdapAttributeUserEmail: model.AppConfigVariable{Value: "mail"}, - LdapAttributeUserFirstName: model.AppConfigVariable{Value: "givenName"}, - LdapAttributeUserLastName: model.AppConfigVariable{Value: "sn"}, - LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "displayName"}, - LdapAttributeUserProfilePicture: model.AppConfigVariable{Value: "jpegPhoto"}, - LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"}, - LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"}, - LdapAttributeGroupName: model.AppConfigVariable{Value: "cn"}, - LdapAdminGroupName: model.AppConfigVariable{Value: "admins"}, - LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"}, +func defaultTestLDAPAppConfig() *appconfig.AppConfigModel { + return &appconfig.AppConfigModel{ + RequireUserEmail: "false", + LdapEnabled: "true", + LdapBase: "dc=example,dc=com", + LdapUserSearchFilter: "(objectClass=person)", + LdapUserGroupSearchFilter: "(objectClass=groupOfNames)", + LdapAttributeUserUniqueIdentifier: "entryUUID", + LdapAttributeUserUsername: "uid", + LdapAttributeUserEmail: "mail", + LdapAttributeUserFirstName: "givenName", + LdapAttributeUserLastName: "sn", + LdapAttributeUserDisplayName: "displayName", + LdapAttributeUserProfilePicture: "jpegPhoto", + LdapAttributeGroupMember: "member", + LdapAttributeGroupUniqueIdentifier: "entryUUID", + LdapAttributeGroupName: "cn", + LdapAdminGroupName: "admins", + LdapSoftDeleteUsers: "true", } } diff --git a/backend/internal/service/one_time_access_service.go b/backend/internal/service/one_time_access_service.go index 27f6d7ec..4bf72c3c 100644 --- a/backend/internal/service/one_time_access_service.go +++ b/backend/internal/service/one_time_access_service.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "fmt" "log/slog" "net/url" "strings" @@ -39,23 +40,31 @@ func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService * } func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error { - isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue() - if isDisabled { + dbConfig, err := appconfig.FromCtx(ctx) + if err != nil { + return fmt.Errorf("error loading app configuration: %w", err) + } + + if !dbConfig.EmailOneTimeAccessAsAdminEnabled.IsTrue() { return &common.OneTimeAccessDisabledError{} } - _, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false) + _, 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 { + dbConfig, err := appconfig.FromCtx(ctx) + if err != nil { + return "", fmt.Errorf("error loading app configuration: %w", err) + } + + if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() { return "", &common.OneTimeAccessDisabledError{} } var userId string - err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error + 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 @@ -152,7 +161,7 @@ func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, use // Commit err = tx.Commit().Error if err != nil { - return "", err + return "", fmt.Errorf("error committing transaction: %w", err) } return token, nil @@ -173,13 +182,18 @@ func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Cont } func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) { + dbConfig, err := appconfig.FromCtx(ctx) + if err != nil { + return model.User{}, "", fmt.Errorf("error loading app configuration: %w", err) + } + tx := s.db.Begin() defer func() { tx.Rollback() }() var oneTimeAccessToken model.OneTimeAccessToken - err := tx. + err = tx. WithContext(ctx). Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())). Preload("User"). @@ -199,7 +213,11 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, t return model.User{}, "", &common.UserDisabledError{} } - accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User, AuthenticationMethodOneTimePassword) + accessToken, err := s.jwtService.GenerateAccessToken( + oneTimeAccessToken.User, + AuthenticationMethodOneTimePassword, + dbConfig.SessionDuration.AsDurationMinutes(), + ) if err != nil { return model.User{}, "", err } @@ -212,11 +230,16 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, t return model.User{}, "", err } - s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx) + 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 model.User{}, "", fmt.Errorf("error committing transaction: %w", err) } return oneTimeAccessToken.User, accessToken, nil diff --git a/backend/internal/webauthn/module.go b/backend/internal/webauthn/module.go index 41d945e8..c4072f1f 100644 --- a/backend/internal/webauthn/module.go +++ b/backend/internal/webauthn/module.go @@ -8,18 +8,19 @@ import ( "github.com/lestrrat-go/jwx/v3/jwt" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/model" ) type TokenService interface { - GenerateAccessToken(user model.User, authenticationMethod string) (string, error) + GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) VerifyAccessToken(tokenString string) (jwt.Token, error) GetAuthenticationMethod(token jwt.Token) (string, error) } type AuditLogger interface { Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) - CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog + CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, dbConfig *appconfig.AppConfigModel) model.AuditLog } type AppConfigProvider interface { diff --git a/backend/internal/webauthn/service.go b/backend/internal/webauthn/service.go index 1fc01bcf..82d518e1 100644 --- a/backend/internal/webauthn/service.go +++ b/backend/internal/webauthn/service.go @@ -13,6 +13,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -228,6 +229,11 @@ func (s *Service) BeginLogin(ctx context.Context) (*PublicKeyCredentialRequestOp } func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) { + dbConfig, err := appconfig.FromCtx(ctx) + if err != nil { + return model.User{}, "", fmt.Errorf("error loading app configuration: %w", err) + } + tx := s.db.Begin() defer func() { tx.Rollback() @@ -235,7 +241,7 @@ func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialA // Load & delete the session row var storedSession WebauthnSession - err := tx. + err = tx. WithContext(ctx). Clauses(clause.Returning{}). Delete(&storedSession, "id = ?", sessionID). @@ -275,7 +281,7 @@ func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialA return model.User{}, "", err } - s.auditLog.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx) + s.auditLog.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx, dbConfig) err = tx.Commit().Error if err != nil {