From 3c337767f1141d3afb84ff6ba6cdc0227410207d Mon Sep 17 00:00:00 2001 From: Firehawk Date: Sun, 12 Jul 2026 01:59:54 +0930 Subject: [PATCH] Protect LDAP-managed user credentials --- core/ldapauth/ldapauth.go | 2 ++ .../20260711130000_add_user_auth_source.sql | 11 ++++++++ model/user.go | 2 ++ persistence/user_repository.go | 27 +++++++++++++++++++ persistence/user_repository_test.go | 21 +++++++++++++++ ui/src/i18n/en.json | 6 +++-- ui/src/user/UserEdit.jsx | 22 ++++++++++++--- 7 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 db/migrations/20260711130000_add_user_auth_source.sql diff --git a/core/ldapauth/ldapauth.go b/core/ldapauth/ldapauth.go index e5a4dd09b..2be1831ad 100644 --- a/core/ldapauth/ldapauth.go +++ b/core/ldapauth/ldapauth.go @@ -242,6 +242,8 @@ func authLDAP(ctx context.Context, ds model.DataStore, src Source, username, pas u.Name = first(du.Name, du.UserName) u.Email = du.Email u.IsAdmin = memberAny(du.Groups, src.AdminGroupDNs) + u.AuthSource = "ldap" + u.AuthSourceID = first(src.ID, src.Name) if err := repo.Put(u); err != nil { return nil, err } diff --git a/db/migrations/20260711130000_add_user_auth_source.sql b/db/migrations/20260711130000_add_user_auth_source.sql new file mode 100644 index 000000000..13f2b730a --- /dev/null +++ b/db/migrations/20260711130000_add_user_auth_source.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +alter table user add column auth_source varchar(32) default '' not null; +alter table user add column auth_source_id varchar(255) default '' not null; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +alter table user drop column auth_source; +alter table user drop column auth_source_id; +-- +goose StatementEnd diff --git a/model/user.go b/model/user.go index 1c8541ccf..71a4caf9e 100644 --- a/model/user.go +++ b/model/user.go @@ -10,6 +10,8 @@ type User struct { Name string `structs:"name" json:"name"` Email string `structs:"email" json:"email"` IsAdmin bool `structs:"is_admin" json:"isAdmin"` + AuthSource string `structs:"auth_source" json:"authSource,omitempty"` + AuthSourceID string `structs:"auth_source_id" json:"authSourceId,omitempty"` LastLoginAt *time.Time `structs:"last_login_at" json:"lastLoginAt"` LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"` CreatedAt time.Time `structs:"created_at" json:"createdAt"` diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 9decff4e5..37f7bf448 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -116,6 +116,9 @@ func (r *userRepository) Put(u *model.User) error { u.ID = id.NewRandom() } u.UpdatedAt = time.Now() + if err := r.preserveExternalAuthSource(u); err != nil { + return err + } if u.NewPassword != "" { _ = r.encryptPassword(u) } @@ -165,6 +168,30 @@ func (r *userRepository) Put(u *model.User) error { return nil } +func (r *userRepository) preserveExternalAuthSource(u *model.User) error { + if u.ID == "" { + return nil + } + existing, err := r.Get(u.ID) + if errors.Is(err, model.ErrNotFound) { + return nil + } + if err != nil { + return err + } + if existing.AuthSource == "" { + return nil + } + if u.NewPassword != "" { + return &rest.ValidationError{Errors: map[string]string{"password": "resources.user.validation.externalPasswordReadOnly"}} + } + if u.AuthSource == "" { + u.AuthSource = existing.AuthSource + u.AuthSourceID = existing.AuthSourceID + } + return nil +} + func (r *userRepository) FindFirstAdmin() (*model.User, error) { sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true}) var usr dbUser diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 6f8ab9161..69ca64abf 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -70,6 +70,27 @@ var _ = Describe("UserRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) + It("does not update password for LDAP-sourced users", func() { + ldapUser := model.User{ + ID: "ldap-user", + UserName: "ldap_user", + Name: "LDAP User", + NewPassword: "generated", + AuthSource: "ldap", + AuthSourceID: "ldap01", + } + Expect(repo.Put(&ldapUser)).To(Succeed()) + + ldapUser.NewPassword = "newpass" + err := repo.Put(&ldapUser) + var verr *rest.ValidationError + Expect(errors.As(err, &verr)).To(BeTrue()) + Expect(verr.Errors).To(HaveKeyWithValue("password", "resources.user.validation.externalPasswordReadOnly")) + + actual, err := repo.FindByUsernameWithPassword("ldap_user") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.Password).To(Equal("generated")) + }) }) Describe("validatePasswordChange", func() { diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 4c5e57cb7..25852c589 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -167,13 +167,15 @@ "deleted": "User deleted" }, "validation": { - "librariesRequired": "At least one library must be selected for non-admin users" + "librariesRequired": "At least one library must be selected for non-admin users", + "externalPasswordReadOnly": "Password cannot be changed for users managed by an external authentication source." }, "message": { "listenBrainzToken": "Enter your ListenBrainz user token.", "clickHereForToken": "Click here to get your token", "selectAllLibraries": "Select all libraries", - "adminAutoLibraries": "Admin users automatically have access to all libraries" + "adminAutoLibraries": "Admin users automatically have access to all libraries", + "externalPasswordReadOnly": "Password changes are disabled because this user is managed by an external authentication source." } }, "player": { diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index d8302a9f9..aeabd1f0e 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -48,14 +48,16 @@ const UserToolbar = ({ showDelete, ...props }) => ( const CurrentPasswordInput = ({ formData, isMyself, ...rest }) => { const { permissions } = usePermissions() - return formData.changePassword && (isMyself || permissions !== 'admin') ? ( + return formData.changePassword && + !formData.authSource && + (isMyself || permissions !== 'admin') ? ( ) : null } const NewPasswordInput = ({ formData, ...rest }) => { const translate = useTranslate() - return formData.changePassword ? ( + return formData.changePassword && !formData.authSource ? ( { {...getNameHelperText()} /> - + + {({ formData }) => + formData.authSource ? ( + + {translate('resources.user.message.externalPasswordReadOnly')} + + ) : ( + + ) + } + {(formDataProps) => (