Merge pull request 'feat: add synchronization for SSH keys for OpenID Connect' (#6232) from Maks1mS/forgejo:feat/add-oidc-ssh-keys into forgejo
Some checks are pending
/ release (push) Waiting to run
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-e2e (push) Blocked by required conditions
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions

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

@ -17,6 +17,7 @@ import (
"sort" "sort"
"strings" "strings"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/auth"
org_model "code.gitea.io/gitea/models/organization" org_model "code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user" 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) { func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) {
updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) updateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
err := updateSSHPubIfNeed(ctx, source, &gothUser, u)
if err != nil {
ctx.ServerError("updateSSHPubIfNeed", err)
return
}
needs2FA := false needs2FA := false
if !source.Cfg.(*oauth2.Source).SkipLocalTwoFA { if !source.Cfg.(*oauth2.Source).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

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

@ -691,6 +691,117 @@ func TestSignInOAuthCallbackRedirectToEscaping(t *testing.T) {
assert.Equal(t, "/login/oauth/authorize?redirect_uri=https://translate.example.org", test.RedirectURL(resp)) 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) { func TestSignUpViaOAuthWithMissingFields(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
// enable auto-creation of accounts via OAuth2 // enable auto-creation of accounts via OAuth2

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);
} }