diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index 97446e6cd3b2f..fa71284545b64 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -37,6 +37,14 @@ jobs: MINIO_ROOT_PASSWORD: 12345678 ports: - "9000:9000" + simplesaml: + image: allspice/simple-saml + ports: + - "8080:8080" + env: + SIMPLESAMLPHP_SP_ENTITY_ID: http://localhost:3002/user/saml/test-sp/metadata + SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE: http://localhost:3002/user/saml/test-sp/acs + SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE: http://localhost:3002/user/saml/test-sp/acs steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 diff --git a/docs/content/usage/authentication.en-us.md b/docs/content/usage/authentication.en-us.md index 6e4ede0be6c89..25e8496fe0890 100644 --- a/docs/content/usage/authentication.en-us.md +++ b/docs/content/usage/authentication.en-us.md @@ -349,3 +349,23 @@ If set `ENABLE_REVERSE_PROXY_FULL_NAME=true`, a user full name expected in `X-WE You can also limit the reverse proxy's IP address range with `REVERSE_PROXY_TRUSTED_PROXIES` which default value is `127.0.0.0/8,::1/128`. By `REVERSE_PROXY_LIMIT`, you can limit trusted proxies level. Notice: Reverse Proxy Auth doesn't support the API. You still need an access token or basic auth to make API requests. + +## SAML + +### Configuring Gitea as a SAML 2.0 Service Provider + +- Navigate to `Site Administration > Identity & Access > Authentication Sources` +- Click the `Add Authentication Source` button +- Select `SAML` as the authentication type and specify an authentication source name in `Authentication Name`. +- The `SAML NameID Format` dropdown specifies how Identity Provider (IdP) users are mapped to Gitea users. This option will be provider specific. +- The `[Insecure] Skip Assertion Signature Validation` option is not recommended and disables integrity verification of IdP SAML assertions. +- Either `Identity Provider Metadata URL` or `Identity Provider Metadata XML` must be specified. + - Specifically, `Identity Provider Metadata XML` should be the XML metadata returned by the IdP metadata endpoint. This may be omitted if the endpoint url is recorded in `Identity Provider Metadata URL`. +- You should generate an X.509-formatted certificate and DSA/RSA private key for signing SAML requests. These are specified in `Service Provider Certificate` and `Service Provider Private Key` respectively. +- The checkbox `Sign SAML Requests` should be enabled if a certificate and private key are provided. +- `Email Assertion Key` (email), `Name Assertion Key` (nickname), and `Username Assertion Key` (username) specify how IdP user attributes are mapped to Gitea user attributes. These will be provider specific (or configurable). + +### Configuring a SAML 2.0 Identity Provider to use Gitea + +- The service provider assertion consumer service url will look like: `http(s)://[mydomain]/user/saml/[Authentication Name]/acs`. +- The service provider metadata url will look like: `http(s)://[mydomain]/user/saml/[Authentication Name]/metadata`. diff --git a/templates/admin/auth/source/saml.tmpl b/templates/admin/auth/source/saml.tmpl index f99f41b6efda4..cf34f2461c7a6 100644 --- a/templates/admin/auth/source/saml.tmpl +++ b/templates/admin/auth/source/saml.tmpl @@ -1,7 +1,7 @@
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
diff --git a/tests/integration/README.md b/tests/integration/README.md index f6f74ca21ff9a..c691483511932 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -110,3 +110,20 @@ SLOW_FLUSH = 5S ; 5s is the default value ```bash GITEA_SLOW_TEST_TIME="10s" GITEA_SLOW_FLUSH_TIME="5s" make test-sqlite ``` + +## Running SimpleSAML for testing SAML locally + +```shell +docker run \ +-p 8080:8080 \ +-p 8443:8443 \ +-e SIMPLESAMLPHP_SP_ENTITY_ID=http://localhost:3003/user/saml/test-sp/metadata \ +-e SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=http://localhost:3003/user/saml/test-sp/acs \ +-e SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=http://localhost:3003/user/saml/test-sp/acs \ +--add-host=localhost:192.168.65.2 \ +-d allspice/simple-saml +``` + +```shell +TEST_SIMPLESAML_URL=localhost:8080 make test-sqlite#TestSAMLRegistration +``` diff --git a/tests/integration/saml_test.go b/tests/integration/saml_test.go new file mode 100644 index 0000000000000..28916bb21ff0f --- /dev/null +++ b/tests/integration/saml_test.go @@ -0,0 +1,139 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "regexp" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/auth/source/saml" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestSAMLRegistration(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + samlURL := "localhost:8080" + + if os.Getenv("CI") == "" || !setting.Database.Type.IsPostgreSQL() { + // Make it possible to run tests against a local simplesaml instance + samlURL = os.Getenv("TEST_SIMPLESAML_URL") + if samlURL == "" { + t.Skip("TEST_SIMPLESAML_URL not set and not running in CI") + return + } + } + + assert.NoError(t, auth.CreateSource(db.DefaultContext, &auth.Source{ + Type: auth.SAML, + Name: "test-sp", + IsActive: true, + IsSyncEnabled: false, + Cfg: &saml.Source{ + IdentityProviderMetadata: "", + IdentityProviderMetadataURL: fmt.Sprintf("http://%s/simplesaml/saml2/idp/metadata.php", samlURL), + InsecureSkipAssertionSignatureValidation: false, + NameIDFormat: 4, + ServiceProviderCertificate: "", + ServiceProviderPrivateKey: "", + SignRequests: false, + EmailAssertionKey: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + NameAssertionKey: "http://schemas.xmlsoap.org/claims/CommonName", + UsernameAssertionKey: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + }, + })) + + // check the saml metadata url + req := NewRequest(t, "GET", "/user/saml/test-sp/metadata") + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/user/saml/test-sp") + resp := MakeRequest(t, req, http.StatusTemporaryRedirect) + + jar, err := cookiejar.New(nil) + assert.NoError(t, err) + + client := http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + } + + req, err = http.NewRequest("GET", test.RedirectURL(resp), nil) + assert.NoError(t, err) + + var formRedirectURL *url.URL + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + // capture the redirected destination to use in POST request + formRedirectURL = req.URL + return nil + } + + res, err := client.Do(req) + client.CheckRedirect = nil + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.NotNil(t, formRedirectURL) + + form := url.Values{ + "username": {"user1"}, + "password": {"user1pass"}, + } + + req, err = http.NewRequest("POST", formRedirectURL.String(), strings.NewReader(form.Encode())) + assert.NoError(t, err) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + res, err = client.Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + + body, err := io.ReadAll(res.Body) + assert.NoError(t, err) + + samlResMatcher := regexp.MustCompile(``) + matches := samlResMatcher.FindStringSubmatch(string(body)) + assert.Len(t, matches, 2) + assert.NoError(t, res.Body.Close()) + + session := emptyTestSession(t) + + req = NewRequestWithValues(t, "POST", "/user/saml/test-sp/acs", map[string]string{ + "SAMLResponse": matches[1], + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, test.RedirectURL(resp), "/user/link_account") + + csrf := GetCSRF(t, session, test.RedirectURL(resp)) + + // link the account + req = NewRequestWithValues(t, "POST", "/user/link_account_signup", map[string]string{ + "_csrf": csrf, + "user_name": "samluser", + "email": "saml@example.com", + }) + + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, test.RedirectURL(resp), "/") + + // verify that the user was created + u, err := user_model.GetUserByEmail(db.DefaultContext, "saml@example.com") + assert.NoError(t, err) + assert.NotNil(t, u) + assert.Equal(t, "samluser", u.Name) +}