Add an option to automatically verify SSH keys from LDAP (#35927)

This pull request adds an option to automatically verify SSH keys from
LDAP authentication sources.

This allows a correct authentication and verification workflow for
LDAP-enabled organizations; under normal circumstances SSH keys in LDAP
are not managed by users manually.
This commit is contained in:
Ivan Tkatchev
2025-12-27 15:33:08 +03:00
committed by GitHub
parent 00cc84e37c
commit 19e1997ee2
15 changed files with 38 additions and 14 deletions
+7
View File
@@ -94,6 +94,10 @@ func commonLdapCLIFlags() []cli.Flag {
Name: "public-ssh-key-attribute", Name: "public-ssh-key-attribute",
Usage: "The attribute of the users LDAP record containing the users public ssh key.", Usage: "The attribute of the users LDAP record containing the users public ssh key.",
}, },
&cli.BoolFlag{
Name: "ssh-keys-are-verified",
Usage: "Set to true to automatically flag SSH keys in LDAP as verified.",
},
&cli.BoolFlag{ &cli.BoolFlag{
Name: "skip-local-2fa", Name: "skip-local-2fa",
Usage: "Set to true to skip local 2fa for users authenticated by this source", Usage: "Set to true to skip local 2fa for users authenticated by this source",
@@ -294,6 +298,9 @@ func parseLdapConfig(c *cli.Command, config *ldap.Source) error {
if c.IsSet("public-ssh-key-attribute") { if c.IsSet("public-ssh-key-attribute") {
config.AttributeSSHPublicKey = c.String("public-ssh-key-attribute") config.AttributeSSHPublicKey = c.String("public-ssh-key-attribute")
} }
if c.IsSet("ssh-keys-are-verified") {
config.SSHKeysAreVerified = c.Bool("ssh-keys-are-verified")
}
if c.IsSet("avatar-attribute") { if c.IsSet("avatar-attribute") {
config.AttributeAvatar = c.String("avatar-attribute") config.AttributeAvatar = c.String("avatar-attribute")
} }
+6 -5
View File
@@ -84,7 +84,7 @@ func addKey(ctx context.Context, key *PublicKey) (err error) {
} }
// AddPublicKey adds new public key to database and authorized_keys file. // AddPublicKey adds new public key to database and authorized_keys file.
func AddPublicKey(ctx context.Context, ownerID int64, name, content string, authSourceID int64) (*PublicKey, error) { func AddPublicKey(ctx context.Context, ownerID int64, name, content string, authSourceID int64, verified bool) (*PublicKey, error) {
log.Trace(content) log.Trace(content)
fingerprint, err := CalcFingerprint(content) fingerprint, err := CalcFingerprint(content)
@@ -115,6 +115,7 @@ func AddPublicKey(ctx context.Context, ownerID int64, name, content string, auth
Mode: perm.AccessModeWrite, Mode: perm.AccessModeWrite,
Type: KeyTypeUser, Type: KeyTypeUser,
LoginSourceID: authSourceID, LoginSourceID: authSourceID,
Verified: verified,
} }
if err = addKey(ctx, key); err != nil { if err = addKey(ctx, key); err != nil {
return nil, fmt.Errorf("addKey: %w", err) return nil, fmt.Errorf("addKey: %w", err)
@@ -298,7 +299,7 @@ func deleteKeysMarkedForDeletion(ctx context.Context, keys []string) (bool, erro
} }
// AddPublicKeysBySource add a users public keys. Returns true if there are changes. // AddPublicKeysBySource add a users public keys. Returns true if there are changes.
func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool { func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string, verified bool) bool {
var sshKeysNeedUpdate bool var sshKeysNeedUpdate bool
for _, sshKey := range sshPublicKeys { for _, sshKey := range sshPublicKeys {
var err error var err error
@@ -317,7 +318,7 @@ func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.So
marshalled = marshalled[:len(marshalled)-1] marshalled = marshalled[:len(marshalled)-1]
sshKeyName := fmt.Sprintf("%s-%s", s.Name, ssh.FingerprintSHA256(out)) sshKeyName := fmt.Sprintf("%s-%s", s.Name, ssh.FingerprintSHA256(out))
if _, err := AddPublicKey(ctx, usr.ID, sshKeyName, marshalled, s.ID); err != nil { if _, err := AddPublicKey(ctx, usr.ID, sshKeyName, marshalled, s.ID, verified); err != nil {
if IsErrKeyAlreadyExist(err) { if IsErrKeyAlreadyExist(err) {
log.Trace("AddPublicKeysBySource[%s]: Public SSH Key %s already exists for user", sshKeyName, usr.Name) log.Trace("AddPublicKeysBySource[%s]: Public SSH Key %s already exists for user", sshKeyName, usr.Name)
} else { } else {
@@ -336,7 +337,7 @@ func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.So
} }
// SynchronizePublicKeys updates a user's public keys. Returns true if there are changes. // SynchronizePublicKeys updates a user's public keys. Returns true if there are changes.
func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool { func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string, verified bool) bool {
var sshKeysNeedUpdate bool var sshKeysNeedUpdate bool
log.Trace("synchronizePublicKeys[%s]: Handling Public SSH Key synchronization for user %s", s.Name, usr.Name) log.Trace("synchronizePublicKeys[%s]: Handling Public SSH Key synchronization for user %s", s.Name, usr.Name)
@@ -381,7 +382,7 @@ func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.So
newKeys = append(newKeys, key) newKeys = append(newKeys, key)
} }
} }
if AddPublicKeysBySource(ctx, usr, s, newKeys) { if AddPublicKeysBySource(ctx, usr, s, newKeys, verified) {
sshKeysNeedUpdate = true sshKeysNeedUpdate = true
} }
+1
View File
@@ -3067,6 +3067,7 @@
"admin.auths.attribute_mail": "Email Attribute", "admin.auths.attribute_mail": "Email Attribute",
"admin.auths.attribute_ssh_public_key": "Public SSH Key Attribute", "admin.auths.attribute_ssh_public_key": "Public SSH Key Attribute",
"admin.auths.attribute_avatar": "Avatar Attribute", "admin.auths.attribute_avatar": "Avatar Attribute",
"admin.auths.ssh_keys_are_verified": "SSH keys in LDAP are considered as verified",
"admin.auths.attributes_in_bind": "Fetch Attributes in Bind DN Context", "admin.auths.attributes_in_bind": "Fetch Attributes in Bind DN Context",
"admin.auths.allow_deactivate_all": "Allow an empty search result to deactivate all users", "admin.auths.allow_deactivate_all": "Allow an empty search result to deactivate all users",
"admin.auths.use_paged_search": "Use Paged Search", "admin.auths.use_paged_search": "Use Paged Search",
+1 -1
View File
@@ -211,7 +211,7 @@ func CreateUserPublicKey(ctx *context.APIContext, form api.CreateKeyOption, uid
return return
} }
key, err := asymkey_model.AddPublicKey(ctx, uid, form.Title, content, 0) key, err := asymkey_model.AddPublicKey(ctx, uid, form.Title, content, 0, false)
if err != nil { if err != nil {
repo.HandleAddKeyError(ctx, err) repo.HandleAddKeyError(ctx, err)
return return
+1
View File
@@ -136,6 +136,7 @@ func parseLDAPConfig(form forms.AuthenticationForm) *ldap.Source {
AttributesInBind: form.AttributesInBind, AttributesInBind: form.AttributesInBind,
AttributeSSHPublicKey: form.AttributeSSHPublicKey, AttributeSSHPublicKey: form.AttributeSSHPublicKey,
AttributeAvatar: form.AttributeAvatar, AttributeAvatar: form.AttributeAvatar,
SSHKeysAreVerified: form.SSHKeysAreVerified,
SearchPageSize: pageSize, SearchPageSize: pageSize,
Filter: form.Filter, Filter: form.Filter,
GroupsEnabled: form.GroupsEnabled, GroupsEnabled: form.GroupsEnabled,
+1 -1
View File
@@ -86,7 +86,7 @@ func oauth2UpdateSSHPubIfNeed(ctx *context.Context, authSource *auth.Source, got
if err != nil { if err != nil {
return err return err
} }
if !asymkey_model.SynchronizePublicKeys(ctx, user, authSource, sshKeys) { if !asymkey_model.SynchronizePublicKeys(ctx, user, authSource, sshKeys, false) {
return nil return nil
} }
return asymkey_service.RewriteAllPublicKeys(ctx) return asymkey_service.RewriteAllPublicKeys(ctx)
+1 -1
View File
@@ -187,7 +187,7 @@ func KeysPost(ctx *context.Context) {
return return
} }
if _, err = asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0); err != nil { if _, err = asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0, false); err != nil {
ctx.Data["HasSSHError"] = true ctx.Data["HasSSHError"] = true
switch { switch {
case asymkey_model.IsErrKeyAlreadyExist(err): case asymkey_model.IsErrKeyAlreadyExist(err):
+1 -1
View File
@@ -31,7 +31,7 @@ func TestParseCommitWithSSHSignature(t *testing.T) {
// AAAEDWqPHTH51xb4hy1y1f1VeWL/2A9Q0b6atOyv5fx8x5prpPrMXSg9qTx04jPNPWRcHs // AAAEDWqPHTH51xb4hy1y1f1VeWL/2A9Q0b6atOyv5fx8x5prpPrMXSg9qTx04jPNPWRcHs
// utyxWjThIpzcaO68yWVnAAAAEXVzZXIyQGV4YW1wbGUuY29tAQIDBA== // utyxWjThIpzcaO68yWVnAAAAEXVzZXIyQGV4YW1wbGUuY29tAQIDBA==
// -----END OPENSSH PRIVATE KEY----- // -----END OPENSSH PRIVATE KEY-----
sshPubKey, err := asymkey_model.AddPublicKey(t.Context(), 999, "user-ssh-key-any-name", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILpPrMXSg9qTx04jPNPWRcHsutyxWjThIpzcaO68yWVn", 0) sshPubKey, err := asymkey_model.AddPublicKey(t.Context(), 999, "user-ssh-key-any-name", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILpPrMXSg9qTx04jPNPWRcHsutyxWjThIpzcaO68yWVn", 0, false)
require.NoError(t, err) require.NoError(t, err)
_, err = db.GetEngine(t.Context()).ID(sshPubKey.ID).Cols("verified").Update(&asymkey_model.PublicKey{Verified: true}) _, err = db.GetEngine(t.Context()).ID(sshPubKey.ID).Cols("verified").Update(&asymkey_model.PublicKey{Verified: true})
require.NoError(t, err) require.NoError(t, err)
+1 -1
View File
@@ -66,7 +66,7 @@ ssh-dss AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3ib
for i, kase := range testCases { for i, kase := range testCases {
s.ID = int64(i) + 20 s.ID = int64(i) + 20
asymkey_model.AddPublicKeysBySource(t.Context(), user, s, []string{kase.keyString}) asymkey_model.AddPublicKeysBySource(t.Context(), user, s, []string{kase.keyString}, false)
keys, err := db.Find[asymkey_model.PublicKey](t.Context(), asymkey_model.FindPublicKeyOptions{ keys, err := db.Find[asymkey_model.PublicKey](t.Context(), asymkey_model.FindPublicKeyOptions{
OwnerID: user.ID, OwnerID: user.ID,
LoginSourceID: s.ID, LoginSourceID: s.ID,
+1
View File
@@ -44,6 +44,7 @@ type Source struct {
AttributesInBind bool // fetch attributes in bind context (not user) AttributesInBind bool // fetch attributes in bind context (not user)
AttributeSSHPublicKey string // LDAP SSH Public Key attribute AttributeSSHPublicKey string // LDAP SSH Public Key attribute
AttributeAvatar string AttributeAvatar string
SSHKeysAreVerified bool // true if SSH keys in LDAP are verified
SearchPageSize uint32 // Search with paging page size SearchPageSize uint32 // Search with paging page size
Filter string // Query filter to validate entry Filter string // Query filter to validate entry
AdminFilter string // Query filter to check if user is admin AdminFilter string // Query filter to check if user is admin
@@ -73,7 +73,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
} }
if user != nil { if user != nil {
if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, user, source.AuthSource, sr.SSHPublicKey) { if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, user, source.AuthSource, sr.SSHPublicKey, source.SSHKeysAreVerified) {
if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil {
return user, err return user, err
} }
@@ -99,7 +99,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
return user, err return user, err
} }
if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.AuthSource, sr.SSHPublicKey) { if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.AuthSource, sr.SSHPublicKey, source.SSHKeysAreVerified) {
if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil {
return user, err return user, err
} }
+2 -2
View File
@@ -135,7 +135,7 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
if err == nil && isAttributeSSHPublicKeySet { if err == nil && isAttributeSSHPublicKeySet {
log.Trace("SyncExternalUsers[%s]: Adding LDAP Public SSH Keys for user %s", source.AuthSource.Name, usr.Name) log.Trace("SyncExternalUsers[%s]: Adding LDAP Public SSH Keys for user %s", source.AuthSource.Name, usr.Name)
if asymkey_model.AddPublicKeysBySource(ctx, usr, source.AuthSource, su.SSHPublicKey) { if asymkey_model.AddPublicKeysBySource(ctx, usr, source.AuthSource, su.SSHPublicKey, source.SSHKeysAreVerified) {
sshKeysNeedUpdate = true sshKeysNeedUpdate = true
} }
} }
@@ -145,7 +145,7 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
} }
} else if updateExisting { } else if updateExisting {
// Synchronize SSH Public Key if that attribute is set // Synchronize SSH Public Key if that attribute is set
if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, usr, source.AuthSource, su.SSHPublicKey) { if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, usr, source.AuthSource, su.SSHPublicKey, source.SSHKeysAreVerified) {
sshKeysNeedUpdate = true sshKeysNeedUpdate = true
} }
+1
View File
@@ -34,6 +34,7 @@ type AuthenticationForm struct {
AttributeMail string AttributeMail string
AttributeSSHPublicKey string AttributeSSHPublicKey string
AttributeAvatar string AttributeAvatar string
SSHKeysAreVerified bool
AttributesInBind bool AttributesInBind bool
UsePagedSearch bool UsePagedSearch bool
SearchPageSize int SearchPageSize int
+6
View File
@@ -112,6 +112,12 @@
<input id="attribute_avatar" name="attribute_avatar" value="{{$cfg.AttributeAvatar}}" placeholder="jpegPhoto"> <input id="attribute_avatar" name="attribute_avatar" value="{{$cfg.AttributeAvatar}}" placeholder="jpegPhoto">
</div> </div>
<div class="inline field">
<div class="ui checkbox">
<label for="ssh_keys_are_verified"><strong>{{ctx.Locale.Tr "admin.auths.ssh_keys_are_verified"}}</strong></label>
<input id="ssh_keys_are_verified" name="ssh_keys_are_verified" type="checkbox" {{if $cfg.SSHKeysAreVerified}}checked{{end}}>
</div>
</div>
<!-- ldap group begin --> <!-- ldap group begin -->
<div class="inline field"> <div class="inline field">
<div class="ui checkbox"> <div class="ui checkbox">
+6
View File
@@ -80,6 +80,12 @@
<input id="attribute_avatar" name="attribute_avatar" value="{{.attribute_avatar}}" placeholder="jpegPhoto"> <input id="attribute_avatar" name="attribute_avatar" value="{{.attribute_avatar}}" placeholder="jpegPhoto">
</div> </div>
<div class="inline field">
<div class="ui checkbox">
<label for="ssh_keys_are_verified"><strong>{{ctx.Locale.Tr "admin.auths.ssh_keys_are_verified"}}</strong></label>
<input id="ssh_keys_are_verified" name="ssh_keys_are_verified" type="checkbox" {{if .ssh_keys_are_verified}}checked{{end}}>
</div>
</div>
<!-- ldap group begin --> <!-- ldap group begin -->
<div class="inline field"> <div class="inline field">
<div class="ui checkbox"> <div class="ui checkbox">