feat: add synchronization for SSH keys with OpenID Connect

Co-authored-by:  Kirill Kolmykov <cyberk1ra@ya.ru>
This commit is contained in:
Maxim Slipenko 2024-12-09 18:59:11 +03:00
parent 4bc0abac3c
commit 4500757acd
9 changed files with 158 additions and 27 deletions

View file

@ -197,6 +197,7 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
CustomURLMapping: customURLMapping, CustomURLMapping: customURLMapping,
IconURL: form.Oauth2IconURL, IconURL: form.Oauth2IconURL,
Scopes: scopes, Scopes: scopes,
AttributeSSHPublicKey: form.Oauth2AttributeSSHPublicKey,
RequiredClaimName: form.Oauth2RequiredClaimName, RequiredClaimName: form.Oauth2RequiredClaimName,
RequiredClaimValue: form.Oauth2RequiredClaimValue, RequiredClaimValue: form.Oauth2RequiredClaimValue,
SkipLocalTwoFA: form.SkipLocalTwoFA, SkipLocalTwoFA: form.SkipLocalTwoFA,

View file

@ -48,4 +48,8 @@ func (b *BaseProvider) CustomURLSettings() *CustomURLSettings {
return nil return nil
} }
func (b *BaseProvider) CanProvideSSHKeys() bool {
return false
}
var _ Provider = &BaseProvider{} var _ Provider = &BaseProvider{}

View file

@ -51,6 +51,10 @@ func (o *OpenIDProvider) CustomURLSettings() *CustomURLSettings {
return nil return nil
} }
func (o *OpenIDProvider) CanProvideSSHKeys() bool {
return true
}
var _ GothProvider = &OpenIDProvider{} var _ GothProvider = &OpenIDProvider{}
func init() { func init() {

View file

@ -4,6 +4,8 @@
package oauth2 package oauth2
import ( import (
"strings"
"code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
) )
@ -17,15 +19,16 @@ type Source struct {
CustomURLMapping *CustomURLMapping CustomURLMapping *CustomURLMapping
IconURL string IconURL string
Scopes []string Scopes []string
RequiredClaimName string AttributeSSHPublicKey string
RequiredClaimValue string RequiredClaimName string
GroupClaimName string RequiredClaimValue string
AdminGroup string GroupClaimName string
GroupTeamMap string AdminGroup string
GroupTeamMapRemoval bool GroupTeamMap string
RestrictedGroup string GroupTeamMapRemoval bool
SkipLocalTwoFA bool `json:",omitempty"` RestrictedGroup string
SkipLocalTwoFA bool `json:",omitempty"`
// reference to the authSource // reference to the authSource
authSource *auth.Source authSource *auth.Source
@ -41,6 +44,11 @@ func (source *Source) ToDB() ([]byte, error) {
return json.Marshal(source) return json.Marshal(source)
} }
// ProvidesSSHKeys returns if this source provides SSH Keys
func (source *Source) ProvidesSSHKeys() bool {
return len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0
}
// SetAuthSource sets the related AuthSource // SetAuthSource sets the related AuthSource
func (source *Source) SetAuthSource(authSource *auth.Source) { func (source *Source) SetAuthSource(authSource *auth.Source) {
source.authSource = authSource source.authSource = authSource

View file

@ -5,15 +5,20 @@ package oauth2
import ( import (
"context" "context"
"fmt"
"time" "time"
"code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
"github.com/markbates/goth" "github.com/markbates/goth"
"github.com/markbates/goth/providers/openidConnect"
"golang.org/x/oauth2" "golang.org/x/oauth2"
asymkey_model "code.gitea.io/gitea/models/asymkey"
) )
// Sync causes this OAuth2 source to synchronize its users with the db. // Sync causes this OAuth2 source to synchronize its users with the db.
@ -108,7 +113,94 @@ func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *us
u.RefreshToken = token.RefreshToken u.RefreshToken = token.RefreshToken
} }
needUserFetch := source.ProvidesSSHKeys()
if needUserFetch {
fetchedUser, err := fetchUser(provider, token)
if err != nil {
log.Error("fetchUser: %v", err)
} else {
err = updateSSHKeys(ctx, source, user, &fetchedUser)
if err != nil {
log.Error("updateSshKeys: %v", err)
}
}
}
err = user_model.UpdateExternalUserByExternalID(ctx, u) err = user_model.UpdateExternalUserByExternalID(ctx, u)
return err return err
} }
func fetchUser(provider goth.Provider, token *oauth2.Token) (goth.User, error) {
state, err := util.CryptoRandomString(40)
if err != nil {
return goth.User{}, err
}
session, err := provider.BeginAuth(state)
if err != nil {
return goth.User{}, err
}
if s, ok := session.(*openidConnect.Session); ok {
s.AccessToken = token.AccessToken
s.RefreshToken = token.RefreshToken
s.ExpiresAt = token.Expiry
s.IDToken = token.Extra("id_token").(string)
}
gothUser, err := provider.FetchUser(session)
if err != nil {
return goth.User{}, err
}
return gothUser, nil
}
func updateSSHKeys(
ctx context.Context,
source *Source,
user *user_model.User,
fetchedUser *goth.User,
) error {
if source.ProvidesSSHKeys() {
sshKeys, err := getSSHKeys(source, fetchedUser)
if err != nil {
return err
}
if asymkey_model.SynchronizePublicKeys(ctx, user, source.authSource, sshKeys) {
err = asymkey_model.RewriteAllPublicKeys(ctx)
if err != nil {
return err
}
}
}
return nil
}
func getSSHKeys(source *Source, gothUser *goth.User) ([]string, error) {
key := source.AttributeSSHPublicKey
value, exists := gothUser.RawData[key]
if !exists {
return nil, fmt.Errorf("attribute '%s' not found in user data", key)
}
rawSlice, ok := value.([]interface{})
if !ok {
return nil, fmt.Errorf("unexpected type for SSH public key, expected []interface{} but got %T", value)
}
sshKeys := make([]string, 0, len(rawSlice))
for i, v := range rawSlice {
str, ok := v.(string)
if !ok {
return nil, fmt.Errorf("unexpected element type at index %d in SSH public key array, expected string but got %T", i, v)
}
sshKeys = append(sshKeys, str)
}
return sshKeys, nil
}

