mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-17 08:10:20 +00:00
Compare commits
9 Commits
main
...
copilot/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c63ef67c2c | ||
|
|
e827f80496 | ||
|
|
a42dc57358 | ||
|
|
881a2c64be | ||
|
|
058ac29b8f | ||
|
|
62e7882493 | ||
|
|
6615ad64a4 | ||
|
|
6bc304dd7f | ||
|
|
33263de1a8 |
106
.github/workflows/pr-quality.yml
vendored
106
.github/workflows/pr-quality.yml
vendored
@@ -1,106 +0,0 @@
|
||||
name: PR Quality
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
pr-quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
with:
|
||||
# General Settings
|
||||
max-failures: 4
|
||||
|
||||
# PR Branch Checks
|
||||
allowed-target-branches: "main"
|
||||
blocked-target-branches: ""
|
||||
allowed-source-branches: ""
|
||||
blocked-source-branches: ""
|
||||
|
||||
# PR Quality Checks
|
||||
max-negative-reactions: 0
|
||||
require-maintainer-can-modify: true
|
||||
|
||||
# PR Title Checks
|
||||
require-conventional-title: true
|
||||
|
||||
# PR Description Checks
|
||||
require-description: true
|
||||
max-description-length: 2500
|
||||
max-emoji-count: 0
|
||||
max-code-references: 0
|
||||
require-linked-issue: false
|
||||
blocked-terms: ""
|
||||
blocked-issue-numbers: ""
|
||||
|
||||
# PR Template Checks
|
||||
require-pr-template: true
|
||||
strict-pr-template-sections: ""
|
||||
optional-pr-template-sections: "Issues"
|
||||
max-additional-pr-template-sections: 3
|
||||
|
||||
# Commit Message Checks
|
||||
max-commit-message-length: 500
|
||||
require-conventional-commits: false
|
||||
require-commit-author-match: true
|
||||
blocked-commit-authors: ""
|
||||
|
||||
# File Checks
|
||||
allowed-file-extensions: ""
|
||||
allowed-paths: ""
|
||||
blocked-paths: |
|
||||
SECURITY.md
|
||||
LICENSE
|
||||
require-final-newline: false
|
||||
max-added-comments: 0
|
||||
|
||||
# User Checks
|
||||
detect-spam-usernames: true
|
||||
min-account-age: 30
|
||||
max-daily-forks: 7
|
||||
min-profile-completeness: 4
|
||||
|
||||
# Merge Checks
|
||||
min-repo-merged-prs: 0
|
||||
min-repo-merge-ratio: 0
|
||||
min-global-merge-ratio: 30
|
||||
global-merge-ratio-exclude-own: false
|
||||
|
||||
# Exemptions
|
||||
exempt-draft-prs: false
|
||||
exempt-bots: |
|
||||
actions-user
|
||||
dependabot[bot]
|
||||
renovate[bot]
|
||||
github-actions[bot]
|
||||
exempt-users: ""
|
||||
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
|
||||
exempt-label: "quality/exempt"
|
||||
exempt-pr-label: ""
|
||||
exempt-all-milestones: false
|
||||
exempt-all-pr-milestones: false
|
||||
exempt-milestones: ""
|
||||
exempt-pr-milestones: ""
|
||||
|
||||
# PR Success Actions
|
||||
success-add-pr-labels: "quality/verified"
|
||||
|
||||
# PR Failure Actions
|
||||
failure-remove-pr-labels: ""
|
||||
failure-remove-all-pr-labels: true
|
||||
failure-add-pr-labels: "quality/rejected"
|
||||
failure-pr-message: |
|
||||
This PR did not pass quality checks so it will be closed.
|
||||
See the [workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}) for details on which checks failed.
|
||||
|
||||
If you believe this is a mistake please let us know.
|
||||
|
||||
close-pr: true
|
||||
lock-pr: false
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,35 +1,3 @@
|
||||
## v2.4.0
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- improve wildcard matching by using `go-urlpattern` ([#1332](https://github.com/pocket-id/pocket-id/pull/1332) by @stonith404)
|
||||
- federated client credentials not working if sub ≠ client_id ([#1342](https://github.com/pocket-id/pocket-id/pull/1342) by @ItalyPaleAle)
|
||||
- handle IPv6 addresses in callback URLs ([#1355](https://github.com/pocket-id/pocket-id/pull/1355) by @ItalyPaleAle)
|
||||
- wildcard callback URLs blocked by browser-native URL validation ([#1359](https://github.com/pocket-id/pocket-id/pull/1359) by @Copilot)
|
||||
- one-time-access-token route should get user ID from URL only ([#1358](https://github.com/pocket-id/pocket-id/pull/1358) by @ItalyPaleAle)
|
||||
- various fixes in background jobs ([#1362](https://github.com/pocket-id/pocket-id/pull/1362) by @ItalyPaleAle)
|
||||
- use URL keyboard type for callback URL inputs ([a675d07](https://github.com/pocket-id/pocket-id/commit/a675d075d1ab9b7ff8160f1cfc35bc0ea1f1980a) by @stonith404)
|
||||
|
||||
### Features
|
||||
|
||||
- allow first name and display name to be optional ([#1288](https://github.com/pocket-id/pocket-id/pull/1288) by @taoso)
|
||||
|
||||
### Other
|
||||
|
||||
- bump svelte from 5.53.2 to 5.53.5 in the npm_and_yarn group across 1 directory ([#1348](https://github.com/pocket-id/pocket-id/pull/1348) by @dependabot[bot])
|
||||
- bump @sveltejs/kit from 2.53.0 to 2.53.3 in the npm_and_yarn group across 1 directory ([#1349](https://github.com/pocket-id/pocket-id/pull/1349) by @dependabot[bot])
|
||||
- update AAGUIDs ([#1354](https://github.com/pocket-id/pocket-id/pull/1354) by @github-actions[bot])
|
||||
- add Português files ([01141b8](https://github.com/pocket-id/pocket-id/commit/01141b8c0f2e96a40fd876d3206e49a694fd12c4) by @kmendell)
|
||||
- add Latvian files ([e0fc4cc](https://github.com/pocket-id/pocket-id/commit/e0fc4cc01bd51e5a97e46aad78a493a668049220) by @kmendell)
|
||||
- fix wrong seed data ([e7bd66d](https://github.com/pocket-id/pocket-id/commit/e7bd66d1a77c89dde542b4385ba01dc0d432e434) by @stonith404)
|
||||
- fix wrong seed data in `database.json` ([f4eb8db](https://github.com/pocket-id/pocket-id/commit/f4eb8db50993edacd90e919b39a5c6d9dd4924c7) by @stonith404)
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
- frontend performance optimizations ([#1344](https://github.com/pocket-id/pocket-id/pull/1344) by @ItalyPaleAle)
|
||||
|
||||
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.3.0...v2.4.0
|
||||
|
||||
## v2.3.0
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -2,7 +2,6 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
@@ -62,9 +60,7 @@ func Bootstrap(ctx context.Context) error {
|
||||
}
|
||||
|
||||
waitUntil, err := svc.appLockService.Acquire(ctx, false)
|
||||
if errors.Is(err, service.ErrLockUnavailable) {
|
||||
return errors.New("it appears that there's already one instance of Pocket ID running; running multiple replicas of Pocket ID is currently not supported")
|
||||
} else if err != nil {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire application lock: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -119,10 +119,11 @@ func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error {
|
||||
defer cancel()
|
||||
|
||||
waitUntil, err := appLockService.Acquire(opCtx, force)
|
||||
if errors.Is(err, service.ErrLockUnavailable) {
|
||||
//nolint:staticcheck
|
||||
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
|
||||
} else if err != nil {
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLockUnavailable) {
|
||||
//nolint:staticcheck
|
||||
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
|
||||
}
|
||||
return fmt.Errorf("failed to acquire application lock: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -36,7 +34,6 @@ func NewAppImagesController(
|
||||
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
||||
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
|
||||
|
||||
group.DELETE("/application-images/background", authMiddleware.Add(), controller.deleteBackgroundImageHandler)
|
||||
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
|
||||
}
|
||||
|
||||
@@ -195,27 +192,12 @@ func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteBackgroundImageHandler godoc
|
||||
// @Summary Delete background image
|
||||
// @Description Delete the application background image
|
||||
// @Tags Application Images
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/background [delete]
|
||||
func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) {
|
||||
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "background"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// updateFaviconHandler godoc
|
||||
// @Summary Update favicon
|
||||
// @Description Update the application favicon
|
||||
// @Tags Application Images
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "Favicon file (.svg/.png/.ico)"
|
||||
// @Param file formData file true "Favicon file (.ico)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/favicon [put]
|
||||
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
|
||||
@@ -226,9 +208,8 @@ func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
mimeType := utils.GetImageMimeType(strings.ToLower(fileType))
|
||||
if !slices.Contains([]string{"image/svg+xml", "image/png", "image/x-icon"}, mimeType) {
|
||||
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"})
|
||||
if fileType != "ico" {
|
||||
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
|
||||
}
|
||||
|
||||
for _, key := range apiKeys {
|
||||
if key.User.Email == nil {
|
||||
if key.User.Email == nil || key.User.ID == "" {
|
||||
continue
|
||||
}
|
||||
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
|
||||
|
||||
@@ -19,25 +19,16 @@ import (
|
||||
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
||||
jobs := &DbCleanupJobs{db: db}
|
||||
|
||||
newBackOff := func() *backoff.ExponentialBackOff {
|
||||
bo := backoff.NewExponentialBackOff()
|
||||
bo.Multiplier = 4
|
||||
bo.RandomizationFactor = 0.1
|
||||
bo.InitialInterval = time.Second
|
||||
bo.MaxInterval = 45 * time.Second
|
||||
return bo
|
||||
}
|
||||
|
||||
// Use exponential backoff for each DB cleanup job so transient query failures are retried automatically rather than causing an immediate job failure
|
||||
return errors.Join(
|
||||
s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", jobDefWithJitter(24*time.Hour), jobs.clearOidcAuthorizationCodes, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: backoff.NewExponentialBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: backoff.NewExponentialBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: backoff.NewExponentialBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: backoff.NewExponentialBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", jobDefWithJitter(24*time.Hour), jobs.clearOidcAuthorizationCodes, service.RegisterJobOpts{RunImmediately: true, BackOff: backoff.NewExponentialBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: backoff.NewExponentialBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: backoff.NewExponentialBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: backoff.NewExponentialBackOff()}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -96,8 +96,7 @@ func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil tim
|
||||
|
||||
var prevLock lockValue
|
||||
if prevLockRaw != "" {
|
||||
err = prevLock.Unmarshal(prevLockRaw)
|
||||
if err != nil {
|
||||
if err := prevLock.Unmarshal(prevLockRaw); err != nil {
|
||||
return time.Time{}, fmt.Errorf("decode existing lock value: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -143,8 +142,7 @@ func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil tim
|
||||
return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error)
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
Nonce: "nonce",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[1].ID,
|
||||
ClientID: oidcClients[3].ID,
|
||||
ClientID: oidcClients[2].ID,
|
||||
},
|
||||
}
|
||||
for _, authCode := range authCodes {
|
||||
|
||||
@@ -35,7 +35,6 @@ type LdapService struct {
|
||||
userService *UserService
|
||||
groupService *UserGroupService
|
||||
fileStorage storage.FileStorage
|
||||
clientFactory func() (ldapClient, error)
|
||||
}
|
||||
|
||||
type savePicture struct {
|
||||
@@ -44,33 +43,8 @@ type savePicture struct {
|
||||
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, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
|
||||
service := &LdapService{
|
||||
return &LdapService{
|
||||
db: db,
|
||||
httpClient: httpClient,
|
||||
appConfigService: appConfigService,
|
||||
@@ -78,12 +52,9 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppC
|
||||
groupService: groupService,
|
||||
fileStorage: fileStorage,
|
||||
}
|
||||
|
||||
service.clientFactory = service.createClient
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *LdapService) createClient() (ldapClient, error) {
|
||||
func (s *LdapService) createClient() (*ldap.Conn, error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
if !dbConfig.LdapEnabled.IsTrue() {
|
||||
@@ -108,33 +79,24 @@ func (s *LdapService) createClient() (ldapClient, error) {
|
||||
|
||||
func (s *LdapService) SyncAll(ctx context.Context) error {
|
||||
// Setup LDAP connection
|
||||
client, err := s.clientFactory()
|
||||
client, err := s.createClient()
|
||||
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)
|
||||
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()
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
// Reconcile users
|
||||
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs)
|
||||
savePictures, deleteFiles, err := s.SyncUsers(ctx, tx, client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users: %w", err)
|
||||
}
|
||||
|
||||
// Reconcile groups
|
||||
err = s.reconcileGroups(ctx, tx, desiredState.groups, desiredState.groupIDs)
|
||||
err = s.SyncGroups(ctx, tx, client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync groups: %w", err)
|
||||
}
|
||||
@@ -167,59 +129,10 @@ func (s *LdapService) SyncAll(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) (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 {
|
||||
return ldapDesiredState{}, err
|
||||
}
|
||||
|
||||
// Then fetch groups to complete the desired LDAP state snapshot
|
||||
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN)
|
||||
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)
|
||||
|
||||
return ldapDesiredState{
|
||||
users: users,
|
||||
userIDs: userIDs,
|
||||
groups: groups,
|
||||
groupIDs: groupIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
if dbConfig.LdapAdminGroupName.Value == "" {
|
||||
return
|
||||
}
|
||||
|
||||
adminUsernames := make(map[string]struct{})
|
||||
for _, group := range desiredGroups {
|
||||
if group.input.Name != dbConfig.LdapAdminGroupName.Value {
|
||||
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) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
|
||||
//nolint:gocognit
|
||||
func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.Conn) error {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
// Query LDAP for all groups we want to manage
|
||||
searchAttrs := []string{
|
||||
dbConfig.LdapAttributeGroupName.Value,
|
||||
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
|
||||
@@ -236,42 +149,90 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
|
||||
)
|
||||
result, err := client.Search(searchReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to query LDAP groups: %w", err)
|
||||
return fmt.Errorf("failed to query LDAP: %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))
|
||||
// Create a mapping for groups that exist
|
||||
ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
|
||||
|
||||
for _, value := range result.Entries {
|
||||
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||
|
||||
// Skip groups without a valid LDAP ID
|
||||
if ldapID == "" {
|
||||
if ldapId == "" {
|
||||
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||
continue
|
||||
}
|
||||
|
||||
ldapGroupIDs[ldapID] = struct{}{}
|
||||
ldapGroupIDs[ldapId] = struct{}{}
|
||||
|
||||
// Try to find the group in the database
|
||||
var databaseGroup model.UserGroup
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("ldap_id = ?", ldapId).
|
||||
First(&databaseGroup).
|
||||
Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// This could error with ErrRecordNotFound and we want to ignore that here
|
||||
return fmt.Errorf("failed to query for LDAP group ID '%s': %w", ldapId, err)
|
||||
}
|
||||
|
||||
// Get group members and add to the correct Group
|
||||
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
|
||||
memberUsernames := make([]string, 0, len(groupMembers))
|
||||
membersUserId := make([]string, 0, len(groupMembers))
|
||||
for _, member := range groupMembers {
|
||||
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN)
|
||||
username := getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
|
||||
|
||||
// If username extraction fails, try to query LDAP directly for the user
|
||||
if username == "" {
|
||||
continue
|
||||
// Query LDAP to get the user by their DN
|
||||
userSearchReq := ldap.NewSearchRequest(
|
||||
member,
|
||||
ldap.ScopeBaseObject,
|
||||
0, 0, 0, false,
|
||||
"(objectClass=*)",
|
||||
[]string{dbConfig.LdapAttributeUserUsername.Value, dbConfig.LdapAttributeUserUniqueIdentifier.Value},
|
||||
[]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))
|
||||
continue
|
||||
}
|
||||
|
||||
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
|
||||
if username == "" {
|
||||
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
memberUsernames = append(memberUsernames, username)
|
||||
username = norm.NFC.String(username)
|
||||
|
||||
var databaseUser model.User
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("username = ? AND ldap_id IS NOT NULL", username).
|
||||
First(&databaseUser).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// The user collides with a non-LDAP user, so we skip it
|
||||
continue
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to query for existing user '%s': %w", username, err)
|
||||
}
|
||||
|
||||
membersUserId = append(membersUserId, databaseUser.ID)
|
||||
}
|
||||
|
||||
syncGroup := dto.UserGroupCreateDto{
|
||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
LdapID: ldapID,
|
||||
LdapID: ldapId,
|
||||
}
|
||||
dto.Normalize(&syncGroup)
|
||||
dto.Normalize(syncGroup)
|
||||
|
||||
err = syncGroup.Validate()
|
||||
if err != nil {
|
||||
@@ -279,21 +240,66 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
|
||||
continue
|
||||
}
|
||||
|
||||
desiredGroups = append(desiredGroups, ldapDesiredGroup{
|
||||
ldapID: ldapID,
|
||||
input: syncGroup,
|
||||
memberUsernames: memberUsernames,
|
||||
})
|
||||
if databaseGroup.ID == "" {
|
||||
newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create group '%s': %w", syncGroup.Name, err)
|
||||
}
|
||||
|
||||
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, membersUserId, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users for group '%s': %w", syncGroup.Name, err)
|
||||
}
|
||||
} else {
|
||||
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, syncGroup, true, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update group '%s': %w", syncGroup.Name, err)
|
||||
}
|
||||
|
||||
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, membersUserId, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users for group '%s': %w", syncGroup.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return desiredGroups, ldapGroupIDs, nil
|
||||
// Get all LDAP groups from the database
|
||||
var ldapGroupsInDb []model.UserGroup
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Find(&ldapGroupsInDb, "ldap_id IS NOT NULL").
|
||||
Select("ldap_id").
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch groups from database: %w", err)
|
||||
}
|
||||
|
||||
// Delete groups that no longer exist in LDAP
|
||||
for _, group := range ldapGroupsInDb {
|
||||
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
|
||||
}
|
||||
|
||||
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
|
||||
//nolint:gocognit
|
||||
func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.Conn) (savePictures []savePicture, deleteFiles []string, err error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
// Query LDAP for all users we want to manage
|
||||
searchAttrs := []string{
|
||||
"memberOf",
|
||||
"sn",
|
||||
"cn",
|
||||
dbConfig.LdapAttributeUserUniqueIdentifier.Value,
|
||||
@@ -317,29 +323,59 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
|
||||
|
||||
result, err := client.Search(searchReq)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to query LDAP users: %w", err)
|
||||
return nil, nil, fmt.Errorf("failed to query LDAP: %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))
|
||||
// Create a mapping for users that exist
|
||||
ldapUserIDs := make(map[string]struct{}, len(result.Entries))
|
||||
savePictures = make([]savePicture, 0, len(result.Entries))
|
||||
|
||||
for _, value := range result.Entries {
|
||||
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value))
|
||||
if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" {
|
||||
usernamesByDN[normalizedDN] = username
|
||||
}
|
||||
|
||||
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||
|
||||
// Skip users without a valid LDAP ID
|
||||
if ldapID == "" {
|
||||
if ldapId == "" {
|
||||
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||
continue
|
||||
}
|
||||
|
||||
ldapUserIDs[ldapID] = struct{}{}
|
||||
ldapUserIDs[ldapId] = struct{}{}
|
||||
|
||||
// Get the user from the database
|
||||
var databaseUser model.User
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("ldap_id = ?", ldapId).
|
||||
First(&databaseUser).
|
||||
Error
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// This could error with ErrRecordNotFound and we want to ignore that here
|
||||
return nil, nil, fmt.Errorf("failed to query for LDAP user ID '%s': %w", ldapId, err)
|
||||
}
|
||||
|
||||
// Check if user is admin by checking if they are in the admin group
|
||||
isAdmin := false
|
||||
for _, group := range value.GetAttributeValues("memberOf") {
|
||||
if getDNProperty(dbConfig.LdapAttributeGroupName.Value, group) == dbConfig.LdapAdminGroupName.Value {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
newUser := dto.UserCreateDto{
|
||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||
@@ -348,17 +384,15 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
|
||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||
// 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,
|
||||
IsAdmin: isAdmin,
|
||||
LdapID: ldapId,
|
||||
}
|
||||
|
||||
if newUser.DisplayName == "" {
|
||||
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
|
||||
}
|
||||
|
||||
dto.Normalize(&newUser)
|
||||
dto.Normalize(newUser)
|
||||
|
||||
err = newUser.Validate()
|
||||
if err != nil {
|
||||
@@ -366,201 +400,53 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
|
||||
continue
|
||||
}
|
||||
|
||||
desiredUsers = append(desiredUsers, ldapDesiredUser{
|
||||
ldapID: ldapID,
|
||||
input: newUser,
|
||||
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value),
|
||||
})
|
||||
}
|
||||
|
||||
return desiredUsers, ldapUserIDs, usernamesByDN, nil
|
||||
}
|
||||
|
||||
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string) string {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
// 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(dbConfig.LdapAttributeUserUsername.Value, member)
|
||||
if username != "" {
|
||||
return norm.NFC.String(username)
|
||||
}
|
||||
|
||||
// As a fallback, query LDAP for the referenced entry
|
||||
userSearchReq := ldap.NewSearchRequest(
|
||||
member,
|
||||
ldap.ScopeBaseObject,
|
||||
0, 0, 0, false,
|
||||
"(objectClass=*)",
|
||||
[]string{dbConfig.LdapAttributeUserUsername.Value},
|
||||
[]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(dbConfig.LdapAttributeUserUsername.Value)
|
||||
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{}) 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)
|
||||
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{}) (savePictures []savePicture, deleteFiles []string, err error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
// 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)
|
||||
createdUser, err := s.userService.createUserInternal(ctx, newUser, true, tx)
|
||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||
slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
|
||||
slog.Warn("Skipping creating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, nil, fmt.Errorf("error creating user '%s': %w", desiredUser.input.Username, err)
|
||||
return nil, nil, fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
|
||||
}
|
||||
|
||||
userID = createdUser.ID
|
||||
ldapUsersByID[desiredUser.ldapID] = createdUser
|
||||
} else {
|
||||
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx)
|
||||
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
|
||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||
slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
|
||||
slog.Warn("Skipping updating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, nil, fmt.Errorf("error updating user '%s': %w", desiredUser.input.Username, err)
|
||||
return nil, nil, fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
|
||||
}
|
||||
}
|
||||
|
||||
if desiredUser.picture != "" {
|
||||
// Save profile picture
|
||||
pictureString := value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value)
|
||||
if pictureString != "" {
|
||||
// Storage operations must be executed outside of a transaction
|
||||
savePictures = append(savePictures, savePicture{
|
||||
userID: userID,
|
||||
username: desiredUser.input.Username,
|
||||
picture: desiredUser.picture,
|
||||
userID: databaseUser.ID,
|
||||
username: userID,
|
||||
picture: pictureString,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// Get all LDAP users from the database
|
||||
var ldapUsersInDb []model.User
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Find(&ldapUsersInDb, "ldap_id IS NOT NULL").
|
||||
Select("id, username, ldap_id, disabled").
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to fetch users from database: %w", err)
|
||||
}
|
||||
|
||||
// Mark users as disabled or delete users that no longer exist in LDAP
|
||||
deleteFiles = make([]string, 0, len(ldapUserIDs))
|
||||
for _, user := range ldapUsersInDb {
|
||||
// Skip if the user ID exists in the fetched LDAP results
|
||||
if _, exists := ldapUserIDs[*user.LdapID]; exists {
|
||||
continue
|
||||
}
|
||||
@@ -572,73 +458,29 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
|
||||
}
|
||||
|
||||
slog.Info("Disabled user", slog.String("username", user.Username))
|
||||
continue
|
||||
}
|
||||
|
||||
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
|
||||
if err != nil {
|
||||
target := &common.LdapUserUpdateError{}
|
||||
if errors.As(err, &target) {
|
||||
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
|
||||
} else {
|
||||
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
|
||||
if err != nil {
|
||||
target := &common.LdapUserUpdateError{}
|
||||
if errors.As(err, &target) {
|
||||
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)
|
||||
}
|
||||
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"))
|
||||
slog.Info("Deleted user", slog.String("username", user.Username))
|
||||
|
||||
// Storage operations must be executed outside of a transaction
|
||||
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)
|
||||
@@ -680,31 +522,6 @@ func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId strin
|
||||
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 {
|
||||
|
||||
@@ -1,368 +1,9 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
type fakeLDAPClient struct {
|
||||
searchFn func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
|
||||
}
|
||||
|
||||
func (c *fakeLDAPClient) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
if c.searchFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return c.searchFn(searchRequest)
|
||||
}
|
||||
|
||||
func (c *fakeLDAPClient) Bind(_, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeLDAPClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
|
||||
service, db := newTestLdapService(t, newFakeLDAPClient(
|
||||
ldapSearchResult(
|
||||
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-alice"},
|
||||
"uid": {"alice"},
|
||||
"mail": {"alice@example.com"},
|
||||
"givenName": {"Alice"},
|
||||
"sn": {"Jones"},
|
||||
"displayName": {""},
|
||||
}),
|
||||
ldapEntry("uid=bob,ou=people,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-bob"},
|
||||
"uid": {"bob"},
|
||||
"mail": {"bob@example.com"},
|
||||
"givenName": {"Bob"},
|
||||
"sn": {"Brown"},
|
||||
"displayName": {""},
|
||||
}),
|
||||
),
|
||||
ldapSearchResult(
|
||||
ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-admins"},
|
||||
"cn": {"admins"},
|
||||
"member": {"uid=alice,ou=people,dc=example,dc=com"},
|
||||
}),
|
||||
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-team"},
|
||||
"cn": {"team"},
|
||||
"member": {
|
||||
"UID=Alice, OU=People, DC=example, DC=com",
|
||||
"uid=bob, ou=people, dc=example, dc=com",
|
||||
},
|
||||
}),
|
||||
),
|
||||
))
|
||||
|
||||
aliceLdapID := "u-alice"
|
||||
missingLdapID := "u-missing"
|
||||
teamLdapID := "g-team"
|
||||
oldGroupLdapID := "g-old"
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Username: "alice-old",
|
||||
Email: new("alice-old@example.com"),
|
||||
EmailVerified: true,
|
||||
FirstName: "Old",
|
||||
LastName: "Name",
|
||||
DisplayName: "Old Name",
|
||||
LdapID: &aliceLdapID,
|
||||
Disabled: true,
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Username: "missing",
|
||||
Email: new("missing@example.com"),
|
||||
EmailVerified: true,
|
||||
FirstName: "Missing",
|
||||
LastName: "User",
|
||||
DisplayName: "Missing User",
|
||||
LdapID: &missingLdapID,
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, db.Create(&model.UserGroup{
|
||||
Name: "team-old",
|
||||
FriendlyName: "team-old",
|
||||
LdapID: &teamLdapID,
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, db.Create(&model.UserGroup{
|
||||
Name: "old-group",
|
||||
FriendlyName: "old-group",
|
||||
LdapID: &oldGroupLdapID,
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, service.SyncAll(t.Context()))
|
||||
|
||||
var alice model.User
|
||||
require.NoError(t, db.First(&alice, "ldap_id = ?", aliceLdapID).Error)
|
||||
assert.Equal(t, "alice", alice.Username)
|
||||
assert.Equal(t, new("alice@example.com"), alice.Email)
|
||||
assert.Equal(t, "Alice", alice.FirstName)
|
||||
assert.Equal(t, "Jones", alice.LastName)
|
||||
assert.Equal(t, "Alice Jones", alice.DisplayName)
|
||||
assert.True(t, alice.IsAdmin)
|
||||
assert.False(t, alice.Disabled)
|
||||
|
||||
var bob model.User
|
||||
require.NoError(t, db.First(&bob, "ldap_id = ?", "u-bob").Error)
|
||||
assert.Equal(t, "bob", bob.Username)
|
||||
assert.Equal(t, "Bob Brown", bob.DisplayName)
|
||||
|
||||
var missing model.User
|
||||
require.NoError(t, db.First(&missing, "ldap_id = ?", missingLdapID).Error)
|
||||
assert.True(t, missing.Disabled)
|
||||
|
||||
var oldGroupCount int64
|
||||
require.NoError(t, db.Model(&model.UserGroup{}).Where("ldap_id = ?", oldGroupLdapID).Count(&oldGroupCount).Error)
|
||||
assert.Zero(t, oldGroupCount)
|
||||
|
||||
var team model.UserGroup
|
||||
require.NoError(t, db.Preload("Users").First(&team, "ldap_id = ?", teamLdapID).Error)
|
||||
assert.Equal(t, "team", team.Name)
|
||||
assert.Equal(t, "team", team.FriendlyName)
|
||||
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(team.Users))
|
||||
}
|
||||
|
||||
func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) {
|
||||
service, db := newTestLdapService(t, newFakeLDAPClient(
|
||||
ldapSearchResult(
|
||||
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-dup"},
|
||||
"uid": {"alice"},
|
||||
"mail": {"alice@example.com"},
|
||||
"givenName": {"Alice"},
|
||||
"sn": {"Doe"},
|
||||
"displayName": {"Alice Doe"},
|
||||
}),
|
||||
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-dup"},
|
||||
"uid": {"alice"},
|
||||
"mail": {"alice@example.com"},
|
||||
"givenName": {"Alicia"},
|
||||
"sn": {"Doe"},
|
||||
"displayName": {"Alicia Doe"},
|
||||
}),
|
||||
),
|
||||
ldapSearchResult(
|
||||
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-dup"},
|
||||
"cn": {"team"},
|
||||
"member": {"uid=alice,ou=people,dc=example,dc=com"},
|
||||
}),
|
||||
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-dup"},
|
||||
"cn": {"team-renamed"},
|
||||
"member": {"uid=alice,ou=people,dc=example,dc=com"},
|
||||
}),
|
||||
),
|
||||
))
|
||||
|
||||
require.NoError(t, service.SyncAll(t.Context()))
|
||||
|
||||
var users []model.User
|
||||
require.NoError(t, db.Find(&users, "ldap_id = ?", "u-dup").Error)
|
||||
require.Len(t, users, 1)
|
||||
assert.Equal(t, "alice", users[0].Username)
|
||||
assert.Equal(t, "Alicia", users[0].FirstName)
|
||||
assert.Equal(t, "Alicia Doe", users[0].DisplayName)
|
||||
|
||||
var groups []model.UserGroup
|
||||
require.NoError(t, db.Preload("Users").Find(&groups, "ldap_id = ?", "g-dup").Error)
|
||||
require.Len(t, groups, 1)
|
||||
assert.Equal(t, "team-renamed", groups[0].Name)
|
||||
assert.Equal(t, "team-renamed", groups[0].FriendlyName)
|
||||
assert.ElementsMatch(t, []string{"alice"}, usernames(groups[0].Users))
|
||||
}
|
||||
|
||||
func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appConfig *model.AppConfig
|
||||
groupEntry *ldap.Entry
|
||||
groupName string
|
||||
groupLookup string
|
||||
}{
|
||||
{
|
||||
name: "memberOf missing on user",
|
||||
appConfig: defaultTestLDAPAppConfig(),
|
||||
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-admins"},
|
||||
"cn": {"admins"},
|
||||
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
|
||||
}),
|
||||
groupName: "admins",
|
||||
groupLookup: "g-admins",
|
||||
},
|
||||
{
|
||||
name: "configured group name attribute differs from DN RDN",
|
||||
appConfig: func() *model.AppConfig {
|
||||
cfg := defaultTestLDAPAppConfig()
|
||||
cfg.LdapAttributeGroupName = model.AppConfigVariable{Value: "displayName"}
|
||||
cfg.LdapAdminGroupName = model.AppConfigVariable{Value: "pocketid.admin"}
|
||||
return cfg
|
||||
}(),
|
||||
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-display-admins"},
|
||||
"cn": {"admins"},
|
||||
"displayName": {"pocketid.admin"},
|
||||
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
|
||||
}),
|
||||
groupName: "pocketid.admin",
|
||||
groupLookup: "g-display-admins",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service, db := newTestLdapServiceWithAppConfig(t, tt.appConfig, newFakeLDAPClient(
|
||||
ldapSearchResult(
|
||||
ldapEntry("uid=testadmin,ou=people,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-testadmin"},
|
||||
"uid": {"testadmin"},
|
||||
"mail": {"testadmin@example.com"},
|
||||
"givenName": {"Test"},
|
||||
"sn": {"Admin"},
|
||||
"displayName": {""},
|
||||
}),
|
||||
),
|
||||
ldapSearchResult(tt.groupEntry),
|
||||
))
|
||||
|
||||
require.NoError(t, service.SyncAll(t.Context()))
|
||||
|
||||
var user model.User
|
||||
require.NoError(t, db.First(&user, "ldap_id = ?", "u-testadmin").Error)
|
||||
assert.True(t, user.IsAdmin)
|
||||
|
||||
var group model.UserGroup
|
||||
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", tt.groupLookup).Error)
|
||||
assert.Equal(t, tt.groupName, group.Name)
|
||||
assert.ElementsMatch(t, []string{"testadmin"}, usernames(group.Users))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newTestLdapService(t *testing.T, client ldapClient) (*LdapService, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
return newTestLdapServiceWithAppConfig(t, defaultTestLDAPAppConfig(), client)
|
||||
}
|
||||
|
||||
func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConfig, client ldapClient) (*LdapService, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
fileStorage, err := storage.NewDatabaseStorage(db)
|
||||
require.NoError(t, err)
|
||||
|
||||
appConfig := NewTestAppConfigService(appConfigModel)
|
||||
|
||||
groupService := NewUserGroupService(db, appConfig, nil)
|
||||
userService := NewUserService(
|
||||
db,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
appConfig,
|
||||
NewCustomClaimService(db),
|
||||
NewAppImagesService(map[string]string{}, fileStorage),
|
||||
nil,
|
||||
fileStorage,
|
||||
)
|
||||
|
||||
service := NewLdapService(db, &http.Client{}, appConfig, userService, groupService, fileStorage)
|
||||
service.clientFactory = func() (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 newFakeLDAPClient(userResult, groupResult *ldap.SearchResult) ldapClient {
|
||||
return &fakeLDAPClient{
|
||||
searchFn: func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
switch searchRequest.Filter {
|
||||
case "(objectClass=person)":
|
||||
return userResult, nil
|
||||
case "(objectClass=groupOfNames)":
|
||||
return groupResult, nil
|
||||
default:
|
||||
return &ldap.SearchResult{}, nil
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ldapSearchResult(entries ...*ldap.Entry) *ldap.SearchResult {
|
||||
return &ldap.SearchResult{Entries: entries}
|
||||
}
|
||||
|
||||
func ldapEntry(dn string, attrs map[string][]string) *ldap.Entry {
|
||||
entry := &ldap.Entry{
|
||||
DN: dn,
|
||||
Attributes: make([]*ldap.EntryAttribute, 0, len(attrs)),
|
||||
}
|
||||
|
||||
for name, values := range attrs {
|
||||
entry.Attributes = append(entry.Attributes, &ldap.EntryAttribute{
|
||||
Name: name,
|
||||
Values: values,
|
||||
})
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func usernames(users []model.User) []string {
|
||||
result := make([]string, 0, len(users))
|
||||
for _, user := range users {
|
||||
result = append(result, user.Username)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func TestGetDNProperty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -423,58 +64,10 @@ func TestGetDNProperty(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getDNProperty(tt.property, tt.dn)
|
||||
assert.Equalf(t, tt.expectedResult, result, "getDNProperty(%q, %q)", tt.property, tt.dn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLDAPDN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "already normalized",
|
||||
input: "cn=alice,dc=example,dc=com",
|
||||
expected: "cn=alice,dc=example,dc=com",
|
||||
},
|
||||
{
|
||||
name: "uppercase attribute types",
|
||||
input: "CN=Alice,DC=example,DC=com",
|
||||
expected: "cn=alice,dc=example,dc=com",
|
||||
},
|
||||
{
|
||||
name: "spaces after commas",
|
||||
input: "cn=alice, dc=example, dc=com",
|
||||
expected: "cn=alice,dc=example,dc=com",
|
||||
},
|
||||
{
|
||||
name: "uppercase types and spaces",
|
||||
input: "CN=Alice, DC=example, DC=com",
|
||||
expected: "cn=alice,dc=example,dc=com",
|
||||
},
|
||||
{
|
||||
name: "multi-valued RDN",
|
||||
input: "cn=alice+uid=a123,dc=example,dc=com",
|
||||
expected: "cn=alice+uid=a123,dc=example,dc=com",
|
||||
},
|
||||
{
|
||||
name: "invalid DN falls back to lowercase+trim",
|
||||
input: " NOT A VALID DN ",
|
||||
expected: "not a valid dn",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := normalizeLDAPDN(tt.input)
|
||||
assert.Equalf(t, tt.expected, result, "normalizeLDAPDN(%q)", tt.input)
|
||||
if result != tt.expectedResult {
|
||||
t.Errorf("getDNProperty(%q, %q) = %q, want %q",
|
||||
tt.property, tt.dn, result, tt.expectedResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -505,7 +98,9 @@ func TestConvertLdapIdToString(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := convertLdapIdToString(tt.input)
|
||||
assert.Equal(t, tt.expected, got)
|
||||
if got != tt.expected {
|
||||
t.Errorf("Expected %q, got %q", tt.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +404,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
|
||||
}
|
||||
}
|
||||
|
||||
if authorizationCodeMetaData.ClientID != input.ClientID || authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||
if authorizationCodeMetaData.ClientID != input.ClientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||
return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,10 +96,7 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -129,10 +126,7 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -181,10 +175,7 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -247,10 +238,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -327,9 +315,6 @@ func (s *UserGroupService) UpdateAllowedOidcClient(ctx context.Context, id strin
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -225,10 +225,7 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -313,10 +310,7 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
}
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -462,10 +456,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
return user, err
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -524,10 +515,7 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -588,10 +576,7 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
|
||||
return err
|
||||
}
|
||||
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
|
||||
"authorize": "Autorizovat",
|
||||
"federated_client_credentials": "Údaje o klientovi ve federaci",
|
||||
"federated_client_credentials_description": "Federované klientské přihlašovací údaje umožňují ověřování klientů OIDC bez správy dlouhodobých tajných klíčů. Využívají tokeny JWT vydané třetími stranami pro klientská tvrzení, např. tokeny identity pracovního zatížení.",
|
||||
"add_federated_client_credential": "Přidat údaje federovaného klienta",
|
||||
"add_another_federated_client_credential": "Přidat dalšího federovaného klienta",
|
||||
"oidc_allowed_group_count": "Počet povolených skupin",
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
"login_code_email_success": "Loginkoden er sendt til brugeren.",
|
||||
"send_email": "Send e-mail",
|
||||
"show_code": "Vis kode",
|
||||
"callback_url_description": "URL(er) angivet af din klient. Tilføjes automatisk, hvis feltet efterlades tomt. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> understøttes.",
|
||||
"callback_url_description": "URL(er) angivet af din klient. Tilføjes automatisk, hvis feltet efterlades tomt. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Jokertegn</link> understøttes.",
|
||||
"logout_callback_url_description": "URL(er) angivet af din klient til logout. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> understøttes.",
|
||||
"api_key_expiration": "Udløb af API-nøgle",
|
||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send en e-mail til brugeren, når deres API-nøgle er ved at udløbe.",
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Indtast koden, der blev vist i det forrige trin.",
|
||||
"authorize": "Godkend",
|
||||
"federated_client_credentials": "Federated klientlegitimationsoplysninger",
|
||||
"federated_client_credentials_description": "Federerede klientlegitimationsoplysninger gør det muligt at autentificere OIDC-klienter uden at skulle administrere langvarige hemmeligheder. De udnytter JWT-tokens udstedt af tredjepartsmyndigheder til klientpåstande, f.eks. identitetstokens for arbejdsbelastning.",
|
||||
"add_federated_client_credential": "Tilføj federated klientlegitimation",
|
||||
"add_another_federated_client_credential": "Tilføj endnu en federated klientlegitimation",
|
||||
"oidc_allowed_group_count": "Tilladt antal grupper",
|
||||
@@ -446,7 +445,7 @@
|
||||
"no_apps_available": "Ingen apps tilgængelige",
|
||||
"contact_your_administrator_for_app_access": "Kontakt din administrator for at få adgang til applikationer.",
|
||||
"launch": "Start",
|
||||
"client_launch_url": "Start-URL til klient",
|
||||
"client_launch_url": "Kundens lancerings-URL",
|
||||
"client_launch_url_description": "Den URL, der åbnes, når en bruger starter appen fra siden Mine apps.",
|
||||
"client_name_description": "Navnet på den klient, der vises i Pocket ID-brugergrænsefladen.",
|
||||
"revoke_access": "Tilbagekald adgang",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
|
||||
"authorize": "Autorisieren",
|
||||
"federated_client_credentials": "Federated Client Credentials",
|
||||
"federated_client_credentials_description": "Mit föderierten Client-Anmeldeinfos kann man OIDC-Clients authentifizieren, ohne sich um langlebige Geheimnisse kümmern zu müssen. Sie nutzen JWT-Token, die von Drittanbietern für Client-Assertions ausgestellt werden, z. B. Workload-Identitätstoken.",
|
||||
"add_federated_client_credential": "Föderierte Client-Anmeldeinfos hinzufügen",
|
||||
"add_another_federated_client_credential": "Weitere Anmeldeinformationen für einen Verbundclient hinzufügen",
|
||||
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Introduce el código que se mostró en el paso anterior.",
|
||||
"authorize": "Autorizar",
|
||||
"federated_client_credentials": "Credenciales de cliente federadas",
|
||||
"federated_client_credentials_description": "Las credenciales de cliente federadas permiten autenticar clientes OIDC sin gestionar secretos de larga duración. Aprovechan los tokens JWT emitidos por autoridades externas para las afirmaciones de los clientes, por ejemplo, tokens de identidad de carga de trabajo.",
|
||||
"add_federated_client_credential": "Añadir credenciales de cliente federado",
|
||||
"add_another_federated_client_credential": "Añadir otra credencial de cliente federado",
|
||||
"oidc_allowed_group_count": "Recuento de grupos permitidos",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"my_account": "Minu konto",
|
||||
"my_account": "My Account",
|
||||
"logout": "Logout",
|
||||
"confirm": "Confirm",
|
||||
"docs": "Docs",
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
||||
"authorize": "Authorize",
|
||||
"federated_client_credentials": "Federated Client Credentials",
|
||||
"federated_client_credentials_description": "Föderatiivsed kliendi autentimisandmed võimaldavad OIDC-kliente autentida ilma pikaajalisi salajasi andmeid haldamata. Need kasutavad kolmandate osapoolte poolt väljastatud JWT-tokeneid kliendi kinnituste jaoks, nt töökoormuse identiteeditokeneid.",
|
||||
"add_federated_client_credential": "Add Federated Client Credential",
|
||||
"add_another_federated_client_credential": "Add another federated client credential",
|
||||
"oidc_allowed_group_count": "Allowed Group Count",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Syötä edellisessä vaiheessa näkynyt koodi.",
|
||||
"authorize": "Salli",
|
||||
"federated_client_credentials": "Federoidut asiakastunnukset",
|
||||
"federated_client_credentials_description": "Yhdistetyt asiakastunnistetiedot mahdollistavat OIDC-asiakkaiden todentamisen ilman pitkäaikaisten salaisuuksien hallintaa. Ne hyödyntävät kolmansien osapuolten viranomaisten myöntämiä JWT-tunnuksia asiakastodistuksiin, esimerkiksi työkuorman tunnistetunnuksiin.",
|
||||
"add_federated_client_credential": "Lisää federoitu asiakastunnus",
|
||||
"add_another_federated_client_credential": "Lisää toinen federoitu asiakastunnus",
|
||||
"oidc_allowed_group_count": "Sallittujen ryhmien määrä",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Entrez le code affiché à l'étape précédente.",
|
||||
"authorize": "Autoriser",
|
||||
"federated_client_credentials": "Identifiants client fédérés",
|
||||
"federated_client_credentials_description": "Les informations d'identification client fédérées permettent d'authentifier les clients OIDC sans avoir à gérer des secrets à long terme. Elles utilisent des jetons JWT émis par des autorités tierces pour les assertions client, par exemple des jetons d'identité de charge de travail.",
|
||||
"add_federated_client_credential": "Ajouter un identifiant client fédéré",
|
||||
"add_another_federated_client_credential": "Ajouter un autre identifiant client fédéré",
|
||||
"oidc_allowed_group_count": "Nombre de groupes autorisés",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.",
|
||||
"authorize": "Autorizza",
|
||||
"federated_client_credentials": "Identità Federate",
|
||||
"federated_client_credentials_description": "Le credenziali client federate ti permettono di autenticare i client OIDC senza dover gestire segreti a lungo termine. Usano i token JWT rilasciati da autorità terze per le asserzioni dei client, tipo i token di identità del carico di lavoro.",
|
||||
"add_federated_client_credential": "Aggiungi Identità Federata",
|
||||
"add_another_federated_client_credential": "Aggiungi un'altra identità federata",
|
||||
"oidc_allowed_group_count": "Numero Gruppi Consentiti",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "前のステップで表示されたコードを入力してください。",
|
||||
"authorize": "Authorize",
|
||||
"federated_client_credentials": "連携クライアントの資格情報",
|
||||
"federated_client_credentials_description": "フェデレーテッドクライアント認証情報は、長期にわたるシークレットを管理せずにOIDCクライアントを認証することを可能にします。これらは、クライアントアサーション(例:ワークロードIDトークン)のためにサードパーティ機関が発行するJWTトークンを活用します。",
|
||||
"add_federated_client_credential": "Add Federated Client Credential",
|
||||
"add_another_federated_client_credential": "Add another federated client credential",
|
||||
"oidc_allowed_group_count": "許可されたグループ数",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "이전 단계에 표시된 코드를 입력하세요.",
|
||||
"authorize": "승인",
|
||||
"federated_client_credentials": "연동 클라이언트 자격 증명",
|
||||
"federated_client_credentials_description": "연방 클라이언트 자격 증명은 장기 비밀을 관리하지 않고도 OIDC 클라이언트를 인증할 수 있게 합니다. 이는 클라이언트 어설션(예: 워크로드 신원 토큰)을 위해 제3자 기관이 발급한 JWT 토큰을 활용합니다.",
|
||||
"add_federated_client_credential": "연동 클라이언트 자격 증명 추가",
|
||||
"add_another_federated_client_credential": "다른 연동 클라이언트 자격 증명 추가",
|
||||
"oidc_allowed_group_count": "허용된 그룹 수",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Voer de code in die in de vorige stap werd getoond.",
|
||||
"authorize": "Autoriseren",
|
||||
"federated_client_credentials": "Federatieve clientreferenties",
|
||||
"federated_client_credentials_description": "Met federatieve klantgegevens kun je OIDC-klanten verifiëren zonder dat je langdurige geheimen hoeft te beheren. Ze gebruiken JWT-tokens die door externe instanties zijn uitgegeven voor klantverklaringen, zoals tokens voor werkbelastingidentiteit.",
|
||||
"add_federated_client_credential": "Federatieve clientreferenties toevoegen",
|
||||
"add_another_federated_client_credential": "Voeg nog een federatieve clientreferentie toe",
|
||||
"oidc_allowed_group_count": "Aantal groepen met toegang",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
||||
"authorize": "Authorize",
|
||||
"federated_client_credentials": "Federated Client Credentials",
|
||||
"federated_client_credentials_description": "Federated client credentials allow authenticating OIDC clients without managing long-lived secrets. They leverage JWT tokens issued by third-party authorities for client assertions, e.g. workload identity tokens.",
|
||||
"add_federated_client_credential": "Add Federated Client Credential",
|
||||
"add_another_federated_client_credential": "Add another federated client credential",
|
||||
"oidc_allowed_group_count": "Allowed Group Count",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.",
|
||||
"authorize": "Autoryzuj",
|
||||
"federated_client_credentials": "Połączone poświadczenia klienta",
|
||||
"federated_client_credentials_description": "Połączone poświadczenia klienta umożliwiają uwierzytelnianie klientów OIDC bez konieczności zarządzania długotrwałymi sekretami. Wykorzystują one tokeny JWT wydane przez zewnętrzne organy do potwierdzania tożsamości klientów, np. tokeny tożsamości obciążenia.",
|
||||
"add_federated_client_credential": "Dodaj poświadczenia klienta federacyjnego",
|
||||
"add_another_federated_client_credential": "Dodaj kolejne poświadczenia klienta federacyjnego",
|
||||
"oidc_allowed_group_count": "Dopuszczalna liczba grup",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Digite o código que apareceu na etapa anterior.",
|
||||
"authorize": "Autorizar",
|
||||
"federated_client_credentials": "Credenciais de Cliente Federadas",
|
||||
"federated_client_credentials_description": "As credenciais federadas do cliente permitem autenticar clientes OIDC sem precisar gerenciar segredos de longa duração. Elas usam tokens JWT emitidos por autoridades terceirizadas para afirmações do cliente, tipo tokens de identidade de carga de trabalho.",
|
||||
"add_federated_client_credential": "Adicionar credencial de cliente federado",
|
||||
"add_another_federated_client_credential": "Adicionar outra credencial de cliente federado",
|
||||
"oidc_allowed_group_count": "Total de grupos permitidos",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
|
||||
"authorize": "Авторизовать",
|
||||
"federated_client_credentials": "Федеративные учетные данные клиента",
|
||||
"federated_client_credentials_description": "Федеративные учетные данные клиента позволяют аутентифицировать клиентов OIDC без необходимости управления долгосрочными секретами. Они используют токены JWT, выданные сторонними органами для утверждений клиента, например токены идентификации рабочей нагрузки.",
|
||||
"add_federated_client_credential": "Добавить федеративные учетные данные клиента",
|
||||
"add_another_federated_client_credential": "Добавить другие федеративные учетные данные клиента",
|
||||
"oidc_allowed_group_count": "Число разрешенных групп",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Ange koden som visades i föregående steg.",
|
||||
"authorize": "Godkänn",
|
||||
"federated_client_credentials": "Federerade klientuppgifter",
|
||||
"federated_client_credentials_description": "Federerade klientautentiseringsuppgifter gör det möjligt att autentisera OIDC-klienter utan att hantera långlivade hemligheter. De utnyttjar JWT-tokens som utfärdats av tredjepartsmyndigheter för klientpåståenden, t.ex. identitetstokens för arbetsbelastning.",
|
||||
"add_federated_client_credential": "Lägg till federerad klientuppgift",
|
||||
"add_another_federated_client_credential": "Lägg till ytterligare en federerad klientuppgift",
|
||||
"oidc_allowed_group_count": "Tillåtet antal grupper",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Önceki adımda görüntülenen kodu girin.",
|
||||
"authorize": "Yetkilendir",
|
||||
"federated_client_credentials": "Birleştirilmiş İstemci Kimlik Bilgileri",
|
||||
"federated_client_credentials_description": "Birleştirilmiş istemci kimlik bilgileri, uzun süreli gizli bilgileri yönetmeden OIDC istemcilerinin kimlik doğrulamasını sağlar. Üçüncü taraf yetkililer tarafından istemci beyanları için verilen JWT belirteçlerini (ör. iş yükü kimlik belirteçleri) kullanır.",
|
||||
"add_federated_client_credential": "Birleştirilmiş İstemci Kimlik Bilgisi Ekle",
|
||||
"add_another_federated_client_credential": "Başka bir birleştirilmiş istemci kimlik bilgisi ekle",
|
||||
"oidc_allowed_group_count": "İzin Verilen Grup Sayısı",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Введіть код, який було показано на попередньому кроці.",
|
||||
"authorize": "Авторизувати",
|
||||
"federated_client_credentials": "Федеративні облікові дані клієнта",
|
||||
"federated_client_credentials_description": "Федеративні облікові дані клієнта дозволяють автентифікувати клієнтів OIDC без управління довготривалими секретами. Вони використовують токени JWT, видані сторонніми органами для підтвердження клієнтів, наприклад, токени ідентичності робочого навантаження.",
|
||||
"add_federated_client_credential": "Додати федеративний обліковий запис клієнта",
|
||||
"add_another_federated_client_credential": "Додати ще один федеративний обліковий запис клієнта",
|
||||
"oidc_allowed_group_count": "Кількість дозволених груп",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "Nhập mã đã hiển thị ở bước trước.",
|
||||
"authorize": "Cho phép",
|
||||
"federated_client_credentials": "Thông Tin Xác Thực Của Federated Clients",
|
||||
"federated_client_credentials_description": "Thông tin xác thực khách hàng liên kết cho phép xác thực các khách hàng OIDC mà không cần quản lý các khóa bí mật có thời hạn dài. Chúng sử dụng các token JWT do các cơ quan thứ ba cấp để xác thực thông tin của khách hàng, ví dụ như các token danh tính công việc.",
|
||||
"add_federated_client_credential": "Thêm thông tin xác thực cho federated clients",
|
||||
"add_another_federated_client_credential": "Thêm một thông tin xác thực cho federated clients khác",
|
||||
"oidc_allowed_group_count": "Số lượng nhóm được phép",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "输入在上一步中显示的代码",
|
||||
"authorize": "授权",
|
||||
"federated_client_credentials": "联合身份",
|
||||
"federated_client_credentials_description": "联合客户端凭证允许在无需管理长期密钥的情况下验证OIDC客户端。该机制利用第三方机构签发的JWT令牌来验证客户端声明,例如工作负载身份令牌。",
|
||||
"add_federated_client_credential": "添加联合身份",
|
||||
"add_another_federated_client_credential": "再添加一个联合身份",
|
||||
"oidc_allowed_group_count": "允许的群组数量",
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"enter_code_displayed_in_previous_step": "請輸入上一步顯示的代碼。",
|
||||
"authorize": "授權",
|
||||
"federated_client_credentials": "聯邦身分",
|
||||
"federated_client_credentials_description": "聯合客戶憑證允許驗證 OIDC 客戶端,無需管理長期存續的機密。此機制利用第三方權威機構發行的 JWT 憑證來驗證客戶端聲明,例如工作負載身分憑證。",
|
||||
"add_federated_client_credential": "增加聯邦身分",
|
||||
"add_another_federated_client_credential": "新增另一組聯邦身分",
|
||||
"oidc_allowed_group_count": "允許的群組數量",
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "2.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "vite dev --port 3000",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 3000",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"axios": "^1.13.5",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jose": "^6.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"runed": "^0.37.1",
|
||||
"sveltekit-superforms": "^2.30.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inlang/paraglide-js": "^2.12.0",
|
||||
"@inlang/plugin-m-function-matcher": "^2.2.1",
|
||||
"@inlang/plugin-message-format": "^4.3.0",
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@lucide/svelte": "^0.559.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.53.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bits-ui": "^2.16.2",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.15.0",
|
||||
"formsnap": "^2.0.1",
|
||||
"globals": "^16.5.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"rollup": "^4.59.0",
|
||||
"svelte": "^5.53.6",
|
||||
"svelte-check": "^4.4.3",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"tslib": "^2.8.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-compression": "^0.5.1"
|
||||
}
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "2.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "vite dev --port 3000",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 3000",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"axios": "^1.13.5",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jose": "^6.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"runed": "^0.37.1",
|
||||
"sveltekit-superforms": "^2.30.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inlang/paraglide-js": "^2.12.0",
|
||||
"@inlang/plugin-m-function-matcher": "^2.2.1",
|
||||
"@inlang/plugin-message-format": "^4.3.0",
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@lucide/svelte": "^0.559.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.53.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bits-ui": "^2.16.2",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.15.0",
|
||||
"formsnap": "^2.0.1",
|
||||
"globals": "^16.5.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"rollup": "^4.59.0",
|
||||
"svelte": "^5.53.6",
|
||||
"svelte-check": "^4.4.3",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"tslib": "^2.8.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-compression": "^0.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
<script module lang="ts">
|
||||
// Persist the last failing background image URL across route remounts so
|
||||
// login pages without a background do not briefly retry the image and stutter.
|
||||
let persistedMissingBackgroundImageUrl: string | undefined;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
@@ -23,32 +17,13 @@
|
||||
showAlternativeSignInMethodButton?: boolean;
|
||||
} = $props();
|
||||
|
||||
let missingBackgroundImageUrl = $state<string | undefined>(persistedMissingBackgroundImageUrl);
|
||||
let loadedBackgroundImageUrl = $state<string | undefined>();
|
||||
let isInitialLoad = $state(false);
|
||||
let backgroundImageUrl = $derived(cachedBackgroundImage.getUrl());
|
||||
let imageError = $derived(missingBackgroundImageUrl === backgroundImageUrl);
|
||||
let imageLoaded = $derived(loadedBackgroundImageUrl === backgroundImageUrl);
|
||||
let animate = $derived(isInitialLoad && imageLoaded && !$appConfigStore.disableAnimations);
|
||||
let animate = $derived(isInitialLoad && !$appConfigStore.disableAnimations);
|
||||
|
||||
afterNavigate((e) => {
|
||||
isInitialLoad = !e?.from?.url;
|
||||
});
|
||||
|
||||
function onBackgroundImageLoad() {
|
||||
loadedBackgroundImageUrl = backgroundImageUrl;
|
||||
if (persistedMissingBackgroundImageUrl === backgroundImageUrl) {
|
||||
persistedMissingBackgroundImageUrl = undefined;
|
||||
missingBackgroundImageUrl = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function onBackgroundImageError() {
|
||||
loadedBackgroundImageUrl = undefined;
|
||||
persistedMissingBackgroundImageUrl = backgroundImageUrl;
|
||||
missingBackgroundImageUrl = backgroundImageUrl;
|
||||
}
|
||||
|
||||
const isDesktop = new MediaQuery('min-width: 1024px');
|
||||
let alternativeSignInButton = $state({
|
||||
href: '/login/alternative',
|
||||
@@ -71,9 +46,9 @@
|
||||
</script>
|
||||
|
||||
{#if isDesktop.current}
|
||||
<div class="h-screen items-center overflow-hidden text-center flex justify-center">
|
||||
<div class="h-screen items-center overflow-hidden text-center">
|
||||
<div
|
||||
class="flex h-full w-[650px] 2xl:w-[800px] p-16 {cn(
|
||||
class="relative z-10 flex h-full w-[650px] 2xl:w-[800px] p-16 {cn(
|
||||
showAlternativeSignInMethodButton && 'pb-0'
|
||||
)}"
|
||||
>
|
||||
@@ -94,18 +69,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !imageError}
|
||||
<!-- Background image -->
|
||||
<div class="m-6 flex h-[calc(100vh-3rem)] overflow-hidden rounded-[40px]">
|
||||
<img
|
||||
src={backgroundImageUrl}
|
||||
class="h-full object-cover {cn(animate && 'animate-bg-zoom')}"
|
||||
alt={m.login_background()}
|
||||
onload={onBackgroundImageLoad}
|
||||
onerror={onBackgroundImageError}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Background image -->
|
||||
<div class="absolute top-0 right-0 left-500px bottom-0 z-0 overflow-hidden rounded-[40px] m-6">
|
||||
<img
|
||||
src={cachedBackgroundImage.getUrl()}
|
||||
class="{cn(
|
||||
animate && 'animate-bg-zoom'
|
||||
)} h-screen object-cover w-[calc(100vw-650px)] 2xl:w-[calc(100vw-800px)]"
|
||||
alt={m.login_background()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
|
||||
@@ -71,11 +71,6 @@ export default class AppConfigService extends APIService {
|
||||
cachedBackgroundImage.bustCache();
|
||||
};
|
||||
|
||||
deleteBackgroundImage = async () => {
|
||||
await this.api.delete(`/application-images/background`);
|
||||
cachedBackgroundImage.bustCache();
|
||||
};
|
||||
|
||||
deleteDefaultProfilePicture = async () => {
|
||||
await this.api.delete('/application-images/default-profile-picture');
|
||||
cachedDefaultProfilePicture.bustCache();
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
logoDark: File | undefined,
|
||||
logoEmail: File | undefined,
|
||||
defaultProfilePicture: File | null | undefined,
|
||||
backgroundImage: File | null | undefined,
|
||||
backgroundImage: File | undefined,
|
||||
favicon: File | undefined
|
||||
) {
|
||||
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
|
||||
@@ -68,12 +68,9 @@
|
||||
? appConfigService.updateDefaultProfilePicture(defaultProfilePicture)
|
||||
: Promise.resolve();
|
||||
|
||||
const backgroundImagePromise =
|
||||
backgroundImage === null
|
||||
? appConfigService.deleteBackgroundImage()
|
||||
: backgroundImage
|
||||
? appConfigService.updateBackgroundImage(backgroundImage)
|
||||
: Promise.resolve();
|
||||
const backgroundImagePromise = backgroundImage
|
||||
? appConfigService.updateBackgroundImage(backgroundImage)
|
||||
: Promise.resolve();
|
||||
|
||||
await Promise.all([
|
||||
lightLogoPromise,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
logoDark: File | undefined,
|
||||
logoEmail: File | undefined,
|
||||
defaultProfilePicture: File | null | undefined,
|
||||
backgroundImage: File | null | undefined,
|
||||
backgroundImage: File | undefined,
|
||||
favicon: File | undefined
|
||||
) => void;
|
||||
} = $props();
|
||||
@@ -26,11 +26,10 @@
|
||||
let logoDark = $state<File | undefined>();
|
||||
let logoEmail = $state<File | undefined>();
|
||||
let defaultProfilePicture = $state<File | null | undefined>();
|
||||
let backgroundImage = $state<File | null | undefined>();
|
||||
let backgroundImage = $state<File | undefined>();
|
||||
let favicon = $state<File | undefined>();
|
||||
|
||||
let defaultProfilePictureSet = $state(true);
|
||||
let backgroundImageSet = $state(true);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
@@ -40,7 +39,7 @@
|
||||
label={m.favicon()}
|
||||
bind:image={favicon}
|
||||
imageURL="/api/application-images/favicon"
|
||||
accept="image/svg+xml, image/png, image/x-icon"
|
||||
accept="image/x-icon"
|
||||
/>
|
||||
<ApplicationImage
|
||||
id="logo-light"
|
||||
@@ -78,12 +77,10 @@
|
||||
/>
|
||||
<ApplicationImage
|
||||
id="background-image"
|
||||
imageClass="max-h-[350px] max-w-[500px]"
|
||||
imageClass="h-[350px] max-w-[500px]"
|
||||
label={m.background_image()}
|
||||
isResetable
|
||||
bind:image={backgroundImage}
|
||||
imageURL={cachedBackgroundImage.getUrl()}
|
||||
isImageSet={backgroundImageSet}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
|
||||
@@ -32,8 +32,6 @@
|
||||
aria-invalid={!!error}
|
||||
data-testid={`callback-url-${i + 1}`}
|
||||
type="text"
|
||||
inputmode="url"
|
||||
autocomplete="url"
|
||||
bind:value={callbackURLs[i]}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Toggle } from '$lib/components/ui/toggle';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
@@ -77,37 +76,34 @@
|
||||
<fieldset disabled={inputDisabled}>
|
||||
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
||||
<FormInput label={m.username()} bind:input={$inputs.username} />
|
||||
<FormInput label={m.email()} bind:input={$inputs.email} labelFor="email">
|
||||
<div class="flex items-end">
|
||||
<Input
|
||||
id="email"
|
||||
class="rounded-r-none border-r-0"
|
||||
aria-invalid={!!$inputs.email.error}
|
||||
bind:value={$inputs.email.value}
|
||||
/>
|
||||
<Tooltip.Provider>
|
||||
{@const label = $inputs.emailVerified.value
|
||||
? m.mark_as_unverified()
|
||||
: m.mark_as_verified()}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Toggle
|
||||
bind:pressed={$inputs.emailVerified.value}
|
||||
aria-label={label}
|
||||
class="h-9 border-input bg-yellow-100 dark:bg-yellow-950 data-[state=on]:bg-green-100 dark:data-[state=on]:bg-green-950 rounded-l-none border px-2 py-1 shadow-xs flex items-center hover:data-[state=on]:bg-accent"
|
||||
>
|
||||
{#if $inputs.emailVerified.value}
|
||||
<LucideMailCheck class="text-green-500 dark:text-green-600 size-5" />
|
||||
{:else}
|
||||
<LucideMailWarning class="text-yellow-500 dark:text-yellow-600 size-5" />
|
||||
{/if}
|
||||
</Toggle>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{label}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
</FormInput>
|
||||
<div class="flex items-end">
|
||||
<FormInput
|
||||
inputClass="rounded-r-none border-r-0"
|
||||
label={m.email()}
|
||||
bind:input={$inputs.email}
|
||||
/>
|
||||
<Tooltip.Provider>
|
||||
{@const label = $inputs.emailVerified.value
|
||||
? m.mark_as_unverified()
|
||||
: m.mark_as_verified()}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Toggle
|
||||
bind:pressed={$inputs.emailVerified.value}
|
||||
aria-label={label}
|
||||
class="h-9 border-input bg-yellow-100 dark:bg-yellow-950 data-[state=on]:bg-green-100 dark:data-[state=on]:bg-green-950 rounded-l-none border px-2 py-1 shadow-xs flex items-center hover:data-[state=on]:bg-accent"
|
||||
>
|
||||
{#if $inputs.emailVerified.value}
|
||||
<LucideMailCheck class="text-green-500 dark:text-green-600 size-5" />
|
||||
{:else}
|
||||
<LucideMailWarning class="text-yellow-500 dark:text-yellow-600 size-5" />
|
||||
{/if}
|
||||
</Toggle>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{label}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
<FormInput label={m.first_name()} oninput={onNameInput} bind:input={$inputs.firstName} />
|
||||
<FormInput label={m.last_name()} oninput={onNameInput} bind:input={$inputs.lastName} />
|
||||
<FormInput
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
},
|
||||
{
|
||||
"client_id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||
"client_id": "7c21a609-96b5-4011-9900-272b8d31a9d1",
|
||||
"code": "federated",
|
||||
"code_challenge": null,
|
||||
"code_challenge_method_sha256": null,
|
||||
|
||||
Reference in New Issue
Block a user