Merge pull request 'feat: add synchronization for SSH keys for OpenID Connect' (#6232) from Maks1mS/forgejo:feat/add-oidc-ssh-keys into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6232
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
Gusted 2024-12-29 22:43:28 +00:00
commit db7be1a1db
10 changed files with 232 additions and 27 deletions

View file

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

View file

@ -17,6 +17,7 @@ import (
"sort"
"strings"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/auth"
org_model "code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
@ -1183,8 +1184,62 @@ func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
}
}
func getSSHKeys(source *oauth2.Source, gothUser *goth.User) ([]string, error) {
key := source.AttributeSSHPublicKey
value, exists := gothUser.RawData[key]
if !exists {
return []string{}, nil
}
rawSlice, ok := value.([]any)
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
}
func updateSSHPubIfNeed(
ctx *context.Context,
authSource *auth.Source,
fetchedUser *goth.User,
user *user_model.User,
) error {
oauth2Source := authSource.Cfg.(*oauth2.Source)
if oauth2Source.ProvidesSSHKeys() {
sshKeys, err := getSSHKeys(oauth2Source, fetchedUser)
if err != nil {
return err
}
if asymkey_model.SynchronizePublicKeys(ctx, user, authSource, sshKeys) {
err = asymkey_model.RewriteAllPublicKeys(ctx)
if err != nil {
return err
}
}
}
return nil
}
func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) {
updateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
err := updateSSHPubIfNeed(ctx, source, &gothUser, u)
if err != nil {
ctx.ServerError("updateSSHPubIfNeed", err)
return
}
needs2FA := false
if !source.Cfg.(*oauth2.Source).SkipLocalTwoFA {

View file

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

View file

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

View file

@ -4,6 +4,8 @@
package oauth2
import (
"strings"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/json"
)
@ -17,15 +19,16 @@ type Source struct {
CustomURLMapping *CustomURLMapping
IconURL string
Scopes []string
RequiredClaimName string
RequiredClaimValue string
GroupClaimName string
AdminGroup string
GroupTeamMap string
GroupTeamMapRemoval bool
RestrictedGroup string
SkipLocalTwoFA bool `json:",omitempty"`
Scopes []string
AttributeSSHPublicKey string
RequiredClaimName string
RequiredClaimValue string
GroupClaimName string
AdminGroup string
GroupTeamMap string
GroupTeamMapRemoval bool
RestrictedGroup string
SkipLocalTwoFA bool `json:",omitempty"`
// reference to the authSource
authSource *auth.Source
@ -41,6 +44,11 @@ func (source *Source) ToDB() ([]byte, error) {
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
func (source *Source) SetAuthSource(authSource *auth.Source) {
source.authSource = authSource

View file

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

View file

@ -326,19 +326,28 @@
<input id="oauth2_tenant" name="oauth2_tenant" value="{{if $cfg.CustomURLMapping}}{{$cfg.CustomURLMapping.Tenant}}{{end}}">
</div>
{{range .OAuth2Providers}}{{if .CustomURLSettings}}
<input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true">
<input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.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}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.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}}_tenant" value="{{.CustomURLSettings.Tenant.Value}}" data-available="{{.CustomURLSettings.Tenant.Available}}" data-required="{{.CustomURLSettings.Tenant.Required}}" type="hidden">
{{end}}{{end}}
{{range .OAuth2Providers}}
{{if .CustomURLSettings}}
<input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true">
<input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.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}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.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}}_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">
<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}}">
</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">
<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}}">

View file

@ -63,19 +63,27 @@
<input id="oauth2_tenant" name="oauth2_tenant" value="{{.oauth2_tenant}}">
</div>
{{range .OAuth2Providers}}{{if .CustomURLSettings}}
<input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true">
<input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.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}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.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}}_tenant" value="{{.CustomURLSettings.Tenant.Value}}" data-available="{{.CustomURLSettings.Tenant.Available}}" data-required="{{.CustomURLSettings.Tenant.Required}}" type="hidden">
{{end}}{{end}}
{{range .OAuth2Providers}}
{{if .CustomURLSettings}}
<input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true">
<input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.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}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.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}}_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">
<label for="oauth2_scopes">{{ctx.Locale.Tr "admin.auths.oauth2_scopes"}}</label>
<input id="oauth2_scopes" name="oauth2_scopes" value="{{.oauth2_scopes}}">
</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">
<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}}">