View file

@ -75,6 +75,7 @@ type AuthenticationForm struct {
Oauth2RestrictedGroup string Oauth2RestrictedGroup string
Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"` Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"`
Oauth2GroupTeamMapRemoval bool Oauth2GroupTeamMapRemoval bool
Oauth2AttributeSSHPublicKey string
SkipLocalTwoFA bool SkipLocalTwoFA bool
SSPIAutoCreateUsers bool SSPIAutoCreateUsers bool
SSPIAutoActivateUsers bool SSPIAutoActivateUsers bool

View file

@ -326,19 +326,28 @@
<input id="oauth2_tenant" name="oauth2_tenant" value="{{if $cfg.CustomURLMapping}}{{$cfg.CustomURLMapping.Tenant}}{{end}}"> <input id="oauth2_tenant" name="oauth2_tenant" value="{{if $cfg.CustomURLMapping}}{{$cfg.CustomURLMapping.Tenant}}{{end}}">
</div> </div>
{{range .OAuth2Providers}}{{if .CustomURLSettings}} {{range .OAuth2Providers}}
<input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true"> {{if .CustomURLSettings}}
<input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.Required}}" type="hidden"> <input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true">
<input id="{{.Name}}_auth_url" value="{{.CustomURLSettings.AuthURL.Value}}" data-available="{{.CustomURLSettings.AuthURL.Available}}" data-required="{{.CustomURLSettings.AuthURL.Required}}" type="hidden"> <input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.Required}}" type="hidden">
<input id="{{.Name}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.Required}}" type="hidden"> <input id="{{.Name}}_auth_url" value="{{.CustomURLSettings.AuthURL.Value}}" data-available="{{.CustomURLSettings.AuthURL.Available}}" data-required="{{.CustomURLSettings.AuthURL.Required}}" type="hidden">
<input id="{{.Name}}_email_url" value="{{.CustomURLSettings.EmailURL.Value}}" data-available="{{.CustomURLSettings.EmailURL.Available}}" data-required="{{.CustomURLSettings.EmailURL.Required}}" type="hidden"> <input id="{{.Name}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.Required}}" type="hidden">
<input id="{{.Name}}_tenant" value="{{.CustomURLSettings.Tenant.Value}}" data-available="{{.CustomURLSettings.Tenant.Available}}" data-required="{{.CustomURLSettings.Tenant.Required}}" type="hidden"> <input id="{{.Name}}_email_url" value="{{.CustomURLSettings.EmailURL.Value}}" data-available="{{.CustomURLSettings.EmailURL.Available}}" data-required="{{.CustomURLSettings.EmailURL.Required}}" type="hidden">
{{end}}{{end}} <input id="{{.Name}}_tenant" value="{{.CustomURLSettings.Tenant.Value}}" data-available="{{.CustomURLSettings.Tenant.Available}}" data-required="{{.CustomURLSettings.Tenant.Required}}" type="hidden">
{{end}}
{{if .CanProvideSSHKeys}}
<input id="{{.Name}}_canProvideSSHKeys" type="hidden">
{{end}}
{{end}}
<div class="field"> <div class="field">
<label for="oauth2_scopes">{{ctx.Locale.Tr "admin.auths.oauth2_scopes"}}</label> <label for="oauth2_scopes">{{ctx.Locale.Tr "admin.auths.oauth2_scopes"}}</label>
<input id="oauth2_scopes" name="oauth2_scopes" value="{{if $cfg.Scopes}}{{StringUtils.Join $cfg.Scopes ","}}{{end}}"> <input id="oauth2_scopes" name="oauth2_scopes" value="{{if $cfg.Scopes}}{{StringUtils.Join $cfg.Scopes ","}}{{end}}">
</div> </div>
<div class="oauth2_attribute_ssh_public_key field">
<label for="oauth2_attribute_ssh_public_key">{{ctx.Locale.Tr "admin.auths.attribute_ssh_public_key"}}</label>
<input id="oauth2_attribute_ssh_public_key" name="oauth2_attribute_ssh_public_key" value="{{$cfg.AttributeSSHPublicKey}}" placeholder="sshpubkey">
</div>
<div class="field"> <div class="field">
<label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label> <label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label>
<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{$cfg.RequiredClaimName}}"> <input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{$cfg.RequiredClaimName}}">

View file

@ -63,19 +63,27 @@
<input id="oauth2_tenant" name="oauth2_tenant" value="{{.oauth2_tenant}}"> <input id="oauth2_tenant" name="oauth2_tenant" value="{{.oauth2_tenant}}">
</div> </div>
{{range .OAuth2Providers}}{{if .CustomURLSettings}} {{range .OAuth2Providers}}
<input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true"> {{if .CustomURLSettings}}
<input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.Required}}" type="hidden"> <input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true">
<input id="{{.Name}}_auth_url" value="{{.CustomURLSettings.AuthURL.Value}}" data-available="{{.CustomURLSettings.AuthURL.Available}}" data-required="{{.CustomURLSettings.AuthURL.Required}}" type="hidden"> <input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.Required}}" type="hidden">
<input id="{{.Name}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.Required}}" type="hidden"> <input id="{{.Name}}_auth_url" value="{{.CustomURLSettings.AuthURL.Value}}" data-available="{{.CustomURLSettings.AuthURL.Available}}" data-required="{{.CustomURLSettings.AuthURL.Required}}" type="hidden">
<input id="{{.Name}}_email_url" value="{{.CustomURLSettings.EmailURL.Value}}" data-available="{{.CustomURLSettings.EmailURL.Available}}" data-required="{{.CustomURLSettings.EmailURL.Required}}" type="hidden"> <input id="{{.Name}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.Required}}" type="hidden">
<input id="{{.Name}}_tenant" value="{{.CustomURLSettings.Tenant.Value}}" data-available="{{.CustomURLSettings.Tenant.Available}}" data-required="{{.CustomURLSettings.Tenant.Required}}" type="hidden"> <input id="{{.Name}}_email_url" value="{{.CustomURLSettings.EmailURL.Value}}" data-available="{{.CustomURLSettings.EmailURL.Available}}" data-required="{{.CustomURLSettings.EmailURL.Required}}" type="hidden">
{{end}}{{end}} <input id="{{.Name}}_tenant" value="{{.CustomURLSettings.Tenant.Value}}" data-available="{{.CustomURLSettings.Tenant.Available}}" data-required="{{.CustomURLSettings.Tenant.Required}}" type="hidden">
{{end}}
{{if .CanProvideSSHKeys}}
<input id="{{.Name}}_canProvideSSHKeys" type="hidden">
{{end}}
{{end}}
<div class="field"> <div class="field">
<label for="oauth2_scopes">{{ctx.Locale.Tr "admin.auths.oauth2_scopes"}}</label> <label for="oauth2_scopes">{{ctx.Locale.Tr "admin.auths.oauth2_scopes"}}</label>
<input id="oauth2_scopes" name="oauth2_scopes" value="{{.oauth2_scopes}}"> <input id="oauth2_scopes" name="oauth2_scopes" value="{{.oauth2_scopes}}">
</div> </div>
<div class="oauth2_attribute_ssh_public_key field">
<label for="oauth2_attribute_ssh_public_key">{{ctx.Locale.Tr "admin.auths.attribute_ssh_public_key"}}</label>
<input id="oauth2_attribute_ssh_public_key" name="oauth2_attribute_ssh_public_key" value="{{.attribute_ssh_public_key}}" placeholder="sshpubkey">
</div>
<div class="field"> <div class="field">
<label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label> <label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label>
<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{.oauth2_required_claim_name}}"> <input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{.oauth2_required_claim_name}}">

View file

@ -62,7 +62,7 @@ export function initAdminCommon() {
} }
function onOAuth2Change(applyDefaultValues) { function onOAuth2Change(applyDefaultValues) {
hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url'); hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url, .oauth2_attribute_ssh_public_key');
for (const input of document.querySelectorAll('.open_id_connect_auto_discovery_url input[required]')) { for (const input of document.querySelectorAll('.open_id_connect_auto_discovery_url input[required]')) {
input.removeAttribute('required'); input.removeAttribute('required');
} }
@ -85,6 +85,10 @@ export function initAdminCommon() {
} }
} }
} }
const canProvideSSHKeys = document.getElementById(`${provider}_canProvideSSHKeys`);
if (canProvideSSHKeys) {
showElem('.oauth2_attribute_ssh_public_key');
}
onOAuth2UseCustomURLChange(applyDefaultValues); onOAuth2UseCustomURLChange(applyDefaultValues);
} }