This commit is contained in:
ItalyPaleAle
2026-07-14 08:57:47 -07:00
parent 1d2f991be5
commit 2b11164532
12 changed files with 131 additions and 131 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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()).

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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",
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {