feat: allow multiple auth sources per user (#500,#477) (#612)

* feat: allow multiple auth sources per user (#500,#477)

* only override isAdmin flag if it is provided by the authentication source
This commit is contained in:
h44z
2026-01-21 22:22:22 +01:00
committed by GitHub
parent d2fe267be7
commit e0f6c1d04b
44 changed files with 1158 additions and 798 deletions

View File

@@ -10,11 +10,12 @@ type LoginProviderInfo struct {
}
type AuthenticatorUserInfo struct {
Identifier UserIdentifier
Email string
Firstname string
Lastname string
Phone string
Department string
IsAdmin bool
Identifier UserIdentifier
Email string
Firstname string
Lastname string
Phone string
Department string
IsAdmin bool
AdminInfoAvailable bool // true if the IsAdmin flag is valid
}

View File

@@ -14,6 +14,7 @@ const (
CtxSystemLdapSyncer = "_WG_SYS_LDAP_SYNCER_"
CtxSystemWgImporter = "_WG_SYS_WG_IMPORTER_"
CtxSystemV1Migrator = "_WG_SYS_V1_MIGRATOR_"
CtxSystemDBMigrator = "_WG_SYS_DB_MIGRATOR_"
)
type ContextUserInfo struct {

View File

@@ -25,6 +25,14 @@ type UserIdentifier string
type UserSource string
type UserAuthentication struct {
BaseModel
UserIdentifier UserIdentifier `gorm:"primaryKey;column:user_identifier"` // sAMAccountName, sub, etc.
Source UserSource `gorm:"primaryKey;column:source"`
ProviderName string `gorm:"primaryKey;column:provider_name"`
}
// User is the user model that gets linked to peer entries, by default an empty user model with only the email address is created
type User struct {
BaseModel
@@ -32,10 +40,15 @@ type User struct {
// required fields
Identifier UserIdentifier `gorm:"primaryKey;column:identifier"`
Email string `form:"email" binding:"required,email"`
Source UserSource
ProviderName string
Source UserSource // deprecated: moved to Authentications.Source
ProviderName string // deprecated: moved to Authentications.ProviderName
IsAdmin bool
// authentication sources
Authentications []UserAuthentication `gorm:"foreignKey:user_identifier"`
// synchronization behavior
PersistLocalChanges bool `gorm:"column:persist_local_changes"`
// optional fields
Firstname string `form:"firstname" binding:"omitempty"`
Lastname string `form:"lastname" binding:"omitempty"`
@@ -81,15 +94,19 @@ func (u *User) IsApiEnabled() bool {
}
func (u *User) CanChangePassword() error {
if u.Source == UserSourceDatabase {
return nil
if slices.ContainsFunc(u.Authentications, func(e UserAuthentication) bool {
return e.Source == UserSourceDatabase
}) {
return nil // password can be changed for database users
}
return errors.New("password change only allowed for database source")
}
func (u *User) HasWeakPassword(minLength int) error {
if u.Source != UserSourceDatabase {
if !slices.ContainsFunc(u.Authentications, func(e UserAuthentication) bool {
return e.Source == UserSourceDatabase
}) {
return nil // password is not required for non-database users, so no check needed
}
@@ -105,13 +122,16 @@ func (u *User) HasWeakPassword(minLength int) error {
}
func (u *User) EditAllowed(new *User) error {
if u.Source == UserSourceDatabase {
return nil
if len(u.Authentications) == 1 && u.Authentications[0].Source == UserSourceDatabase {
return nil // database-only users can be edited always
}
if new.PersistLocalChanges {
return nil // if changes will be persisted locally, they can be edited always
}
// for users which are not database users, only the notes field and the disabled flag can be updated
updateOk := u.Identifier == new.Identifier
updateOk = updateOk && u.Source == new.Source
updateOk = updateOk && u.IsAdmin == new.IsAdmin
updateOk = updateOk && u.Email == new.Email
updateOk = updateOk && u.Firstname == new.Firstname
@@ -120,7 +140,7 @@ func (u *User) EditAllowed(new *User) error {
updateOk = updateOk && u.Department == new.Department
if !updateOk {
return errors.New("edit only allowed for database source")
return errors.New("edit only allowed for reserved fields")
}
return nil
@@ -131,8 +151,10 @@ func (u *User) DeleteAllowed() error {
}
func (u *User) CheckPassword(password string) error {
if u.Source != UserSourceDatabase {
return errors.New("invalid user source")
if !slices.ContainsFunc(u.Authentications, func(e UserAuthentication) bool {
return e.Source == UserSourceDatabase
}) {
return errors.New("invalid user source") // password can only be checked for database users
}
if u.IsDisabled() {
@@ -180,9 +202,24 @@ func (u *User) HashPassword() error {
return nil
}
func (u *User) CopyCalculatedAttributes(src *User) {
func (u *User) CopyCalculatedAttributes(src *User, withAuthentications bool) {
u.BaseModel = src.BaseModel
u.LinkedPeerCount = src.LinkedPeerCount
if withAuthentications {
u.Authentications = src.Authentications
u.WebAuthnId = src.WebAuthnId
u.WebAuthnCredentialList = src.WebAuthnCredentialList
}
}
// MergeAuthSources merges the given authentication sources with the existing ones.
// Already existing sources are not overwritten, nor will be added any duplicates.
func (u *User) MergeAuthSources(extSources ...UserAuthentication) {
for _, src := range extSources {
if !slices.Contains(u.Authentications, src) {
u.Authentications = append(u.Authentications, src)
}
}
}
// DisplayName returns the display name of the user.

View File

@@ -35,19 +35,25 @@ func TestUser_IsApiEnabled(t *testing.T) {
}
func TestUser_CanChangePassword(t *testing.T) {
user := &User{Source: UserSourceDatabase}
user := &User{Authentications: []UserAuthentication{{Source: UserSourceDatabase}}}
assert.NoError(t, user.CanChangePassword())
user.Source = UserSourceLdap
user.Authentications = []UserAuthentication{{Source: UserSourceLdap}}
assert.Error(t, user.CanChangePassword())
user.Source = UserSourceOauth
user.Authentications = []UserAuthentication{{Source: UserSourceOauth}}
assert.Error(t, user.CanChangePassword())
user.Authentications = []UserAuthentication{{Source: UserSourceLdap}, {Source: UserSourceDatabase}}
assert.NoError(t, user.CanChangePassword())
user.Authentications = []UserAuthentication{{Source: UserSourceOauth}, {Source: UserSourceDatabase}}
assert.NoError(t, user.CanChangePassword())
}
func TestUser_EditAllowed(t *testing.T) {
user := &User{Source: UserSourceDatabase}
newUser := &User{Source: UserSourceDatabase}
user := &User{Authentications: []UserAuthentication{{Source: UserSourceDatabase}}}
newUser := &User{Authentications: []UserAuthentication{{Source: UserSourceDatabase}}}
assert.NoError(t, user.EditAllowed(newUser))
newUser.Notes = "notes can be changed"
@@ -59,8 +65,8 @@ func TestUser_EditAllowed(t *testing.T) {
newUser.Lastname = "lastname or other fields can be changed"
assert.NoError(t, user.EditAllowed(newUser))
user.Source = UserSourceLdap
newUser.Source = UserSourceLdap
user.Authentications = []UserAuthentication{{Source: UserSourceLdap}}
newUser.Authentications = []UserAuthentication{{Source: UserSourceLdap}}
newUser.Disabled = nil
newUser.Lastname = ""
newUser.Notes = "notes can be changed"
@@ -72,8 +78,8 @@ func TestUser_EditAllowed(t *testing.T) {
newUser.Lastname = "lastname or other fields can not be changed"
assert.Error(t, user.EditAllowed(newUser))
user.Source = UserSourceOauth
newUser.Source = UserSourceOauth
user.Authentications = []UserAuthentication{{Source: UserSourceOauth}}
newUser.Authentications = []UserAuthentication{{Source: UserSourceOauth}}
newUser.Disabled = nil
newUser.Lastname = ""
newUser.Notes = "notes can be changed"
@@ -84,6 +90,20 @@ func TestUser_EditAllowed(t *testing.T) {
newUser.Lastname = "lastname or other fields can not be changed"
assert.Error(t, user.EditAllowed(newUser))
user.Authentications = []UserAuthentication{{Source: UserSourceOauth}, {Source: UserSourceDatabase}}
newUser.Authentications = []UserAuthentication{{Source: UserSourceOauth}, {Source: UserSourceDatabase}}
newUser.PersistLocalChanges = true
newUser.Disabled = nil
newUser.Lastname = ""
newUser.Notes = "notes can be changed"
assert.NoError(t, user.EditAllowed(newUser))
newUser.Disabled = &time.Time{}
assert.NoError(t, user.EditAllowed(newUser))
newUser.Lastname = "lastname or other fields can be changed"
assert.NoError(t, user.EditAllowed(newUser))
}
func TestUser_DeleteAllowed(t *testing.T) {
@@ -95,13 +115,15 @@ func TestUser_CheckPassword(t *testing.T) {
password := "password"
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
user := &User{Source: UserSourceDatabase, Password: PrivateString(hashedPassword)}
user := &User{
Authentications: []UserAuthentication{{Source: UserSourceDatabase}}, Password: PrivateString(hashedPassword),
}
assert.NoError(t, user.CheckPassword(password))
user.Password = ""
assert.Error(t, user.CheckPassword(password))
user.Source = UserSourceLdap
user.Authentications = []UserAuthentication{{Source: UserSourceLdap}}
assert.Error(t, user.CheckPassword(password))
}