Protect LDAP-managed user credentials

This commit is contained in:
Firehawk 2026-07-12 01:59:54 +09:30
parent dd1fc43762
commit 3c337767f1
7 changed files with 86 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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') ? (
<PasswordInput className="ra-input" source="currentPassword" {...rest} />
) : null
}
const NewPasswordInput = ({ formData, ...rest }) => {
const translate = useTranslate()
return formData.changePassword ? (
return formData.changePassword && !formData.authSource ? (
<PasswordInput
source="password"
className="ra-input"
@ -131,7 +133,21 @@ const UserEdit = (props) => {
{...getNameHelperText()}
/>
<TextInput spellCheck={false} source="email" validate={[email()]} />
<BooleanInput source="changePassword" />
<FormDataConsumer>
{({ formData }) =>
formData.authSource ? (
<Typography
variant="body2"
color="textSecondary"
style={{ marginTop: 16, marginBottom: 16 }}
>
{translate('resources.user.message.externalPasswordReadOnly')}
</Typography>
) : (
<BooleanInput source="changePassword" />
)
}
</FormDataConsumer>
<FormDataConsumer>
{(formDataProps) => (
<CurrentPasswordInput