Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SAML integration test #1965

Merged
merged 21 commits into from
Dec 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/pull-db-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/content/usage/authentication.en-us.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
20 changes: 10 additions & 10 deletions templates/admin/auth/source/saml.tmpl
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div class="saml field {{if not (eq .type 8)}}gt-hidden{{end}}">

<div class="inline required field">
<label>{{.locale.Tr "admin.auths.saml_nameidformat"}}</label>
<label>{{ctx.Locale.Tr "admin.auths.saml_nameidformat"}}</label>
<div class="ui selection type dropdown">
<input type="hidden" id="name_id_format" name="name_id_format" value="{{.name_id_format}}">
<div class="text">{{.CurrentNameIDFormat}}</div>
Expand All @@ -15,49 +15,49 @@
</div>

<div class="field">
<label for="identity_provider_metadata_url">{{.locale.Tr "admin.auths.saml_identity_provider_metadata_url"}}</label>
<label for="identity_provider_metadata_url">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_metadata_url"}}</label>
<input id="identity_provider_metadata_url" name="identity_provider_metadata_url" value="{{.IdentityProviderMetadataURL}}">
</div>
<div class="field">
<label for="identity_provider_metadata">{{.locale.Tr "admin.auths.saml_identity_provider_metadata"}}</label>
<label for="identity_provider_metadata">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_metadata"}}</label>
<textarea rows=2 id="identity_provider_metadata" name="identity_provider_metadata" value="{{.IdentityProviderMetadata}}"></textarea>
</div>

<div class="inline field">
<div class="ui checkbox">
<label><strong>{{.locale.Tr "admin.auths.saml_insecure_skip_assertion_signature_validation"}}</strong></label>
<label><strong>{{ctx.Locale.Tr "admin.auths.saml_insecure_skip_assertion_signature_validation"}}</strong></label>
<input name="insecure_skip_assertion_signature_validation" type="checkbox" {{if .InsecureSkipAssertionSignatureValidation}}checked{{end}}>
</div>
</div>

<div class=" field">
<label for="service_provider_certificate">{{.locale.Tr "admin.auths.saml_service_provider_certificate"}}</label>
<label for="service_provider_certificate">{{ctx.Locale.Tr "admin.auths.saml_service_provider_certificate"}}</label>
<textarea rows=2 id="service_provider_certificate" name="service_provider_certificate" value="{{.ServiceProviderCertificate}}"></textarea>
</div>
<div class=" field">
<label for="service_provider_private_key">{{.locale.Tr "admin.auths.saml_service_provider_private_key"}}</label>
<label for="service_provider_private_key">{{ctx.Locale.Tr "admin.auths.saml_service_provider_private_key"}}</label>
<textarea rows=2 id="service_provider_private_key" name="service_provider_private_key" value="{{.ServiceProviderPrivateKey}}"></textarea>
</div>

<div class="inline field">
<div class="ui checkbox">
<label><strong>{{.locale.Tr "admin.auths.saml_sign_requests"}}</strong></label>
<label><strong>{{ctx.Locale.Tr "admin.auths.saml_sign_requests"}}</strong></label>
<input name="sign_requests" type="checkbox" {{if .SignRequests}}checked{{end}}>
</div>
</div>

<div class="field">
<label for="email_assertion_key">{{.locale.Tr "admin.auths.saml_identity_provider_email_assertion_key"}}</label>
<label for="email_assertion_key">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_email_assertion_key"}}</label>
<input id="email_assertion_key" name="email_assertion_key" value="{{if not .EmailAssertionKey}}http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress{{else}}{{.EmailAssertionKey}}{{end}}">
</div>

<div class="field">
<label for="name_assertion_key">{{.locale.Tr "admin.auths.saml_identity_provider_name_assertion_key"}}</label>
<label for="name_assertion_key">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_name_assertion_key"}}</label>
<input id="name_assertion_key" name="name_assertion_key" value="{{if not .NameAssertionKey}}http://schemas.xmlsoap.org/claims/CommonName{{else}}{{.NameAssertionKey}}{{end}}">
</div>

<div class="field">
<label for="username_assertion_key">{{.locale.Tr "admin.auths.saml_identity_provider_username_assertion_key"}}</label>
<label for="username_assertion_key">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_username_assertion_key"}}</label>
<input id="username_assertion_key" name="username_assertion_key" value="{{if not .UsernameAssertionKey}}http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name{{else}}{{.UsernameAssertionKey}}{{end}}">
</div>

Expand Down
17 changes: 17 additions & 0 deletions tests/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
139 changes: 139 additions & 0 deletions tests/integration/saml_test.go
Original file line number Diff line number Diff line change
@@ -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(`<input.*?name="SAMLResponse".*?value="([^"]+)".*?>`)
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)
}