mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-21 09:09:04 +02:00
Remove the AppConfigMiddleware that stored an app-config resolver in the request context and the FromCtx helper that read it back downstream. Handlers now load the app config from AppConfigService and pass it as an explicit argument to the service methods that need it, which then forward it further down the call chain. The webauthn and usersignup modules gain an AppConfigResolver dependency so their handlers can load the config the same way. The email SendEmail and LDAP SyncAll helpers drop their context-based variants in favor of the explicit-config versions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ6SoJpmnLggZqactXxrak
738 lines
24 KiB
Go
738 lines
24 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/go-ldap/ldap/v3"
|
|
"github.com/google/uuid"
|
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
|
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
"golang.org/x/text/unicode/norm"
|
|
"gorm.io/gorm"
|
|
|
|
"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"
|
|
)
|
|
|
|
type LdapService struct {
|
|
db *gorm.DB
|
|
httpClient *http.Client
|
|
userService *UserService
|
|
groupService *UserGroupService
|
|
fileStorage storage.FileStorage
|
|
clientFactory func(dbConfig *appconfig.AppConfigModel) (ldapClient, error)
|
|
}
|
|
|
|
type savePicture struct {
|
|
userID string
|
|
username string
|
|
picture string
|
|
}
|
|
|
|
type ldapDesiredUser struct {
|
|
ldapID string
|
|
input dto.UserCreateDto
|
|
picture string
|
|
}
|
|
|
|
type ldapDesiredGroup struct {
|
|
ldapID string
|
|
input dto.UserGroupCreateDto
|
|
memberUsernames []string
|
|
}
|
|
|
|
type ldapDesiredState struct {
|
|
users []ldapDesiredUser
|
|
userIDs map[string]struct{}
|
|
groups []ldapDesiredGroup
|
|
groupIDs map[string]struct{}
|
|
}
|
|
|
|
type ldapClient interface {
|
|
Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
|
|
Bind(username, password string) error
|
|
Close() error
|
|
}
|
|
|
|
func NewLdapService(db *gorm.DB, httpClient *http.Client, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
|
|
service := &LdapService{
|
|
db: db,
|
|
httpClient: httpClient,
|
|
userService: userService,
|
|
groupService: groupService,
|
|
fileStorage: fileStorage,
|
|
}
|
|
|
|
service.clientFactory = service.createClient
|
|
return service
|
|
}
|
|
|
|
func (s *LdapService) createClient(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
|
|
if !dbConfig.LdapEnabled.IsTrue() {
|
|
return nil, fmt.Errorf("LDAP is not enabled")
|
|
}
|
|
|
|
// Setup LDAP connection
|
|
client, err := ldap.DialURL(dbConfig.LdapUrl.String(), ldap.DialWithTLSConfig(&tls.Config{
|
|
InsecureSkipVerify: dbConfig.LdapSkipCertVerify.IsTrue(), //nolint:gosec
|
|
}))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to LDAP: %w", err)
|
|
}
|
|
|
|
// Bind as service account
|
|
err = client.Bind(dbConfig.LdapBindDn.String(), dbConfig.LdapBindPassword.String())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to bind to LDAP: %w", err)
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// SyncAll synchronizes LDAP using the provided application configuration
|
|
func (s *LdapService) SyncAll(ctx context.Context, dbConfig *appconfig.AppConfigModel) error {
|
|
// Setup LDAP connection
|
|
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, dbConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch LDAP state: %w", err)
|
|
}
|
|
|
|
// Start a transaction
|
|
tx := s.db.WithContext(ctx).Begin()
|
|
if tx.Error != nil {
|
|
return fmt.Errorf("failed to begin database transaction: %w", tx.Error)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Reconcile users
|
|
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs, dbConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to sync users: %w", err)
|
|
}
|
|
|
|
// Reconcile groups
|
|
err = s.reconcileGroups(ctx, tx, desiredState.groups, desiredState.groupIDs, dbConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to sync groups: %w", err)
|
|
}
|
|
|
|
// Commit the changes
|
|
err = tx.Commit().Error
|
|
if err != nil {
|
|
return fmt.Errorf("failed to commit changes to database: %w", err)
|
|
}
|
|
|
|
// Now that we've committed the transaction, we can perform operations on the storage layer
|
|
// First, save all new pictures
|
|
for _, sp := range savePictures {
|
|
err = s.saveProfilePicture(ctx, sp.userID, sp.picture)
|
|
if err != nil {
|
|
// This is not a fatal error
|
|
slog.Warn("Error saving profile picture for LDAP user", slog.String("username", sp.username), slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
// Delete all old files
|
|
for _, path := range deleteFiles {
|
|
err = s.fileStorage.Delete(ctx, path)
|
|
if err != nil {
|
|
// This is not a fatal error
|
|
slog.Error("Failed to delete file after LDAP sync", slog.String("path", path), slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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, dbConfig)
|
|
if err != nil {
|
|
return ldapDesiredState{}, err
|
|
}
|
|
|
|
// Then fetch groups to complete the desired LDAP state snapshot
|
|
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN, dbConfig)
|
|
if err != nil {
|
|
return ldapDesiredState{}, err
|
|
}
|
|
|
|
// 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, dbConfig)
|
|
|
|
return ldapDesiredState{
|
|
users: users,
|
|
userIDs: userIDs,
|
|
groups: groups,
|
|
groupIDs: groupIDs,
|
|
}, nil
|
|
}
|
|
|
|
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 != string(dbConfig.LdapAdminGroupName) {
|
|
continue
|
|
}
|
|
|
|
for _, username := range group.memberUsernames {
|
|
adminUsernames[username] = struct{}{}
|
|
}
|
|
}
|
|
|
|
for i := range desiredUsers {
|
|
_, isAdmin := adminUsernames[desiredUsers[i].input.Username]
|
|
desiredUsers[i].input.IsAdmin = desiredUsers[i].input.IsAdmin || isAdmin
|
|
}
|
|
}
|
|
|
|
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.String(),
|
|
dbConfig.LdapAttributeGroupUniqueIdentifier.String(),
|
|
dbConfig.LdapAttributeGroupMember.String(),
|
|
}
|
|
|
|
searchReq := ldap.NewSearchRequest(
|
|
dbConfig.LdapBase.String(),
|
|
ldap.ScopeWholeSubtree,
|
|
0, 0, 0, false,
|
|
dbConfig.LdapUserGroupSearchFilter.String(),
|
|
searchAttrs,
|
|
[]ldap.Control{},
|
|
)
|
|
result, err := client.Search(searchReq)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to query LDAP groups: %w", err)
|
|
}
|
|
|
|
// Build the in-memory desired state for groups
|
|
ldapGroupIDs = make(map[string]struct{}, len(result.Entries))
|
|
desiredGroups = make([]ldapDesiredGroup, 0, len(result.Entries))
|
|
|
|
for _, value := range result.Entries {
|
|
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.String()))
|
|
continue
|
|
}
|
|
|
|
ldapGroupIDs[ldapID] = struct{}{}
|
|
|
|
// Get group members and add to the correct Group
|
|
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.String())
|
|
if username == "" {
|
|
continue
|
|
}
|
|
|
|
memberUsernames = append(memberUsernames, username)
|
|
}
|
|
|
|
syncGroup := dto.UserGroupCreateDto{
|
|
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()),
|
|
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()),
|
|
LdapID: ldapID,
|
|
}
|
|
dto.Normalize(&syncGroup)
|
|
|
|
err = syncGroup.Validate()
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "LDAP user group object is not valid", slog.Any("error", err))
|
|
continue
|
|
}
|
|
|
|
desiredGroups = append(desiredGroups, ldapDesiredGroup{
|
|
ldapID: ldapID,
|
|
input: syncGroup,
|
|
memberUsernames: memberUsernames,
|
|
})
|
|
}
|
|
|
|
return desiredGroups, ldapGroupIDs, nil
|
|
}
|
|
|
|
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient, dbConfig *appconfig.AppConfigModel) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
|
|
// Query LDAP for all users we want to manage
|
|
searchAttrs := []string{
|
|
"sn",
|
|
"cn",
|
|
dbConfig.LdapAttributeUserUniqueIdentifier.String(),
|
|
dbConfig.LdapAttributeUserUsername.String(),
|
|
dbConfig.LdapAttributeUserEmail.String(),
|
|
dbConfig.LdapAttributeUserFirstName.String(),
|
|
dbConfig.LdapAttributeUserLastName.String(),
|
|
dbConfig.LdapAttributeUserProfilePicture.String(),
|
|
dbConfig.LdapAttributeUserDisplayName.String(),
|
|
}
|
|
|
|
// Filters must start and finish with ()!
|
|
searchReq := ldap.NewSearchRequest(
|
|
dbConfig.LdapBase.String(),
|
|
ldap.ScopeWholeSubtree,
|
|
0, 0, 0, false,
|
|
dbConfig.LdapUserSearchFilter.String(),
|
|
searchAttrs,
|
|
[]ldap.Control{},
|
|
)
|
|
|
|
result, err := client.Search(searchReq)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("failed to query LDAP users: %w", err)
|
|
}
|
|
|
|
// Build the in-memory desired state for users and a DN lookup for group membership resolution
|
|
ldapUserIDs = make(map[string]struct{}, len(result.Entries))
|
|
usernamesByDN = make(map[string]string, len(result.Entries))
|
|
desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries))
|
|
|
|
for _, value := range result.Entries {
|
|
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()))
|
|
if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" {
|
|
usernamesByDN[normalizedDN] = username
|
|
}
|
|
|
|
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.String()))
|
|
|
|
// Skip users without a valid LDAP ID
|
|
if ldapID == "" {
|
|
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.String()))
|
|
continue
|
|
}
|
|
|
|
ldapUserIDs[ldapID] = struct{}{}
|
|
|
|
newUser := dto.UserCreateDto{
|
|
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()),
|
|
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.String())),
|
|
EmailVerified: true,
|
|
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.String()),
|
|
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.String()),
|
|
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.String()),
|
|
// Admin status is computed after groups are loaded so it can use the
|
|
// configured group member attribute instead of a hard-coded memberOf.
|
|
IsAdmin: false,
|
|
LdapID: ldapID,
|
|
}
|
|
|
|
if newUser.DisplayName == "" {
|
|
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
|
|
}
|
|
|
|
dto.Normalize(&newUser)
|
|
|
|
err = newUser.Validate()
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "LDAP user object is not valid", slog.Any("error", err))
|
|
continue
|
|
}
|
|
|
|
desiredUsers = append(desiredUsers, ldapDesiredUser{
|
|
ldapID: ldapID,
|
|
input: newUser,
|
|
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.String()),
|
|
})
|
|
}
|
|
|
|
return desiredUsers, ldapUserIDs, usernamesByDN, nil
|
|
}
|
|
|
|
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string, usernameAttr string) string {
|
|
// First try the DN cache we built while loading users
|
|
username, exists := usernamesByDN[normalizeLDAPDN(member)]
|
|
if exists && username != "" {
|
|
return username
|
|
}
|
|
|
|
// Then try to extract the username directly from the DN
|
|
username = getDNProperty(usernameAttr, member)
|
|
if username != "" {
|
|
return norm.NFC.String(username)
|
|
}
|
|
|
|
// posixGroup (and similar) stores bare usernames in memberUid, not DNs. Treat any value
|
|
// that is not a valid DN as the username directly — see https://github.com/pocket-id/pocket-id/issues/1408
|
|
_, err := ldap.ParseDN(member)
|
|
if err != nil {
|
|
return norm.NFC.String(member)
|
|
}
|
|
|
|
// As a fallback, query LDAP for the referenced entry
|
|
userSearchReq := ldap.NewSearchRequest(
|
|
member,
|
|
ldap.ScopeBaseObject,
|
|
0, 0, 0, false,
|
|
"(objectClass=*)",
|
|
[]string{usernameAttr},
|
|
[]ldap.Control{},
|
|
)
|
|
|
|
userResult, err := client.Search(userSearchReq)
|
|
if err != nil || len(userResult.Entries) == 0 {
|
|
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
|
|
return ""
|
|
}
|
|
|
|
username = userResult.Entries[0].GetAttributeValue(usernameAttr)
|
|
if username == "" {
|
|
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
|
|
return ""
|
|
}
|
|
|
|
return norm.NFC.String(username)
|
|
}
|
|
|
|
func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, dbConfig *appconfig.AppConfigModel) error {
|
|
// Load the current LDAP-managed state from the database
|
|
ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch groups from database: %w", err)
|
|
}
|
|
|
|
_, _, ldapUsersByUsername, err := s.loadLDAPUsersInDB(ctx, tx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch users from database: %w", err)
|
|
}
|
|
|
|
// Apply creates and updates to match the desired LDAP group state
|
|
for _, desiredGroup := range desiredGroups {
|
|
memberUserIDs := make([]string, 0, len(desiredGroup.memberUsernames))
|
|
for _, username := range desiredGroup.memberUsernames {
|
|
databaseUser, exists := ldapUsersByUsername[username]
|
|
if !exists {
|
|
// The user collides with a non-LDAP user or was skipped during user sync, so we ignore it
|
|
continue
|
|
}
|
|
|
|
memberUserIDs = append(memberUserIDs, databaseUser.ID)
|
|
}
|
|
|
|
databaseGroup := ldapGroupsByID[desiredGroup.ldapID]
|
|
if databaseGroup.ID == "" {
|
|
newGroup, err := s.groupService.createInternal(ctx, desiredGroup.input, tx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create group '%s': %w", desiredGroup.input.Name, err)
|
|
}
|
|
ldapGroupsByID[desiredGroup.ldapID] = newGroup
|
|
|
|
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, memberUserIDs, tx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx, dbConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
|
|
}
|
|
|
|
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
|
|
}
|
|
}
|
|
|
|
// Delete groups that are no longer present in LDAP
|
|
for _, group := range ldapGroupsInDB {
|
|
if group.LdapID == nil {
|
|
continue
|
|
}
|
|
|
|
if _, exists := ldapGroupIDs[*group.LdapID]; exists {
|
|
continue
|
|
}
|
|
|
|
err = tx.
|
|
WithContext(ctx).
|
|
Delete(&model.UserGroup{}, "ldap_id = ?", *group.LdapID).
|
|
Error
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
|
|
}
|
|
|
|
slog.Info("Deleted group", slog.String("group", group.Name))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
//nolint:gocognit
|
|
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 {
|
|
return nil, nil, fmt.Errorf("failed to fetch users from database: %w", err)
|
|
}
|
|
|
|
// Apply creates and updates to match the desired LDAP user state
|
|
savePictures = make([]savePicture, 0, len(desiredUsers))
|
|
|
|
for _, desiredUser := range desiredUsers {
|
|
databaseUser := ldapUsersByID[desiredUser.ldapID]
|
|
|
|
// If a user is found (even if disabled), enable them since they're now back in LDAP.
|
|
if databaseUser.ID != "" && databaseUser.Disabled {
|
|
err = tx.
|
|
WithContext(ctx).
|
|
Model(&model.User{}).
|
|
Where("id = ?", databaseUser.ID).
|
|
Update("disabled", false).
|
|
Error
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
|
|
}
|
|
|
|
databaseUser.Disabled = false
|
|
ldapUsersByID[desiredUser.ldapID] = databaseUser
|
|
}
|
|
|
|
userID := databaseUser.ID
|
|
if databaseUser.ID == "" {
|
|
createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx, dbConfig)
|
|
if errors.Is(err, &common.AlreadyInUseError{}) {
|
|
slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
|
|
continue
|
|
} else if err != nil {
|
|
return nil, nil, fmt.Errorf("error creating user '%s': %w", desiredUser.input.Username, err)
|
|
}
|
|
|
|
userID = createdUser.ID
|
|
ldapUsersByID[desiredUser.ldapID] = createdUser
|
|
} else {
|
|
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx, dbConfig)
|
|
if errors.Is(err, &common.AlreadyInUseError{}) {
|
|
slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
|
|
continue
|
|
} else if err != nil {
|
|
return nil, nil, fmt.Errorf("error updating user '%s': %w", desiredUser.input.Username, err)
|
|
}
|
|
}
|
|
|
|
if desiredUser.picture != "" {
|
|
savePictures = append(savePictures, savePicture{
|
|
userID: userID,
|
|
username: desiredUser.input.Username,
|
|
picture: desiredUser.picture,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Disable or delete users that are no longer present in LDAP
|
|
deleteFiles = make([]string, 0, len(ldapUsersInDB))
|
|
for _, user := range ldapUsersInDB {
|
|
if user.LdapID == nil {
|
|
continue
|
|
}
|
|
|
|
if _, exists := ldapUserIDs[*user.LdapID]; exists {
|
|
continue
|
|
}
|
|
|
|
if dbConfig.LdapSoftDeleteUsers.IsTrue() {
|
|
err = s.userService.disableUserInternal(ctx, tx, user.ID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to disable user %s: %w", user.Username, err)
|
|
}
|
|
|
|
slog.Info("Disabled user", slog.String("username", user.Username))
|
|
continue
|
|
}
|
|
|
|
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true, dbConfig)
|
|
if err != nil {
|
|
if _, ok := errors.AsType[*common.LdapUserUpdateError](err); ok {
|
|
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
|
|
}
|
|
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
|
|
}
|
|
|
|
slog.Info("Deleted user", slog.String("username", user.Username))
|
|
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
|
|
}
|
|
|
|
return savePictures, deleteFiles, nil
|
|
}
|
|
|
|
func (s *LdapService) loadLDAPUsersInDB(ctx context.Context, tx *gorm.DB) (users []model.User, byLdapID map[string]model.User, byUsername map[string]model.User, err error) {
|
|
// Load all LDAP-managed users and index them by LDAP ID and by username
|
|
err = tx.
|
|
WithContext(ctx).
|
|
Select("id, username, ldap_id, disabled").
|
|
Where("ldap_id IS NOT NULL").
|
|
Find(&users).
|
|
Error
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
byLdapID = make(map[string]model.User, len(users))
|
|
byUsername = make(map[string]model.User, len(users))
|
|
for _, user := range users {
|
|
byLdapID[*user.LdapID] = user
|
|
byUsername[user.Username] = user
|
|
}
|
|
|
|
return users, byLdapID, byUsername, nil
|
|
}
|
|
|
|
func (s *LdapService) loadLDAPGroupsInDB(ctx context.Context, tx *gorm.DB) ([]model.UserGroup, map[string]model.UserGroup, error) {
|
|
var groups []model.UserGroup
|
|
|
|
// Load all LDAP-managed groups and index them by LDAP ID
|
|
err := tx.
|
|
WithContext(ctx).
|
|
Select("id, name, ldap_id").
|
|
Where("ldap_id IS NOT NULL").
|
|
Find(&groups).
|
|
Error
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
groupsByID := make(map[string]model.UserGroup, len(groups))
|
|
for _, group := range groups {
|
|
groupsByID[*group.LdapID] = group
|
|
}
|
|
|
|
return groups, groupsByID, nil
|
|
}
|
|
|
|
func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId string, pictureString string) error {
|
|
var reader io.ReadSeeker
|
|
|
|
// Accept either a URL, a base64-encoded payload, or raw binary data
|
|
_, err := url.ParseRequestURI(pictureString)
|
|
if err == nil {
|
|
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
|
|
defer cancel()
|
|
|
|
var req *http.Request
|
|
req, err = http.NewRequestWithContext(ctx, http.MethodGet, pictureString, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
var res *http.Response
|
|
res, err = s.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download profile picture: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
data, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read profile picture: %w", err)
|
|
}
|
|
|
|
reader = bytes.NewReader(data)
|
|
} else if decodedPhoto, err := base64.StdEncoding.DecodeString(pictureString); err == nil {
|
|
// If the photo is a base64 encoded string, decode it
|
|
reader = bytes.NewReader(decodedPhoto)
|
|
} else {
|
|
// If the photo is a string, we assume that it's a binary string
|
|
reader = bytes.NewReader([]byte(pictureString))
|
|
}
|
|
|
|
// Update the profile picture
|
|
err = s.userService.UpdateProfilePicture(parentCtx, userId, reader)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update profile picture: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// normalizeLDAPDN returns a canonical lowercase form of a DN for use as a map key.
|
|
// Different LDAP servers may format the same DN with varying attribute type casing (e.g. "CN=" vs "cn=") or extra whitespace (e.g. "dc=example, dc=com").
|
|
// Without normalization, cache lookups in usernamesByDN would miss when a member attribute value uses a different format than the DN returned in the search entry
|
|
//
|
|
// ldap.ParseDN is used instead of simple lowercasing because it correctly handles multi-valued RDNs (joined with "+") and strips inter-component whitespace.
|
|
// If parsing fails for any reason, we fall back to a simple lowercase+trim.
|
|
func normalizeLDAPDN(dn string) string {
|
|
parsed, err := ldap.ParseDN(dn)
|
|
if err != nil {
|
|
return strings.ToLower(strings.TrimSpace(dn))
|
|
}
|
|
|
|
// Reconstruct the DN in a canonical form: lowercase type=lowercase value, with RDN components separated by "," and multi-value attributes by "+"
|
|
parts := make([]string, 0, len(parsed.RDNs))
|
|
for _, rdn := range parsed.RDNs {
|
|
attrs := make([]string, 0, len(rdn.Attributes))
|
|
for _, attr := range rdn.Attributes {
|
|
attrs = append(attrs, strings.ToLower(attr.Type)+"="+strings.ToLower(attr.Value))
|
|
}
|
|
parts = append(parts, strings.Join(attrs, "+"))
|
|
}
|
|
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// getDNProperty returns the value of a property from a LDAP identifier
|
|
// See: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names
|
|
func getDNProperty(property string, str string) string {
|
|
// Example format is "CN=username,ou=people,dc=example,dc=com"
|
|
// First we split at the comma
|
|
property = strings.ToLower(property)
|
|
l := len(property) + 1
|
|
for v := range strings.SplitSeq(str, ",") {
|
|
v = strings.TrimSpace(v)
|
|
if len(v) > l && strings.ToLower(v)[0:l] == property+"=" {
|
|
return v[l:]
|
|
}
|
|
}
|
|
|
|
// CN not found, return an empty string
|
|
return ""
|
|
}
|
|
|
|
// convertLdapIdToString converts LDAP IDs to valid UTF-8 strings.
|
|
// LDAP servers may return binary UUIDs (16 bytes) or other non-UTF-8 data.
|
|
func convertLdapIdToString(ldapId string) string {
|
|
if utf8.ValidString(ldapId) {
|
|
return norm.NFC.String(ldapId)
|
|
}
|
|
|
|
// Try to parse as binary UUID (16 bytes)
|
|
if len(ldapId) == 16 {
|
|
if parsedUUID, err := uuid.FromBytes([]byte(ldapId)); err == nil {
|
|
return parsedUUID.String()
|
|
}
|
|
}
|
|
|
|
// As a last resort, encode as base64 to make it UTF-8 safe
|
|
return base64.StdEncoding.EncodeToString([]byte(ldapId))
|
|
}
|