View file

@ -691,6 +691,117 @@ func TestSignInOAuthCallbackRedirectToEscaping(t *testing.T) {
assert.Equal(t, "/login/oauth/authorize?redirect_uri=https://translate.example.org", test.RedirectURL(resp))
}
func setupMockOIDCServer() *httptest.Server {
var mockServer *httptest.Server
mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"issuer": "` + mockServer.URL + `",
"authorization_endpoint": "` + mockServer.URL + `/authorize",
"token_endpoint": "` + mockServer.URL + `/token",
"userinfo_endpoint": "` + mockServer.URL + `/userinfo"
}`))
default:
http.NotFound(w, r)
}
}))
return mockServer
}
func TestSignInOauthCallbackSyncSSHKeys(t *testing.T) {
defer tests.PrepareTestEnv(t)()
mockServer := setupMockOIDCServer()
defer mockServer.Close()
sourceName := "oidc"
authPayload := authSourcePayloadOpenIDConnect(sourceName, mockServer.URL+"/")
authPayload["oauth2_attribute_ssh_public_key"] = "sshpubkey"
authSource := addAuthSource(t, authPayload)
userID := "5678"
user := &user_model.User{
Name: "oidc.user",
Email: "oidc.user@example.com",
Passwd: "oidc.userpassword",
Type: user_model.UserTypeIndividual,
LoginType: auth_model.OAuth2,
LoginSource: authSource.ID,
LoginName: userID,
IsActive: true,
}
defer createUser(context.Background(), t, user)()
for _, tt := range []struct {
name string
rawData map[string]any
parsedKeySets []string
}{
{
name: "Add keys",
rawData: map[string]any{
"sshpubkey": []any{
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINDRDoephkaFELacrNNe2fqAwedhRB1MKOpLEHlPuczO nocomment",
},
},
parsedKeySets: []string{
"SHA256:X/mW7JUQ8J8yhrKBbZ/pJni8qx7zPA1DTFsi8ftpDwg",
},
},
{
name: "Update keys",
rawData: map[string]any{
"sshpubkey": []any{
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMLLMOLFMouSJmzOASKKv178d+7op4utSxcugF9tVVch nocomment",
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGyDh9sg1IGQGa0U363wcGXrDlGBhZI3UHvS7we/0d+T nocomment",
},
},
parsedKeySets: []string{
"SHA256:gsyG4JNmY5XoLBK5lSzuwD3EXcaDBiDKBkqDkpQTH6Q",
"SHA256:bbEKB1Qpumgk6QrgiN6t/kIvtUZvIQ8rqQBz8yYPzYw",
},
},
{
name: "Remove keys",
rawData: map[string]any{
"sshpubkey": []any{},
},
parsedKeySets: []string{},
},
} {
t.Run(tt.name, func(t *testing.T) {
defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
return goth.User{
Provider: sourceName,
UserID: userID,
Email: user.Email,
RawData: tt.rawData,
}, nil
})()
session := emptyTestSession(t)
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", sourceName))
resp := session.MakeRequest(t, req, http.StatusSeeOther)
assert.Equal(t, "/", test.RedirectURL(resp))
req = NewRequest(t, "GET", "/user/settings/keys")
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
divs := htmlDoc.doc.Find("#keys-ssh .flex-item .flex-item-body:not(:last-child)")
syncedKeys := make([]string, divs.Length())
for i := 0; i < divs.Length(); i++ {
syncedKeys[i] = strings.TrimSpace(divs.Eq(i).Text())
}
assert.ElementsMatch(t, tt.parsedKeySets, syncedKeys, "Unequal number of keys")
})
}
}
func TestSignUpViaOAuthWithMissingFields(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// enable auto-creation of accounts via OAuth2

View file

@ -62,7 +62,7 @@ export function initAdminCommon() {
}
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]')) {
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);
}