This reverts #25165 (5bb8d1924d
), as there
was a chance some important reviews got missed.
so after reverting this patch it will be resubmitted for reviewing again
https://github.com/go-gitea/gitea/pull/25165#issuecomment-1960670242
temporary Open #5512 again
tags/v1.22.0-rc0
MINIO_ROOT_PASSWORD: 12345678 | MINIO_ROOT_PASSWORD: 12345678 | ||||
ports: | ports: | ||||
- "9000:9000" | - "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: | steps: | ||||
- uses: actions/checkout@v4 | - uses: actions/checkout@v4 | ||||
- uses: actions/setup-go@v5 | - uses: actions/setup-go@v5 |
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. | 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. | 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. | |||||
#### Features Not Yet Supported | |||||
Currently, auto-registration is not supported for SAML. During the external account linking process the user will be prompted to set a username and email address or link to an existing account. | |||||
SAML group mapping is not supported. | |||||
#### Settings | |||||
- `Authentication Name` **(required)** | |||||
- The name of this authentication source (appears in the Gitea ACS and metadata URLs) | |||||
- `SAML NameID Format` **(required)** | |||||
- This specifies how Identity Provider (IdP) users are mapped to Gitea users. This option will be provider specific. | |||||
- `Icon URL` (optional) | |||||
- URL of an icon to display on the Sign-In page for this authentication source. | |||||
- `[Insecure] Skip Assertion Signature Validation` (optional) | |||||
- This option is not recommended and disables integrity verification of IdP SAML assertions. | |||||
- `Identity Provider Metadata URL` (optional if XML set) | |||||
- The URL of the IdP metadata endpoint. | |||||
- This field must be set if `Identity Provider Metadata XML` is left blank. | |||||
- `Identity Provider Metadata XML` (optional if URL set) | |||||
- The XML returned by the IdP metadata endpoint. | |||||
- This field must be set if `Identity Provider Metadata URL` is left blank. | |||||
- `Service Provider Certificate` (optional) | |||||
- X.509-formatted certificate (with `Service Provider Private Key`) used for signing SAML requests. | |||||
- A certificate will be generated if this field is left blank. | |||||
- `Service Provider Private Key` (optional) | |||||
- DSA/RSA private key (with `Service Provider Certificate`) used for signing SAML requests. | |||||
- A private key will be generated if this field is left blank. | |||||
- `Email Assertion Key` (optional) | |||||
- The SAML assertion key used for the IdP user's email (depends on provider configuration). | |||||
- `Name Assertion Key` (optional) | |||||
- The SAML assertion key used for the IdP user's nickname (depends on provider configuration). | |||||
- `Username Assertion Key` (optional) | |||||
- The SAML assertion key used for the IdP user's username (depends on provider configuration). | |||||
### 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`. |
github.com/quasoft/websspi v1.1.2 | github.com/quasoft/websspi v1.1.2 | ||||
github.com/redis/go-redis/v9 v9.4.0 | github.com/redis/go-redis/v9 v9.4.0 | ||||
github.com/robfig/cron/v3 v3.0.1 | github.com/robfig/cron/v3 v3.0.1 | ||||
github.com/russellhaering/gosaml2 v0.9.1 | |||||
github.com/russellhaering/goxmldsig v1.3.0 | |||||
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 | github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 | ||||
github.com/sassoftware/go-rpmutils v0.2.1-0.20240124161140-277b154961dd | github.com/sassoftware/go-rpmutils v0.2.1-0.20240124161140-277b154961dd | ||||
github.com/sergi/go-diff v1.3.1 | github.com/sergi/go-diff v1.3.1 | ||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect | ||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect | ||||
github.com/aymerick/douceur v0.2.0 // indirect | github.com/aymerick/douceur v0.2.0 // indirect | ||||
github.com/beevik/etree v1.1.0 // indirect | |||||
github.com/beorn7/perks v1.0.1 // indirect | github.com/beorn7/perks v1.0.1 // indirect | ||||
github.com/bits-and-blooms/bitset v1.13.0 // indirect | github.com/bits-and-blooms/bitset v1.13.0 // indirect | ||||
github.com/blevesearch/bleve_index_api v1.1.5 // indirect | github.com/blevesearch/bleve_index_api v1.1.5 // indirect | ||||
github.com/imdario/mergo v0.3.16 // indirect | github.com/imdario/mergo v0.3.16 // indirect | ||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect | ||||
github.com/jessevdk/go-flags v1.5.0 // indirect | github.com/jessevdk/go-flags v1.5.0 // indirect | ||||
github.com/jonboulle/clockwork v0.3.0 // indirect | |||||
github.com/josharian/intern v1.0.0 // indirect | github.com/josharian/intern v1.0.0 // indirect | ||||
github.com/kevinburke/ssh_config v1.2.0 // indirect | github.com/kevinburke/ssh_config v1.2.0 // indirect | ||||
github.com/klauspost/pgzip v1.2.6 // indirect | github.com/klauspost/pgzip v1.2.6 // indirect | ||||
github.com/magiconair/properties v1.8.7 // indirect | github.com/magiconair/properties v1.8.7 // indirect | ||||
github.com/mailru/easyjson v0.7.7 // indirect | github.com/mailru/easyjson v0.7.7 // indirect | ||||
github.com/markbates/going v1.0.3 // indirect | github.com/markbates/going v1.0.3 // indirect | ||||
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect | |||||
github.com/mattn/go-colorable v0.1.13 // indirect | github.com/mattn/go-colorable v0.1.13 // indirect | ||||
github.com/mattn/go-runewidth v0.0.15 // indirect | github.com/mattn/go-runewidth v0.0.15 // indirect | ||||
github.com/mholt/acmez v1.2.0 // indirect | github.com/mholt/acmez v1.2.0 // indirect |
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= | ||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= | ||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= | ||||
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= | |||||
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= | |||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= | ||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= | ||||
github.com/bits-and-blooms/bitset v1.1.10/go.mod h1:w0XsmFg8qg6cmpTtJ0z3pKgjTDBMMnI/+I2syrE6XBE= | github.com/bits-and-blooms/bitset v1.1.10/go.mod h1:w0XsmFg8qg6cmpTtJ0z3pKgjTDBMMnI/+I2syrE6XBE= | ||||
github.com/jhillyerd/enmime v1.1.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I= | github.com/jhillyerd/enmime v1.1.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I= | ||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= | ||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= | ||||
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= | |||||
github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= | |||||
github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= | |||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= | ||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= | ||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= | ||||
github.com/markbates/going v1.0.3/go.mod h1:fQiT6v6yQar9UD6bd/D4Z5Afbk9J6BBVBtLiyY4gp2o= | github.com/markbates/going v1.0.3/go.mod h1:fQiT6v6yQar9UD6bd/D4Z5Afbk9J6BBVBtLiyY4gp2o= | ||||
github.com/markbates/goth v1.78.0 h1:7VEIFDycJp9deyVv3YraGBPdD0ZYQW93Y3Aw1eVP3BY= | github.com/markbates/goth v1.78.0 h1:7VEIFDycJp9deyVv3YraGBPdD0ZYQW93Y3Aw1eVP3BY= | ||||
github.com/markbates/goth v1.78.0/go.mod h1:X6xdNgpapSENS0O35iTBBcMHoJDQDfI9bJl+APCkYMc= | github.com/markbates/goth v1.78.0/go.mod h1:X6xdNgpapSENS0O35iTBBcMHoJDQDfI9bJl+APCkYMc= | ||||
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= | |||||
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= | |||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= | ||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= | ||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= | ||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= | ||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= | ||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= | ||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= | |||||
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= | github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= | ||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= | ||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= | ||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= | ||||
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= | github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= | ||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= | ||||
github.com/russellhaering/gosaml2 v0.9.1 h1:H/whrl8NuSoxyW46Ww5lKPskm+5K+qYLw9afqJ/Zef0= | |||||
github.com/russellhaering/gosaml2 v0.9.1/go.mod h1:ja+qgbayxm+0mxBRLMSUuX3COqy+sb0RRhIGun/W2kc= | |||||
github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3cigIwLonTPM= | |||||
github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= | |||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= | github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= | ||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= | ||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= |
"crypto/sha256" | "crypto/sha256" | ||||
"encoding/base32" | "encoding/base32" | ||||
"encoding/base64" | "encoding/base64" | ||||
"encoding/gob" | |||||
"fmt" | "fmt" | ||||
"net" | "net" | ||||
"net/url" | "net/url" | ||||
builtinAllClientIDs = append(builtinAllClientIDs, clientID) | builtinAllClientIDs = append(builtinAllClientIDs, clientID) | ||||
} | } | ||||
// This is needed in order to encode and store the struct in the goth/gothic session | |||||
// during the process of linking the external user. | |||||
gob.Register(LinkAccountUser{}) | |||||
var registeredApps []*OAuth2Application | var registeredApps []*OAuth2Application | ||||
if err := db.GetEngine(ctx).In("client_id", builtinAllClientIDs).Find(®isteredApps); err != nil { | if err := db.GetEngine(ctx).In("client_id", builtinAllClientIDs).Find(®isteredApps); err != nil { | ||||
return err | return err | ||||
return util.ErrNotExist | return util.ErrNotExist | ||||
} | } | ||||
// GetActiveOAuth2SourceByName returns a OAuth2 AuthSource based on the given name | |||||
func GetActiveOAuth2SourceByName(ctx context.Context, name string) (*Source, error) { | |||||
authSource := new(Source) | |||||
has, err := db.GetEngine(ctx).Where("name = ? and type = ? and is_active = ?", name, OAuth2, true).Get(authSource) | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
if !has { | |||||
return nil, fmt.Errorf("oauth2 source not found, name: %q", name) | |||||
} | |||||
return authSource, nil | |||||
} | |||||
func DeleteOAuth2RelictsByUserID(ctx context.Context, userID int64) error { | func DeleteOAuth2RelictsByUserID(ctx context.Context, userID int64) error { | ||||
deleteCond := builder.Select("id").From("oauth2_grant").Where(builder.Eq{"oauth2_grant.user_id": userID}) | deleteCond := builder.Select("id").From("oauth2_grant").Where(builder.Eq{"oauth2_grant.user_id": userID}) | ||||
"code.gitea.io/gitea/modules/timeutil" | "code.gitea.io/gitea/modules/timeutil" | ||||
"code.gitea.io/gitea/modules/util" | "code.gitea.io/gitea/modules/util" | ||||
"github.com/markbates/goth" | |||||
"xorm.io/builder" | "xorm.io/builder" | ||||
"xorm.io/xorm" | "xorm.io/xorm" | ||||
"xorm.io/xorm/convert" | "xorm.io/xorm/convert" | ||||
DLDAP // 5 | DLDAP // 5 | ||||
OAuth2 // 6 | OAuth2 // 6 | ||||
SSPI // 7 | SSPI // 7 | ||||
SAML // 8 | |||||
) | ) | ||||
// String returns the string name of the LoginType | // String returns the string name of the LoginType | ||||
PAM: "PAM", | PAM: "PAM", | ||||
OAuth2: "OAuth2", | OAuth2: "OAuth2", | ||||
SSPI: "SPNEGO with SSPI", | SSPI: "SPNEGO with SSPI", | ||||
SAML: "SAML", | |||||
} | } | ||||
// Config represents login config as far as the db is concerned | // Config represents login config as far as the db is concerned | ||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` | UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` | ||||
} | } | ||||
// LinkAccountUser is used to link an external user with a local user | |||||
type LinkAccountUser struct { | |||||
Type Type | |||||
GothUser goth.User | |||||
} | |||||
// TableName xorm will read the table name from this method | // TableName xorm will read the table name from this method | ||||
func (Source) TableName() string { | func (Source) TableName() string { | ||||
return "login_source" | return "login_source" | ||||
return source.Type == SSPI | return source.Type == SSPI | ||||
} | } | ||||
// IsSAML returns true of this source is of the SAML type. | |||||
func (source *Source) IsSAML() bool { | |||||
return source.Type == SAML | |||||
} | |||||
// HasTLS returns true of this source supports TLS. | // HasTLS returns true of this source supports TLS. | ||||
func (source *Source) HasTLS() bool { | func (source *Source) HasTLS() bool { | ||||
hasTLSer, ok := source.Cfg.(HasTLSer) | hasTLSer, ok := source.Cfg.(HasTLSer) | ||||
func (err ErrSourceInUse) Error() string { | func (err ErrSourceInUse) Error() string { | ||||
return fmt.Sprintf("login source is still used by some users [id: %d]", err.ID) | return fmt.Sprintf("login source is still used by some users [id: %d]", err.ID) | ||||
} | } | ||||
// GetActiveAuthProviderSources returns all activated sources | |||||
func GetActiveAuthProviderSources(ctx context.Context, authType Type) ([]*Source, error) { | |||||
sources := make([]*Source, 0, 1) | |||||
if err := db.GetEngine(ctx).Where("is_active = ? and type = ?", true, authType).Find(&sources); err != nil { | |||||
return nil, err | |||||
} | |||||
return sources, nil | |||||
} | |||||
// GetActiveAuthSourceByName returns an AuthSource based on the given name and type | |||||
func GetActiveAuthSourceByName(ctx context.Context, name string, authType Type) (*Source, error) { | |||||
authSource := new(Source) | |||||
has, err := db.GetEngine(ctx).Where("name = ? and type = ? and is_active = ?", name, authType, true).Get(authSource) | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
if !has { | |||||
return nil, fmt.Errorf("auth source not found, name: %q", name) | |||||
} | |||||
return authSource, nil | |||||
} |
SSPISeparatorReplacement = Separator | SSPISeparatorReplacement = Separator | ||||
SSPIDefaultLanguage = Default Language | SSPIDefaultLanguage = Default Language | ||||
SAMLMetadata = Either SAML Identity Provider metadata URL or XML | |||||
SAMLMetadataURL = SAML Identity Provider metadata URL is invalid | |||||
require_error = ` cannot be empty.` | require_error = ` cannot be empty.` | ||||
alpha_dash_error = ` should contain only alphanumeric, dash ('-') and underscore ('_') characters.` | alpha_dash_error = ` should contain only alphanumeric, dash ('-') and underscore ('_') characters.` | ||||
alpha_dash_dot_error = ` should contain only alphanumeric, dash ('-'), underscore ('_') and dot ('.') characters.` | alpha_dash_dot_error = ` should contain only alphanumeric, dash ('-'), underscore ('_') and dot ('.') characters.` | ||||
auths.sspi_separator_replacement_helper = The character to use to replace the separators of down-level logon names (eg. the \ in "DOMAIN\user") and user principal names (eg. the @ in "user@example.org"). | auths.sspi_separator_replacement_helper = The character to use to replace the separators of down-level logon names (eg. the \ in "DOMAIN\user") and user principal names (eg. the @ in "user@example.org"). | ||||
auths.sspi_default_language = Default user language | auths.sspi_default_language = Default user language | ||||
auths.sspi_default_language_helper = Default language for users automatically created by SSPI auth method. Leave empty if you prefer language to be automatically detected. | auths.sspi_default_language_helper = Default language for users automatically created by SSPI auth method. Leave empty if you prefer language to be automatically detected. | ||||
auths.saml_nameidformat = SAML NameID Format | |||||
auths.saml_identity_provider_metadata_url = Identity Provider Metadata URL | |||||
auths.saml_identity_provider_metadata = Identity Provider Metadata XML | |||||
auths.saml_insecure_skip_assertion_signature_validation = [Insecure] Skip Assertion Signature Validation | |||||
auths.saml_service_provider_certificate = Service Provider Certificate | |||||
auths.saml_service_provider_private_key = Service Provider Private Key | |||||
auths.saml_identity_provider_email_assertion_key = Email Assertion Key | |||||
auths.saml_identity_provider_name_assertion_key = Name Assertion Key | |||||
auths.saml_identity_provider_username_assertion_key = Username Assertion Key | |||||
auths.saml_icon_url = Icon URL | |||||
auths.tips = Tips | auths.tips = Tips | ||||
auths.tips.saml = Documentation can be found at https://docs.gitea.com/usage/authentication#saml | |||||
auths.tips.oauth2.general = OAuth2 Authentication | auths.tips.oauth2.general = OAuth2 Authentication | ||||
auths.tips.oauth2.general.tip = When registering a new OAuth2 authentication, the callback/redirect URL should be: | auths.tips.oauth2.general.tip = When registering a new OAuth2 authentication, the callback/redirect URL should be: | ||||
auths.tip.oauth2_provider = OAuth2 Provider | auths.tip.oauth2_provider = OAuth2 Provider |
actions_service "code.gitea.io/gitea/services/actions" | actions_service "code.gitea.io/gitea/services/actions" | ||||
"code.gitea.io/gitea/services/auth" | "code.gitea.io/gitea/services/auth" | ||||
"code.gitea.io/gitea/services/auth/source/oauth2" | "code.gitea.io/gitea/services/auth/source/oauth2" | ||||
"code.gitea.io/gitea/services/auth/source/saml" | |||||
"code.gitea.io/gitea/services/automerge" | "code.gitea.io/gitea/services/automerge" | ||||
"code.gitea.io/gitea/services/cron" | "code.gitea.io/gitea/services/cron" | ||||
feed_service "code.gitea.io/gitea/services/feed" | feed_service "code.gitea.io/gitea/services/feed" | ||||
log.Info("ORM engine initialization successful!") | log.Info("ORM engine initialization successful!") | ||||
mustInit(system.Init) | mustInit(system.Init) | ||||
mustInitCtx(ctx, oauth2.Init) | mustInitCtx(ctx, oauth2.Init) | ||||
mustInitCtx(ctx, saml.Init) | |||||
mustInit(release_service.Init) | mustInit(release_service.Init) | ||||
// Copyright 2014 The Gogs Authors. All rights reserved. | // Copyright 2014 The Gogs Authors. All rights reserved. | ||||
// Copyright 2024 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | // SPDX-License-Identifier: MIT | ||||
package admin | package admin | ||||
import ( | import ( | ||||
"crypto/tls" | |||||
"crypto/x509" | |||||
"errors" | "errors" | ||||
"fmt" | "fmt" | ||||
"net/http" | "net/http" | ||||
"code.gitea.io/gitea/services/auth/source/ldap" | "code.gitea.io/gitea/services/auth/source/ldap" | ||||
"code.gitea.io/gitea/services/auth/source/oauth2" | "code.gitea.io/gitea/services/auth/source/oauth2" | ||||
pam_service "code.gitea.io/gitea/services/auth/source/pam" | pam_service "code.gitea.io/gitea/services/auth/source/pam" | ||||
"code.gitea.io/gitea/services/auth/source/saml" | |||||
"code.gitea.io/gitea/services/auth/source/smtp" | "code.gitea.io/gitea/services/auth/source/smtp" | ||||
"code.gitea.io/gitea/services/auth/source/sspi" | "code.gitea.io/gitea/services/auth/source/sspi" | ||||
"code.gitea.io/gitea/services/forms" | "code.gitea.io/gitea/services/forms" | ||||
{auth.SMTP.String(), auth.SMTP}, | {auth.SMTP.String(), auth.SMTP}, | ||||
{auth.OAuth2.String(), auth.OAuth2}, | {auth.OAuth2.String(), auth.OAuth2}, | ||||
{auth.SSPI.String(), auth.SSPI}, | {auth.SSPI.String(), auth.SSPI}, | ||||
{auth.SAML.String(), auth.SAML}, | |||||
} | } | ||||
if pam.Supported { | if pam.Supported { | ||||
items = append(items, dropdownItem{auth.Names[auth.PAM], auth.PAM}) | items = append(items, dropdownItem{auth.Names[auth.PAM], auth.PAM}) | ||||
{ldap.SecurityProtocolNames[ldap.SecurityProtocolLDAPS], ldap.SecurityProtocolLDAPS}, | {ldap.SecurityProtocolNames[ldap.SecurityProtocolLDAPS], ldap.SecurityProtocolLDAPS}, | ||||
{ldap.SecurityProtocolNames[ldap.SecurityProtocolStartTLS], ldap.SecurityProtocolStartTLS}, | {ldap.SecurityProtocolNames[ldap.SecurityProtocolStartTLS], ldap.SecurityProtocolStartTLS}, | ||||
} | } | ||||
nameIDFormats = []dropdownItem{ | |||||
{saml.NameIDFormatNames[saml.SAML20Persistent], saml.SAML20Persistent}, // use this as default value | |||||
{saml.NameIDFormatNames[saml.SAML11Email], saml.SAML11Email}, | |||||
{saml.NameIDFormatNames[saml.SAML11Persistent], saml.SAML11Persistent}, | |||||
{saml.NameIDFormatNames[saml.SAML11Unspecified], saml.SAML11Unspecified}, | |||||
{saml.NameIDFormatNames[saml.SAML20Email], saml.SAML20Email}, | |||||
{saml.NameIDFormatNames[saml.SAML20Transient], saml.SAML20Transient}, | |||||
{saml.NameIDFormatNames[saml.SAML20Unspecified], saml.SAML20Unspecified}, | |||||
} | |||||
) | ) | ||||
// NewAuthSource render adding a new auth source page | // NewAuthSource render adding a new auth source page | ||||
ctx.Data["is_sync_enabled"] = true | ctx.Data["is_sync_enabled"] = true | ||||
ctx.Data["AuthSources"] = authSources | ctx.Data["AuthSources"] = authSources | ||||
ctx.Data["SecurityProtocols"] = securityProtocols | ctx.Data["SecurityProtocols"] = securityProtocols | ||||
ctx.Data["CurrentNameIDFormat"] = saml.NameIDFormatNames[saml.SAML20Persistent] | |||||
ctx.Data["NameIDFormats"] = nameIDFormats | |||||
ctx.Data["SMTPAuths"] = smtp.Authenticators | ctx.Data["SMTPAuths"] = smtp.Authenticators | ||||
oauth2providers := oauth2.GetSupportedOAuth2Providers() | oauth2providers := oauth2.GetSupportedOAuth2Providers() | ||||
ctx.Data["OAuth2Providers"] = oauth2providers | ctx.Data["OAuth2Providers"] = oauth2providers | ||||
}, nil | }, nil | ||||
} | } | ||||
func parseSAMLConfig(ctx *context.Context, form forms.AuthenticationForm) (*saml.Source, error) { | |||||
if util.IsEmptyString(form.IdentityProviderMetadata) && util.IsEmptyString(form.IdentityProviderMetadataURL) { | |||||
return nil, fmt.Errorf("%s %s", ctx.Tr("form.SAMLMetadata"), ctx.Tr("form.require_error")) | |||||
} | |||||
if !util.IsEmptyString(form.IdentityProviderMetadataURL) { | |||||
_, err := url.Parse(form.IdentityProviderMetadataURL) | |||||
if err != nil { | |||||
return nil, fmt.Errorf("%s", ctx.Tr("form.SAMLMetadataURL")) | |||||
} | |||||
} | |||||
// check the integrity of the certificate and private key (autogenerated if these form fields are blank) | |||||
if !util.IsEmptyString(form.ServiceProviderCertificate) && !util.IsEmptyString(form.ServiceProviderPrivateKey) { | |||||
keyPair, err := tls.X509KeyPair([]byte(form.ServiceProviderCertificate), []byte(form.ServiceProviderPrivateKey)) | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
} else { | |||||
privateKey, cert, err := saml.GenerateSAMLSPKeypair() | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
form.ServiceProviderPrivateKey = privateKey | |||||
form.ServiceProviderCertificate = cert | |||||
} | |||||
return &saml.Source{ | |||||
IdentityProviderMetadata: form.IdentityProviderMetadata, | |||||
IdentityProviderMetadataURL: form.IdentityProviderMetadataURL, | |||||
InsecureSkipAssertionSignatureValidation: form.InsecureSkipAssertionSignatureValidation, | |||||
NameIDFormat: saml.NameIDFormat(form.NameIDFormat), | |||||
ServiceProviderCertificate: form.ServiceProviderCertificate, | |||||
ServiceProviderPrivateKey: form.ServiceProviderPrivateKey, | |||||
EmailAssertionKey: form.EmailAssertionKey, | |||||
NameAssertionKey: form.NameAssertionKey, | |||||
UsernameAssertionKey: form.UsernameAssertionKey, | |||||
IconURL: form.SAMLIconURL, | |||||
}, nil | |||||
} | |||||
// NewAuthSourcePost response for adding an auth source | // NewAuthSourcePost response for adding an auth source | ||||
func NewAuthSourcePost(ctx *context.Context) { | func NewAuthSourcePost(ctx *context.Context) { | ||||
form := *web.GetForm(ctx).(*forms.AuthenticationForm) | form := *web.GetForm(ctx).(*forms.AuthenticationForm) | ||||
ctx.Data["SMTPAuths"] = smtp.Authenticators | ctx.Data["SMTPAuths"] = smtp.Authenticators | ||||
oauth2providers := oauth2.GetSupportedOAuth2Providers() | oauth2providers := oauth2.GetSupportedOAuth2Providers() | ||||
ctx.Data["OAuth2Providers"] = oauth2providers | ctx.Data["OAuth2Providers"] = oauth2providers | ||||
ctx.Data["CurrentNameIDFormat"] = saml.NameIDFormatNames[saml.NameIDFormat(form.NameIDFormat)] | |||||
ctx.Data["NameIDFormats"] = nameIDFormats | |||||
ctx.Data["SSPIAutoCreateUsers"] = true | ctx.Data["SSPIAutoCreateUsers"] = true | ||||
ctx.Data["SSPIAutoActivateUsers"] = true | ctx.Data["SSPIAutoActivateUsers"] = true | ||||
ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_of_type_exist"), tplAuthNew, form) | ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_of_type_exist"), tplAuthNew, form) | ||||
return | return | ||||
} | } | ||||
case auth.SAML: | |||||
var err error | |||||
config, err = parseSAMLConfig(ctx, form) | |||||
if err != nil { | |||||
ctx.RenderWithErr(err.Error(), tplAuthNew, form) | |||||
return | |||||
} | |||||
default: | default: | ||||
ctx.Error(http.StatusBadRequest) | ctx.Error(http.StatusBadRequest) | ||||
return | return | ||||
ctx.Data["SMTPAuths"] = smtp.Authenticators | ctx.Data["SMTPAuths"] = smtp.Authenticators | ||||
oauth2providers := oauth2.GetSupportedOAuth2Providers() | oauth2providers := oauth2.GetSupportedOAuth2Providers() | ||||
ctx.Data["OAuth2Providers"] = oauth2providers | ctx.Data["OAuth2Providers"] = oauth2providers | ||||
ctx.Data["NameIDFormats"] = nameIDFormats | |||||
source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid")) | source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid")) | ||||
if err != nil { | if err != nil { | ||||
} | } | ||||
ctx.Data["Source"] = source | ctx.Data["Source"] = source | ||||
ctx.Data["HasTLS"] = source.HasTLS() | ctx.Data["HasTLS"] = source.HasTLS() | ||||
if source.IsSAML() { | |||||
ctx.Data["CurrentNameIDFormat"] = saml.NameIDFormatNames[source.Cfg.(*saml.Source).NameIDFormat] | |||||
} | |||||
if source.IsOAuth2() { | if source.IsOAuth2() { | ||||
type Named interface { | type Named interface { | ||||
} | } | ||||
ctx.Data["Source"] = source | ctx.Data["Source"] = source | ||||
ctx.Data["HasTLS"] = source.HasTLS() | ctx.Data["HasTLS"] = source.HasTLS() | ||||
ctx.Data["CurrentNameIDFormat"] = saml.NameIDFormatNames[saml.SAML20Persistent] | |||||
ctx.Data["NameIDFormats"] = nameIDFormats | |||||
if ctx.HasError() { | if ctx.HasError() { | ||||
ctx.HTML(http.StatusOK, tplAuthEdit) | ctx.HTML(http.StatusOK, tplAuthEdit) | ||||
ctx.RenderWithErr(err.Error(), tplAuthEdit, form) | ctx.RenderWithErr(err.Error(), tplAuthEdit, form) | ||||
return | return | ||||
} | } | ||||
case auth.SAML: | |||||
config, err = parseSAMLConfig(ctx, form) | |||||
if err != nil { | |||||
ctx.RenderWithErr(err.Error(), tplAuthEdit, form) | |||||
return | |||||
} | |||||
default: | default: | ||||
ctx.Error(http.StatusBadRequest) | ctx.Error(http.StatusBadRequest) | ||||
return | return |
"code.gitea.io/gitea/routers/utils" | "code.gitea.io/gitea/routers/utils" | ||||
auth_service "code.gitea.io/gitea/services/auth" | auth_service "code.gitea.io/gitea/services/auth" | ||||
"code.gitea.io/gitea/services/auth/source/oauth2" | "code.gitea.io/gitea/services/auth/source/oauth2" | ||||
"code.gitea.io/gitea/services/auth/source/saml" | |||||
"code.gitea.io/gitea/services/externalaccount" | "code.gitea.io/gitea/services/externalaccount" | ||||
"code.gitea.io/gitea/services/forms" | "code.gitea.io/gitea/services/forms" | ||||
"code.gitea.io/gitea/services/mailer" | "code.gitea.io/gitea/services/mailer" | ||||
return | return | ||||
} | } | ||||
ctx.Data["OAuth2Providers"] = oauth2Providers | ctx.Data["OAuth2Providers"] = oauth2Providers | ||||
samlProviders, err := saml.GetSAMLProviders(ctx, util.OptionalBoolTrue) | |||||
if err != nil { | |||||
ctx.ServerError("UserSignIn", err) | |||||
return | |||||
} | |||||
ctx.Data["SAMLProviders"] = samlProviders | |||||
ctx.Data["Title"] = ctx.Tr("sign_in") | ctx.Data["Title"] = ctx.Tr("sign_in") | ||||
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" | ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" | ||||
ctx.Data["PageIsSignIn"] = true | ctx.Data["PageIsSignIn"] = true | ||||
return | return | ||||
} | } | ||||
ctx.Data["OAuth2Providers"] = oauth2Providers | ctx.Data["OAuth2Providers"] = oauth2Providers | ||||
samlProviders, err := saml.GetSAMLProviders(ctx, util.OptionalBoolTrue) | |||||
if err != nil { | |||||
ctx.ServerError("UserSignIn", err) | |||||
return | |||||
} | |||||
ctx.Data["SAMLProviders"] = samlProviders | |||||
ctx.Data["Title"] = ctx.Tr("sign_in") | ctx.Data["Title"] = ctx.Tr("sign_in") | ||||
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" | ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" | ||||
ctx.Data["PageIsSignIn"] = true | ctx.Data["PageIsSignIn"] = true | ||||
Passwd: form.Password, | Passwd: form.Password, | ||||
} | } | ||||
if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil, false, auth.NoType) { | |||||
if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil, false) { | |||||
// error already handled | // error already handled | ||||
return | return | ||||
} | } | ||||
// createAndHandleCreatedUser calls createUserInContext and | // createAndHandleCreatedUser calls createUserInContext and | ||||
// then handleUserCreated. | // then handleUserCreated. | ||||
func createAndHandleCreatedUser(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool, authType auth.Type) bool { | |||||
if !createUserInContext(ctx, tpl, form, u, overwrites, gothUser, allowLink, authType) { | |||||
func createAndHandleCreatedUser(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) bool { | |||||
if !createUserInContext(ctx, tpl, form, u, overwrites, gothUser, allowLink) { | |||||
return false | return false | ||||
} | } | ||||
return handleUserCreated(ctx, u, gothUser, authType) | |||||
return handleUserCreated(ctx, u, gothUser) | |||||
} | } | ||||
// createUserInContext creates a user and handles errors within a given context. | // createUserInContext creates a user and handles errors within a given context. | ||||
// Optionally a template can be specified. | // Optionally a template can be specified. | ||||
func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool, authType auth.Type) (ok bool) { | |||||
func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) (ok bool) { | |||||
if err := user_model.CreateUser(ctx, u, overwrites); err != nil { | if err := user_model.CreateUser(ctx, u, overwrites); err != nil { | ||||
if allowLink && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) { | if allowLink && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) { | ||||
if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingAuto { | if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingAuto { | ||||
} | } | ||||
// TODO: probably we should respect 'remember' user's choice... | // TODO: probably we should respect 'remember' user's choice... | ||||
linkAccount(ctx, user, *gothUser, true, authType) | |||||
linkAccount(ctx, user, *gothUser, true) | |||||
return false // user is already created here, all redirects are handled | return false // user is already created here, all redirects are handled | ||||
} else if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingLogin { | } else if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingLogin { | ||||
showLinkingLogin(ctx, *gothUser, authType) | |||||
showLinkingLogin(ctx, *gothUser) | |||||
return false // user will be created only after linking login | return false // user will be created only after linking login | ||||
} | } | ||||
} | } | ||||
// handleUserCreated does additional steps after a new user is created. | // handleUserCreated does additional steps after a new user is created. | ||||
// It auto-sets admin for the only user, updates the optional external user and | // It auto-sets admin for the only user, updates the optional external user and | ||||
// sends a confirmation email if required. | // sends a confirmation email if required. | ||||
func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.User, authType auth.Type) (ok bool) { | |||||
func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.User) (ok bool) { | |||||
// Auto-set admin for the only user. | // Auto-set admin for the only user. | ||||
if user_model.CountUsers(ctx, nil) == 1 { | if user_model.CountUsers(ctx, nil) == 1 { | ||||
opts := &user_service.UpdateOptions{ | opts := &user_service.UpdateOptions{ | ||||
// update external user information | // update external user information | ||||
if gothUser != nil { | if gothUser != nil { | ||||
if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser, authType); err != nil { | |||||
if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser); err != nil { | |||||
if !errors.Is(err, util.ErrNotExist) { | if !errors.Is(err, util.ErrNotExist) { | ||||
log.Error("UpdateExternalUser failed: %v", err) | log.Error("UpdateExternalUser failed: %v", err) | ||||
} | } |
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" | ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" | ||||
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" | ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" | ||||
externalLinkUser := ctx.Session.Get("linkAccountUser") | |||||
if externalLinkUser == nil { | |||||
gothUser := ctx.Session.Get("linkAccountGothUser") | |||||
if gothUser == nil { | |||||
ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) | ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) | ||||
return | return | ||||
} | } | ||||
gu := externalLinkUser.(auth.LinkAccountUser).GothUser | |||||
gu, _ := gothUser.(goth.User) | |||||
uname, err := getUserName(&gu) | uname, err := getUserName(&gu) | ||||
if err != nil { | if err != nil { | ||||
ctx.ServerError("UserSignIn", err) | ctx.ServerError("UserSignIn", err) | ||||
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" | ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" | ||||
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" | ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" | ||||
externalLinkUserInterface := ctx.Session.Get("linkAccountUser") | |||||
if externalLinkUserInterface == nil { | |||||
gothUser := ctx.Session.Get("linkAccountGothUser") | |||||
if gothUser == nil { | |||||
ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) | ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) | ||||
return | return | ||||
} | } | ||||
externalLinkUser := externalLinkUserInterface.(auth.LinkAccountUser) | |||||
if ctx.HasError() { | if ctx.HasError() { | ||||
ctx.HTML(http.StatusOK, tplLinkAccount) | ctx.HTML(http.StatusOK, tplLinkAccount) | ||||
return | return | ||||
return | return | ||||
} | } | ||||
linkAccount(ctx, u, externalLinkUser.GothUser, signInForm.Remember, externalLinkUser.Type) | |||||
linkAccount(ctx, u, gothUser.(goth.User), signInForm.Remember) | |||||
} | } | ||||
func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool, authType auth.Type) { | |||||
func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool) { | |||||
updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) | updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) | ||||
// If this user is enrolled in 2FA, we can't sign the user in just yet. | // If this user is enrolled in 2FA, we can't sign the user in just yet. | ||||
return | return | ||||
} | } | ||||
err = externalaccount.LinkAccountToUser(ctx, u, gothUser, authType) | |||||
err = externalaccount.LinkAccountToUser(ctx, u, gothUser) | |||||
if err != nil { | if err != nil { | ||||
ctx.ServerError("UserLinkAccount", err) | ctx.ServerError("UserLinkAccount", err) | ||||
return | return | ||||
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" | ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" | ||||
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" | ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" | ||||
externalLinkUser := ctx.Session.Get("linkAccountUser") | |||||
if externalLinkUser == nil { | |||||
gothUserInterface := ctx.Session.Get("linkAccountGothUser") | |||||
if gothUserInterface == nil { | |||||
ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session")) | ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session")) | ||||
return | return | ||||
} | } | ||||
linkUser, ok := externalLinkUser.(auth.LinkAccountUser) | |||||
gothUser, ok := gothUserInterface.(goth.User) | |||||
if !ok { | if !ok { | ||||
ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountUser type is %t but not goth.User", externalLinkUser)) | |||||
ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountGothUser type is %t but not goth.User", gothUserInterface)) | |||||
return | return | ||||
} | } | ||||
} | } | ||||
} | } | ||||
authSource, err := auth.GetActiveAuthSourceByName(ctx, linkUser.GothUser.Provider, linkUser.Type) | |||||
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider) | |||||
if err != nil { | if err != nil { | ||||
ctx.ServerError("CreateUser", err) | ctx.ServerError("CreateUser", err) | ||||
return | return | ||||
Name: form.UserName, | Name: form.UserName, | ||||
Email: form.Email, | Email: form.Email, | ||||
Passwd: form.Password, | Passwd: form.Password, | ||||
LoginType: authSource.Type, | |||||
LoginType: auth.OAuth2, | |||||
LoginSource: authSource.ID, | LoginSource: authSource.ID, | ||||
LoginName: linkUser.GothUser.UserID, | |||||
LoginName: gothUser.UserID, | |||||
} | } | ||||
if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &linkUser.GothUser, false, linkUser.Type) { | |||||
if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &gothUser, false) { | |||||
// error already handled | // error already handled | ||||
return | return | ||||
} | } | ||||
if linkUser.Type == auth.OAuth2 { | |||||
source := authSource.Cfg.(*oauth2.Source) | |||||
if err := syncGroupsToTeams(ctx, source, &linkUser.GothUser, u); err != nil { | |||||
ctx.ServerError("SyncGroupsToTeams", err) | |||||
return | |||||
} | |||||
source := authSource.Cfg.(*oauth2.Source) | |||||
if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { | |||||
ctx.ServerError("SyncGroupsToTeams", err) | |||||
return | |||||
} | } | ||||
// TODO we will support some form of group mapping for SAML | |||||
handleSignIn(ctx, u, false) | handleSignIn(ctx, u, false) | ||||
} | } |
func SignInOAuth(ctx *context.Context) { | func SignInOAuth(ctx *context.Context) { | ||||
provider := ctx.Params(":provider") | provider := ctx.Params(":provider") | ||||
authSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.OAuth2) | |||||
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) | |||||
if err != nil { | if err != nil { | ||||
ctx.ServerError("SignIn", err) | ctx.ServerError("SignIn", err) | ||||
return | return | ||||
} | } | ||||
// first look if the provider is still active | // first look if the provider is still active | ||||
authSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.OAuth2) | |||||
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) | |||||
if err != nil { | if err != nil { | ||||
ctx.ServerError("SignIn", err) | ctx.ServerError("SignIn", err) | ||||
return | return | ||||
if u == nil { | if u == nil { | ||||
if ctx.Doer != nil { | if ctx.Doer != nil { | ||||
// attach user to already logged in user | // attach user to already logged in user | ||||
err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser, auth.OAuth2) | |||||
err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser) | |||||
if err != nil { | if err != nil { | ||||
ctx.ServerError("UserLinkAccount", err) | ctx.ServerError("UserLinkAccount", err) | ||||
return | return | ||||
u.IsAdmin = isAdmin.ValueOrDefault(false) | u.IsAdmin = isAdmin.ValueOrDefault(false) | ||||
u.IsRestricted = isRestricted.ValueOrDefault(false) | u.IsRestricted = isRestricted.ValueOrDefault(false) | ||||
if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled, auth.OAuth2) { | |||||
if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { | |||||
// error already handled | // error already handled | ||||
return | return | ||||
} | } | ||||
} | } | ||||
} else { | } else { | ||||
// no existing user is found, request attach or new account | // no existing user is found, request attach or new account | ||||
showLinkingLogin(ctx, gothUser, auth.OAuth2) | |||||
showLinkingLogin(ctx, gothUser) | |||||
return | return | ||||
} | } | ||||
} | } | ||||
return isAdmin, isRestricted | return isAdmin, isRestricted | ||||
} | } | ||||
func showLinkingLogin(ctx *context.Context, gothUser goth.User, authType auth.Type) { | |||||
func showLinkingLogin(ctx *context.Context, gothUser goth.User) { | |||||
if err := updateSession(ctx, nil, map[string]any{ | if err := updateSession(ctx, nil, map[string]any{ | ||||
"linkAccountUser": auth.LinkAccountUser{ | |||||
Type: authType, | |||||
GothUser: gothUser, | |||||
}, | |||||
"linkAccountGothUser": gothUser, | |||||
}); err != nil { | }); err != nil { | ||||
ctx.ServerError("updateSession", err) | ctx.ServerError("updateSession", err) | ||||
return | return | ||||
} | } | ||||
// update external user information | // update external user information | ||||
if err := externalaccount.UpdateExternalUser(ctx, u, gothUser, auth.OAuth2); err != nil { | |||||
if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil { | |||||
if !errors.Is(err, util.ErrNotExist) { | if !errors.Is(err, util.ErrNotExist) { | ||||
log.Error("UpdateExternalUser failed: %v", err) | log.Error("UpdateExternalUser failed: %v", err) | ||||
} | } |
"net/http" | "net/http" | ||||
"net/url" | "net/url" | ||||
auth_model "code.gitea.io/gitea/models/auth" | |||||
user_model "code.gitea.io/gitea/models/user" | user_model "code.gitea.io/gitea/models/user" | ||||
"code.gitea.io/gitea/modules/auth/openid" | "code.gitea.io/gitea/modules/auth/openid" | ||||
"code.gitea.io/gitea/modules/base" | "code.gitea.io/gitea/modules/base" | ||||
Email: form.Email, | Email: form.Email, | ||||
Passwd: password, | Passwd: password, | ||||
} | } | ||||
if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil, false, auth_model.NoType) { | |||||
if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil, false) { | |||||
// error already handled | // error already handled | ||||
return | return | ||||
} | } | ||||
return | return | ||||
} | } | ||||
if !handleUserCreated(ctx, u, nil, auth_model.NoType) { | |||||
if !handleUserCreated(ctx, u, nil) { | |||||
// error already handled | // error already handled | ||||
return | return | ||||
} | } |
// Copyright 2024 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package auth | |||||
import ( | |||||
"errors" | |||||
"fmt" | |||||
"net/http" | |||||
"strings" | |||||
"code.gitea.io/gitea/models/auth" | |||||
user_model "code.gitea.io/gitea/models/user" | |||||
"code.gitea.io/gitea/modules/context" | |||||
"code.gitea.io/gitea/modules/log" | |||||
"code.gitea.io/gitea/modules/setting" | |||||
"code.gitea.io/gitea/modules/util" | |||||
"code.gitea.io/gitea/modules/web/middleware" | |||||
"code.gitea.io/gitea/services/auth/source/saml" | |||||
"code.gitea.io/gitea/services/externalaccount" | |||||
"github.com/markbates/goth" | |||||
) | |||||
func SignInSAML(ctx *context.Context) { | |||||
provider := ctx.Params(":provider") | |||||
loginSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.SAML) | |||||
if err != nil || loginSource == nil { | |||||
ctx.NotFound("SAMLMetadata", err) | |||||
return | |||||
} | |||||
if err = loginSource.Cfg.(*saml.Source).Callout(ctx.Req, ctx.Resp); err != nil { | |||||
if strings.Contains(err.Error(), "no provider for ") { | |||||
ctx.Error(http.StatusNotFound) | |||||
return | |||||
} | |||||
ctx.ServerError("SignIn", err) | |||||
} | |||||
} | |||||
func SignInSAMLCallback(ctx *context.Context) { | |||||
provider := ctx.Params(":provider") | |||||
loginSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.SAML) | |||||
if err != nil || loginSource == nil { | |||||
ctx.NotFound("SignInSAMLCallback", err) | |||||
return | |||||
} | |||||
if loginSource == nil { | |||||
ctx.ServerError("SignIn", fmt.Errorf("no valid provider found, check configured callback url in provider")) | |||||
return | |||||
} | |||||
u, gothUser, err := samlUserLoginCallback(*ctx, loginSource, ctx.Req, ctx.Resp) | |||||
if err != nil { | |||||
ctx.ServerError("SignInSAMLCallback", err) | |||||
return | |||||
} | |||||
if u == nil { | |||||
if ctx.Doer != nil { | |||||
// attach user to already logged in user | |||||
err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser, auth.SAML) | |||||
if err != nil { | |||||
ctx.ServerError("LinkAccountToUser", err) | |||||
return | |||||
} | |||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security") | |||||
return | |||||
} else if !setting.Service.AllowOnlyInternalRegistration && false { | |||||
// TODO: allow auto registration from saml users (OAuth2 uses the following setting.OAuth2Client.EnableAutoRegistration) | |||||
} else { | |||||
// no existing user is found, request attach or new account | |||||
showLinkingLogin(ctx, gothUser, auth.SAML) | |||||
return | |||||
} | |||||
} | |||||
handleSamlSignIn(ctx, loginSource, u, gothUser) | |||||
} | |||||
func handleSamlSignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) { | |||||
if err := updateSession(ctx, nil, map[string]any{ | |||||
"uid": u.ID, | |||||
"uname": u.Name, | |||||
}); err != nil { | |||||
ctx.ServerError("updateSession", err) | |||||
return | |||||
} | |||||
// Clear whatever CSRF cookie has right now, force to generate a new one | |||||
ctx.Csrf.DeleteCookie(ctx) | |||||
// Register last login | |||||
u.SetLastLogin() | |||||
// update external user information | |||||
if err := externalaccount.UpdateExternalUser(ctx, u, gothUser, auth.SAML); err != nil { | |||||
if !errors.Is(err, util.ErrNotExist) { | |||||
log.Error("UpdateExternalUser failed: %v", err) | |||||
} | |||||
} | |||||
if err := resetLocale(ctx, u); err != nil { | |||||
ctx.ServerError("resetLocale", err) | |||||
return | |||||
} | |||||
if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { | |||||
middleware.DeleteRedirectToCookie(ctx.Resp) | |||||
ctx.RedirectToFirst(redirectTo) | |||||
return | |||||
} | |||||
ctx.Redirect(setting.AppSubURL + "/") | |||||
} | |||||
func samlUserLoginCallback(ctx context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { | |||||
samlSource := authSource.Cfg.(*saml.Source) | |||||
gothUser, err := samlSource.Callback(request, response) | |||||
if err != nil { | |||||
return nil, gothUser, err | |||||
} | |||||
user := &user_model.User{ | |||||
LoginName: gothUser.UserID, | |||||
LoginType: auth.SAML, | |||||
LoginSource: authSource.ID, | |||||
} | |||||
hasUser, err := user_model.GetUser(ctx, user) | |||||
if err != nil { | |||||
return nil, goth.User{}, err | |||||
} | |||||
if hasUser { | |||||
return user, gothUser, nil | |||||
} | |||||
// search in external linked users | |||||
externalLoginUser := &user_model.ExternalLoginUser{ | |||||
ExternalID: gothUser.UserID, | |||||
LoginSourceID: authSource.ID, | |||||
} | |||||
hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser) | |||||
if err != nil { | |||||
return nil, goth.User{}, err | |||||
} | |||||
if hasUser { | |||||
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID) | |||||
return user, gothUser, err | |||||
} | |||||
// no user found to login | |||||
return nil, gothUser, nil | |||||
} | |||||
func SAMLMetadata(ctx *context.Context) { | |||||
provider := ctx.Params(":provider") | |||||
loginSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.SAML) | |||||
if err != nil || loginSource == nil { | |||||
ctx.NotFound("SAMLMetadata", err) | |||||
return | |||||
} | |||||
if err = loginSource.Cfg.(*saml.Source).Metadata(ctx.Req, ctx.Resp); err != nil { | |||||
ctx.ServerError("SAMLMetadata", err) | |||||
} | |||||
} |
m.Get("/{provider}", auth.SignInOAuth) | m.Get("/{provider}", auth.SignInOAuth) | ||||
m.Get("/{provider}/callback", auth.SignInOAuthCallback) | m.Get("/{provider}/callback", auth.SignInOAuthCallback) | ||||
}) | }) | ||||
m.Group("/saml", func() { | |||||
m.Get("/{provider}", auth.SignInSAML) // redir to SAML IDP | |||||
m.Post("/{provider}/acs", auth.SignInSAMLCallback) | |||||
m.Get("/{provider}/metadata", auth.SAMLMetadata) | |||||
}) | |||||
}) | }) | ||||
// ***** END: User ***** | // ***** END: User ***** | ||||
// Copyright 2023 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package saml_test | |||||
import ( | |||||
auth_model "code.gitea.io/gitea/models/auth" | |||||
"code.gitea.io/gitea/services/auth" | |||||
"code.gitea.io/gitea/services/auth/source/saml" | |||||
) | |||||
// This test file exists to assert that our Source exposes the interfaces that we expect | |||||
// It tightly binds the interfaces and implementation without breaking go import cycles | |||||
type sourceInterface interface { | |||||
auth_model.Config | |||||
auth_model.SourceSettable | |||||
auth_model.RegisterableSource | |||||
auth.PasswordAuthenticator | |||||
} | |||||
var _ (sourceInterface) = &saml.Source{} |
// Copyright 2023 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package saml | |||||
import ( | |||||
"context" | |||||
"sync" | |||||
"code.gitea.io/gitea/models/auth" | |||||
"code.gitea.io/gitea/modules/log" | |||||
) | |||||
var samlRWMutex = sync.RWMutex{} | |||||
func Init(ctx context.Context) error { | |||||
loginSources, _ := auth.GetActiveAuthProviderSources(ctx, auth.SAML) | |||||
for _, source := range loginSources { | |||||
samlSource, ok := source.Cfg.(*Source) | |||||
if !ok { | |||||
continue | |||||
} | |||||
err := samlSource.RegisterSource() | |||||
if err != nil { | |||||
log.Error("Unable to register source: %s due to Error: %v.", source.Name, err) | |||||
} | |||||
} | |||||
return nil | |||||
} |
// Copyright 2023 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package saml | |||||
type NameIDFormat int | |||||
const ( | |||||
SAML11Email NameIDFormat = iota + 1 | |||||
SAML11Persistent | |||||
SAML11Unspecified | |||||
SAML20Email | |||||
SAML20Persistent | |||||
SAML20Transient | |||||
SAML20Unspecified | |||||
) | |||||
const DefaultNameIDFormat NameIDFormat = SAML20Persistent | |||||
var NameIDFormatNames = map[NameIDFormat]string{ | |||||
SAML11Email: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", | |||||
SAML11Persistent: "urn:oasis:names:tc:SAML:1.1:nameid-format:persistent", | |||||
SAML11Unspecified: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", | |||||
SAML20Email: "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress", | |||||
SAML20Persistent: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", | |||||
SAML20Transient: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", | |||||
SAML20Unspecified: "urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified", | |||||
} | |||||
// String returns the name of the NameIDFormat | |||||
func (n NameIDFormat) String() string { | |||||
return NameIDFormatNames[n] | |||||
} | |||||
// Int returns the int value of the NameIDFormat | |||||
func (n NameIDFormat) Int() int { | |||||
return int(n) | |||||
} |
// Copyright 2023 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package saml | |||||
import ( | |||||
"context" | |||||
"fmt" | |||||
"html" | |||||
"html/template" | |||||
"io" | |||||
"net/http" | |||||
"sort" | |||||
"time" | |||||
"code.gitea.io/gitea/models/auth" | |||||
"code.gitea.io/gitea/models/db" | |||||
"code.gitea.io/gitea/modules/httplib" | |||||
"code.gitea.io/gitea/modules/svg" | |||||
"code.gitea.io/gitea/modules/util" | |||||
) | |||||
// Providers is list of known/available providers. | |||||
type Providers map[string]Source | |||||
var providers = Providers{} | |||||
// Provider is an interface for describing a single SAML provider | |||||
type Provider interface { | |||||
Name() string | |||||
IconHTML(size int) template.HTML | |||||
} | |||||
// AuthSourceProvider is a SAML provider | |||||
type AuthSourceProvider struct { | |||||
sourceName, iconURL string | |||||
} | |||||
func (p *AuthSourceProvider) Name() string { | |||||
return p.sourceName | |||||
} | |||||
func (p *AuthSourceProvider) IconHTML(size int) template.HTML { | |||||
if p.iconURL != "" { | |||||
return template.HTML(fmt.Sprintf(`<img class="gt-object-contain gt-mr-3" width="%d" height="%d" src="%s" alt="%s">`, | |||||
size, | |||||
size, | |||||
html.EscapeString(p.iconURL), html.EscapeString(p.Name()), | |||||
)) | |||||
} | |||||
return svg.RenderHTML("gitea-lock-cog", size, "gt-mr-3") | |||||
} | |||||
func readIdentityProviderMetadata(ctx context.Context, source *Source) ([]byte, error) { | |||||
if source.IdentityProviderMetadata != "" { | |||||
return []byte(source.IdentityProviderMetadata), nil | |||||
} | |||||
req := httplib.NewRequest(source.IdentityProviderMetadataURL, "GET") | |||||
req.SetTimeout(20*time.Second, time.Minute) | |||||
resp, err := req.Response() | |||||
if err != nil { | |||||
return nil, fmt.Errorf("Unable to contact gitea: %v", err) | |||||
} | |||||
defer resp.Body.Close() | |||||
if resp.StatusCode != http.StatusOK { | |||||
return nil, err | |||||
} | |||||
data, err := io.ReadAll(resp.Body) | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
return data, nil | |||||
} | |||||
func createProviderFromSource(source *auth.Source) (Provider, error) { | |||||
samlCfg, ok := source.Cfg.(*Source) | |||||
if !ok { | |||||
return nil, fmt.Errorf("invalid SAML source config: %v", samlCfg) | |||||
} | |||||
return &AuthSourceProvider{sourceName: source.Name, iconURL: samlCfg.IconURL}, nil | |||||
} | |||||
// GetSAMLProviders returns the list of configured SAML providers | |||||
func GetSAMLProviders(ctx context.Context, isActive util.OptionalBool) ([]Provider, error) { | |||||
authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ | |||||
IsActive: isActive, | |||||
LoginType: auth.SAML, | |||||
}) | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
samlProviders := make([]Provider, 0, len(authSources)) | |||||
for _, source := range authSources { | |||||
p, err := createProviderFromSource(source) | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
samlProviders = append(samlProviders, p) | |||||
} | |||||
sort.Slice(samlProviders, func(i, j int) bool { | |||||
return samlProviders[i].Name() < samlProviders[j].Name() | |||||
}) | |||||
return samlProviders, nil | |||||
} |
// Copyright 2023 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package saml | |||||
import ( | |||||
"context" | |||||
"crypto/rand" | |||||
"crypto/rsa" | |||||
"crypto/tls" | |||||
"crypto/x509" | |||||
"encoding/base64" | |||||
"encoding/pem" | |||||
"encoding/xml" | |||||
"errors" | |||||
"fmt" | |||||
"math/big" | |||||
"net/url" | |||||
"time" | |||||
"code.gitea.io/gitea/models/auth" | |||||
"code.gitea.io/gitea/modules/json" | |||||
"code.gitea.io/gitea/modules/log" | |||||
"code.gitea.io/gitea/modules/setting" | |||||
saml2 "github.com/russellhaering/gosaml2" | |||||
"github.com/russellhaering/gosaml2/types" | |||||
dsig "github.com/russellhaering/goxmldsig" | |||||
) | |||||
// Source holds configuration for the SAML login source. | |||||
type Source struct { | |||||
// IdentityProviderMetadata description: The SAML Identity Provider metadata XML contents (for static configuration of the SAML Service Provider). The value of this field should be an XML document whose root element is `<EntityDescriptor>` or `<EntityDescriptors>`. To escape the value into a JSON string, you may want to use a tool like https://json-escape-text.now.sh. | |||||
IdentityProviderMetadata string | |||||
// IdentityProviderMetadataURL description: The SAML Identity Provider metadata URL (for dynamic configuration of the SAML Service Provider). | |||||
IdentityProviderMetadataURL string | |||||
// InsecureSkipAssertionSignatureValidation description: Whether the Service Provider should (insecurely) accept assertions from the Identity Provider without a valid signature. | |||||
InsecureSkipAssertionSignatureValidation bool | |||||
// NameIDFormat description: The SAML NameID format to use when performing user authentication. | |||||
NameIDFormat NameIDFormat | |||||
// ServiceProviderCertificate description: The SAML Service Provider certificate in X.509 encoding (begins with "-----BEGIN CERTIFICATE-----"). This certificate is used by the Identity Provider to validate the Service Provider's AuthnRequests and LogoutRequests. It corresponds to the Service Provider's private key (`serviceProviderPrivateKey`). To escape the value into a JSON string, you may want to use a tool like https://json-escape-text.now.sh. | |||||
ServiceProviderCertificate string | |||||
// ServiceProviderIssuer description: The SAML Service Provider name, used to identify this Service Provider. This is required if the "externalURL" field is not set (as the SAML metadata endpoint is computed as "<externalURL>.auth/saml/metadata"), or when using multiple SAML authentication providers. | |||||
ServiceProviderIssuer string | |||||
// ServiceProviderPrivateKey description: The SAML Service Provider private key in PKCS#8 encoding (begins with "-----BEGIN PRIVATE KEY-----"). This private key is used to sign AuthnRequests and LogoutRequests. It corresponds to the Service Provider's certificate (`serviceProviderCertificate`). To escape the value into a JSON string, you may want to use a tool like https://json-escape-text.now.sh. | |||||
ServiceProviderPrivateKey string | |||||
CallbackURL string | |||||
IconURL string | |||||
// EmailAssertionKey description: Assertion key for user.Email | |||||
EmailAssertionKey string | |||||
// NameAssertionKey description: Assertion key for user.NickName | |||||
NameAssertionKey string | |||||
// UsernameAssertionKey description: Assertion key for user.Name | |||||
UsernameAssertionKey string | |||||
// reference to the authSource | |||||
authSource *auth.Source | |||||
samlSP *saml2.SAMLServiceProvider | |||||
} | |||||
func GenerateSAMLSPKeypair() (string, string, error) { | |||||
key, err := rsa.GenerateKey(rand.Reader, 4096) | |||||
if err != nil { | |||||
return "", "", err | |||||
} | |||||
keyBytes := x509.MarshalPKCS1PrivateKey(key) | |||||
keyPem := pem.EncodeToMemory( | |||||
&pem.Block{ | |||||
Type: "RSA PRIVATE KEY", | |||||
Bytes: keyBytes, | |||||
}, | |||||
) | |||||
now := time.Now() | |||||
template := &x509.Certificate{ | |||||
SerialNumber: big.NewInt(0), | |||||
NotBefore: now.Add(-5 * time.Minute), | |||||
NotAfter: now.Add(365 * 24 * time.Hour), | |||||
KeyUsage: x509.KeyUsageDigitalSignature, | |||||
ExtKeyUsage: []x509.ExtKeyUsage{}, | |||||
BasicConstraintsValid: true, | |||||
} | |||||
certificate, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) | |||||
if err != nil { | |||||
return "", "", err | |||||
} | |||||
certPem := pem.EncodeToMemory( | |||||
&pem.Block{ | |||||
Type: "CERTIFICATE", | |||||
Bytes: certificate, | |||||
}, | |||||
) | |||||
return string(keyPem), string(certPem), nil | |||||
} | |||||
func (source *Source) initSAMLSp() error { | |||||
source.CallbackURL = setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/acs" | |||||
idpMetadata, err := readIdentityProviderMetadata(context.Background(), source) | |||||
if err != nil { | |||||
return err | |||||
} | |||||
{ | |||||
if source.IdentityProviderMetadataURL != "" { | |||||
log.Trace(fmt.Sprintf("Identity Provider metadata: %s", source.IdentityProviderMetadataURL), string(idpMetadata)) | |||||
} | |||||
} | |||||
metadata := &types.EntityDescriptor{} | |||||
err = xml.Unmarshal(idpMetadata, metadata) | |||||
if err != nil { | |||||
return err | |||||
} | |||||
certStore := dsig.MemoryX509CertificateStore{ | |||||
Roots: []*x509.Certificate{}, | |||||
} | |||||
if metadata.IDPSSODescriptor == nil { | |||||
return errors.New("saml idp metadata missing IDPSSODescriptor") | |||||
} | |||||
for _, kd := range metadata.IDPSSODescriptor.KeyDescriptors { | |||||
for idx, xcert := range kd.KeyInfo.X509Data.X509Certificates { | |||||
if xcert.Data == "" { | |||||
return fmt.Errorf("metadata certificate(%d) must not be empty", idx) | |||||
} | |||||
certData, err := base64.StdEncoding.DecodeString(xcert.Data) | |||||
if err != nil { | |||||
return err | |||||
} | |||||
idpCert, err := x509.ParseCertificate(certData) | |||||
if err != nil { | |||||
return err | |||||
} | |||||
certStore.Roots = append(certStore.Roots, idpCert) | |||||
} | |||||
} | |||||
var keyStore dsig.X509KeyStore | |||||
if source.ServiceProviderCertificate != "" && source.ServiceProviderPrivateKey != "" { | |||||
keyPair, err := tls.X509KeyPair([]byte(source.ServiceProviderCertificate), []byte(source.ServiceProviderPrivateKey)) | |||||
if err != nil { | |||||
return err | |||||
} | |||||
keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) | |||||
if err != nil { | |||||
return err | |||||
} | |||||
keyStore = dsig.TLSCertKeyStore(keyPair) | |||||
} | |||||
source.samlSP = &saml2.SAMLServiceProvider{ | |||||
IdentityProviderSSOURL: metadata.IDPSSODescriptor.SingleSignOnServices[0].Location, | |||||
IdentityProviderIssuer: metadata.EntityID, | |||||
AudienceURI: setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/metadata", | |||||
AssertionConsumerServiceURL: source.CallbackURL, | |||||
SkipSignatureValidation: source.InsecureSkipAssertionSignatureValidation, | |||||
NameIdFormat: source.NameIDFormat.String(), | |||||
IDPCertificateStore: &certStore, | |||||
SignAuthnRequests: source.ServiceProviderCertificate != "" && source.ServiceProviderPrivateKey != "", | |||||
SPKeyStore: keyStore, | |||||
ServiceProviderIssuer: setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/metadata", | |||||
} | |||||
return nil | |||||
} | |||||
// FromDB fills up a SAML from serialized format. | |||||
func (source *Source) FromDB(bs []byte) error { | |||||
if err := json.UnmarshalHandleDoubleEncode(bs, &source); err != nil { | |||||
return err | |||||
} | |||||
return source.initSAMLSp() | |||||
} | |||||
// ToDB exports a SAML to a serialized format. | |||||
func (source *Source) ToDB() ([]byte, error) { | |||||
return json.Marshal(source) | |||||
} | |||||
// SetAuthSource sets the related AuthSource | |||||
func (source *Source) SetAuthSource(authSource *auth.Source) { | |||||
source.authSource = authSource | |||||
} | |||||
func init() { | |||||
auth.RegisterTypeConfig(auth.SAML, &Source{}) | |||||
} |
// Copyright 2023 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package saml | |||||
import ( | |||||
"context" | |||||
user_model "code.gitea.io/gitea/models/user" | |||||
"code.gitea.io/gitea/services/auth/source/db" | |||||
) | |||||
// Authenticate falls back to the db authenticator | |||||
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) { | |||||
return db.Authenticate(ctx, user, login, password) | |||||
} |
// Copyright 2023 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package saml | |||||
import ( | |||||
"fmt" | |||||
"net/http" | |||||
"strings" | |||||
"github.com/markbates/goth" | |||||
) | |||||
// Callout redirects request/response pair to authenticate against the provider | |||||
func (source *Source) Callout(request *http.Request, response http.ResponseWriter) error { | |||||
samlRWMutex.RLock() | |||||
defer samlRWMutex.RUnlock() | |||||
if _, ok := providers[source.authSource.Name]; !ok { | |||||
return fmt.Errorf("no provider for this saml") | |||||
} | |||||
authURL, err := providers[source.authSource.Name].samlSP.BuildAuthURL("") | |||||
if err == nil { | |||||
http.Redirect(response, request, authURL, http.StatusTemporaryRedirect) | |||||
} | |||||
return err | |||||
} | |||||
// Callback handles SAML callback, resolve to a goth user and send back to original url | |||||
// this will trigger a new authentication request, but because we save it in the session we can use that | |||||
func (source *Source) Callback(request *http.Request, response http.ResponseWriter) (goth.User, error) { | |||||
samlRWMutex.RLock() | |||||
defer samlRWMutex.RUnlock() | |||||
user := goth.User{ | |||||
Provider: source.authSource.Name, | |||||
} | |||||
samlResponse := request.FormValue("SAMLResponse") | |||||
assertions, err := source.samlSP.RetrieveAssertionInfo(samlResponse) | |||||
if err != nil { | |||||
return user, err | |||||
} | |||||
if assertions.WarningInfo.OneTimeUse { | |||||
return user, fmt.Errorf("SAML response contains one time use warning") | |||||
} | |||||
if assertions.WarningInfo.ProxyRestriction != nil { | |||||
return user, fmt.Errorf("SAML response contains proxy restriction warning: %v", assertions.WarningInfo.ProxyRestriction) | |||||
} | |||||
if assertions.WarningInfo.NotInAudience { | |||||
return user, fmt.Errorf("SAML response contains audience warning") | |||||
} | |||||
if assertions.WarningInfo.InvalidTime { | |||||
return user, fmt.Errorf("SAML response contains invalid time warning") | |||||
} | |||||
samlMap := make(map[string]string) | |||||
for key, value := range assertions.Values { | |||||
keyParsed := strings.ToLower(key[strings.LastIndex(key, "/")+1:]) // Uses the trailing slug as the key name. | |||||
valueParsed := value.Values[0].Value | |||||
samlMap[keyParsed] = valueParsed | |||||
} | |||||
user.UserID = assertions.NameID | |||||
if user.UserID == "" { | |||||
return user, fmt.Errorf("no nameID found in SAML response") | |||||
} | |||||
if _, ok := samlMap[source.EmailAssertionKey]; !ok { | |||||
user.Email = samlMap[source.EmailAssertionKey] | |||||
} | |||||
// name | |||||
if _, ok := samlMap[source.NameAssertionKey]; !ok { | |||||
user.NickName = samlMap[source.NameAssertionKey] | |||||
} | |||||
// username | |||||
if _, ok := samlMap[source.UsernameAssertionKey]; !ok { | |||||
user.Name = samlMap[source.UsernameAssertionKey] | |||||
} | |||||
// TODO: utilize groups once mapping is supported | |||||
return user, nil | |||||
} |
// Copyright 2023 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package saml | |||||
import ( | |||||
"encoding/xml" | |||||
"fmt" | |||||
"net/http" | |||||
) | |||||
// Metadata redirects request/response pair to authenticate against the provider | |||||
func (source *Source) Metadata(request *http.Request, response http.ResponseWriter) error { | |||||
samlRWMutex.RLock() | |||||
defer samlRWMutex.RUnlock() | |||||
if _, ok := providers[source.authSource.Name]; !ok { | |||||
return fmt.Errorf("provider does not exist") | |||||
} | |||||
metadata, err := providers[source.authSource.Name].samlSP.Metadata() | |||||
if err != nil { | |||||
return err | |||||
} | |||||
buf, err := xml.Marshal(metadata) | |||||
if err != nil { | |||||
return err | |||||
} | |||||
response.Header().Set("Content-Type", "application/samlmetadata+xml; charset=utf-8") | |||||
_, _ = response.Write(buf) | |||||
return nil | |||||
} |
// Copyright 2023 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package saml | |||||
// RegisterSource causes an OAuth2 configuration to be registered | |||||
func (source *Source) RegisterSource() error { | |||||
samlRWMutex.Lock() | |||||
defer samlRWMutex.Unlock() | |||||
if err := source.initSAMLSp(); err != nil { | |||||
return err | |||||
} | |||||
providers[source.authSource.Name] = *source | |||||
return nil | |||||
} | |||||
// UnregisterSource causes an SAML configuration to be unregistered | |||||
func (source *Source) UnregisterSource() error { | |||||
samlRWMutex.Lock() | |||||
defer samlRWMutex.Unlock() | |||||
delete(providers, source.authSource.Name) | |||||
return nil | |||||
} |
"context" | "context" | ||||
"fmt" | "fmt" | ||||
"code.gitea.io/gitea/models/auth" | |||||
user_model "code.gitea.io/gitea/models/user" | user_model "code.gitea.io/gitea/models/user" | ||||
"github.com/markbates/goth" | |||||
) | ) | ||||
// Store represents a thing that stores things | // Store represents a thing that stores things | ||||
// LinkAccountFromStore links the provided user with a stored external user | // LinkAccountFromStore links the provided user with a stored external user | ||||
func LinkAccountFromStore(ctx context.Context, store Store, user *user_model.User) error { | func LinkAccountFromStore(ctx context.Context, store Store, user *user_model.User) error { | ||||
externalLinkUserInterface := store.Get("linkAccountUser") | |||||
if externalLinkUserInterface == nil { | |||||
gothUser := store.Get("linkAccountGothUser") | |||||
if gothUser == nil { | |||||
return fmt.Errorf("not in LinkAccount session") | return fmt.Errorf("not in LinkAccount session") | ||||
} | } | ||||
externalLinkUser := externalLinkUserInterface.(auth.LinkAccountUser) | |||||
return LinkAccountToUser(ctx, user, externalLinkUser.GothUser, externalLinkUser.Type) | |||||
return LinkAccountToUser(ctx, user, gothUser.(goth.User)) | |||||
} | } |
"github.com/markbates/goth" | "github.com/markbates/goth" | ||||
) | ) | ||||
func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser goth.User, authType auth.Type) (*user_model.ExternalLoginUser, error) { | |||||
authSource, err := auth.GetActiveAuthSourceByName(ctx, gothUser.Provider, authType) | |||||
func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser goth.User) (*user_model.ExternalLoginUser, error) { | |||||
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider) | |||||
if err != nil { | if err != nil { | ||||
return nil, err | return nil, err | ||||
} | } | ||||
} | } | ||||
// LinkAccountToUser link the gothUser to the user | // LinkAccountToUser link the gothUser to the user | ||||
func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth.User, authType auth.Type) error { | |||||
externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser, authType) | |||||
func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { | |||||
externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser) | |||||
if err != nil { | if err != nil { | ||||
return err | return err | ||||
} | } | ||||
} | } | ||||
// UpdateExternalUser updates external user's information | // UpdateExternalUser updates external user's information | ||||
func UpdateExternalUser(ctx context.Context, user *user_model.User, gothUser goth.User, authType auth.Type) error { | |||||
externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser, authType) | |||||
func UpdateExternalUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { | |||||
externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser) | |||||
if err != nil { | if err != nil { | ||||
return err | return err | ||||
} | } |
// Copyright 2023 The Gitea Authors. All rights reserved. | |||||
// Copyright 2014 The Gogs Authors. All rights reserved. | // Copyright 2014 The Gogs Authors. All rights reserved. | ||||
// SPDX-License-Identifier: MIT | // SPDX-License-Identifier: MIT | ||||
// AuthenticationForm form for authentication | // AuthenticationForm form for authentication | ||||
type AuthenticationForm struct { | type AuthenticationForm struct { | ||||
ID int64 | ID int64 | ||||
Type int `binding:"Range(2,9)"` | |||||
Type int `binding:"Range(2,7)"` | |||||
Name string `binding:"Required;MaxSize(30)"` | Name string `binding:"Required;MaxSize(30)"` | ||||
Host string | Host string | ||||
Port int | Port int | ||||
SSPIDefaultLanguage string | SSPIDefaultLanguage string | ||||
GroupTeamMap string `binding:"ValidGroupTeamMap"` | GroupTeamMap string `binding:"ValidGroupTeamMap"` | ||||
GroupTeamMapRemoval bool | GroupTeamMapRemoval bool | ||||
// SAML Settings | |||||
NameIDFormat int | |||||
IdentityProviderMetadata string | |||||
IdentityProviderMetadataURL string | |||||
InsecureSkipAssertionSignatureValidation bool | |||||
ServiceProviderCertificate string | |||||
ServiceProviderPrivateKey string | |||||
EmailAssertionKey string | |||||
NameAssertionKey string | |||||
UsernameAssertionKey string | |||||
SAMLIconURL string | |||||
} | } | ||||
// Validate validates fields | // Validate validates fields |
</div> | </div> | ||||
{{end}} | {{end}} | ||||
<!-- SAML --> | |||||
{{if .Source.IsSAML}} | |||||
{{$cfg:=.Source.Cfg}} | |||||
<div class="inline required field"> | |||||
<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="{{$cfg.NameIDFormat}}"> | |||||
<div class="text">{{.CurrentNameIDFormat}}</div> | |||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}} | |||||
<div class="menu"> | |||||
{{range .NameIDFormats}} | |||||
<div class="item" data-value="{{.Type.Int}}">{{.Name}}</div> | |||||
{{end}} | |||||
</div> | |||||
</div> | |||||
</div> | |||||
<div class="optional field"> | |||||
<label for="saml_icon_url">{{ctx.Locale.Tr "admin.auths.saml_icon_url"}}</label> | |||||
<input id="saml_icon_url" name="saml_icon_url" value="{{$cfg.IconURL}}"> | |||||
</div> | |||||
<div class="field"> | |||||
<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="{{$cfg.IdentityProviderMetadataURL}}"> | |||||
</div> | |||||
<div class="field"> | |||||
<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">{{$cfg.IdentityProviderMetadata}}</textarea> | |||||
</div> | |||||
<div class="inline field"> | |||||
<div class="ui checkbox"> | |||||
<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 $cfg.InsecureSkipAssertionSignatureValidation}}checked{{end}}> | |||||
</div> | |||||
</div> | |||||
<div class=" field"> | |||||
<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">{{$cfg.ServiceProviderCertificate}}</textarea> | |||||
</div> | |||||
<div class=" field"> | |||||
<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">{{$cfg.ServiceProviderPrivateKey}}</textarea> | |||||
</div> | |||||
<div class="field"> | |||||
<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 $cfg.EmailAssertionKey}}http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress{{else}}{{$cfg.EmailAssertionKey}}{{end}}"> | |||||
</div> | |||||
<div class="field"> | |||||
<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 $cfg.NameAssertionKey}}http://schemas.xmlsoap.org/claims/CommonName{{else}}{{$cfg.NameAssertionKey}}{{end}}"> | |||||
</div> | |||||
<div class="field"> | |||||
<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 $cfg.UsernameAssertionKey}}http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name{{else}}{{$cfg.UsernameAssertionKey}}{{end}}"> | |||||
</div> | |||||
{{end}} | |||||
<!-- SSPI --> | <!-- SSPI --> | ||||
{{if .Source.IsSSPI}} | {{if .Source.IsSSPI}} | ||||
{{$cfg:=.Source.Cfg}} | {{$cfg:=.Source.Cfg}} | ||||
<h5>GMail Settings:</h5> | <h5>GMail Settings:</h5> | ||||
<p>Host: smtp.gmail.com, Port: 587, Enable TLS Encryption: true</p> | <p>Host: smtp.gmail.com, Port: 587, Enable TLS Encryption: true</p> | ||||
<h5>SAML Settings:</h5> | |||||
<p>{{ctx.Locale.Tr "admin.auths.tips.saml"}}</p> | |||||
<h5 class="oauth2">{{ctx.Locale.Tr "admin.auths.tips.oauth2.general"}}:</h5> | <h5 class="oauth2">{{ctx.Locale.Tr "admin.auths.tips.oauth2.general"}}:</h5> | ||||
<p class="oauth2">{{ctx.Locale.Tr "admin.auths.tips.oauth2.general.tip"}} <b id="oauth2-callback-url"></b></p> | <p class="oauth2">{{ctx.Locale.Tr "admin.auths.tips.oauth2.general.tip"}} <b id="oauth2-callback-url"></b></p> | ||||
</div> | </div> |
<!-- SSPI --> | <!-- SSPI --> | ||||
{{template "admin/auth/source/sspi" .}} | {{template "admin/auth/source/sspi" .}} | ||||
<!-- SAML --> | |||||
{{template "admin/auth/source/saml" .}} | |||||
<div class="ldap field"> | <div class="ldap field"> | ||||
<div class="ui checkbox"> | <div class="ui checkbox"> | ||||
<label><strong>{{ctx.Locale.Tr "admin.auths.attributes_in_bind"}}</strong></label> | <label><strong>{{ctx.Locale.Tr "admin.auths.attributes_in_bind"}}</strong></label> | ||||
<h5>GMail Settings:</h5> | <h5>GMail Settings:</h5> | ||||
<p>Host: smtp.gmail.com, Port: 587, Enable TLS Encryption: true</p> | <p>Host: smtp.gmail.com, Port: 587, Enable TLS Encryption: true</p> | ||||
<h5>SAML Settings:</h5> | |||||
<p>{{ctx.Locale.Tr "admin.auths.tips.saml"}}</p> | |||||
<h5 class="oauth2">{{ctx.Locale.Tr "admin.auths.tips.oauth2.general"}}:</h5> | <h5 class="oauth2">{{ctx.Locale.Tr "admin.auths.tips.oauth2.general"}}:</h5> | ||||
<p class="oauth2">{{ctx.Locale.Tr "admin.auths.tips.oauth2.general.tip"}} <b id="oauth2-callback-url"></b></p> | <p class="oauth2">{{ctx.Locale.Tr "admin.auths.tips.oauth2.general.tip"}} <b id="oauth2-callback-url"></b></p> | ||||
<div class="saml field {{if not (eq .type 8)}}gt-hidden{{end}}"> | |||||
<div class="inline required field"> | |||||
<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> | |||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}} | |||||
<div class="menu"> | |||||
{{range .NameIDFormats}} | |||||
<div class="item" data-value="{{.Type.Int}}">{{.Name}}</div> | |||||
{{end}} | |||||
</div> | |||||
</div> | |||||
</div> | |||||
<div class="optional field"> | |||||
<label for="saml_icon_url">{{ctx.Locale.Tr "admin.auths.saml_icon_url"}}</label> | |||||
<input id="saml_icon_url" name="saml_icon_url" value="{{.SAMLIconURL}}"> | |||||
</div> | |||||
<div class="field"> | |||||
<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">{{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>{{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">{{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">{{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="field"> | |||||
<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">{{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">{{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> | |||||
</div> |
</div> | </div> | ||||
</div> | </div> | ||||
{{end}} | {{end}} | ||||
{{if .SAMLProviders}} | |||||
<div class="divider divider-text"> | |||||
{{.locale.Tr "sign_in_or"}} | |||||
</div> | |||||
<div id="saml-login-navigator" class="gt-py-2"> | |||||
<div class="gt-df gt-fc gt-jc"> | |||||
<div id="saml-login-navigator-inner" class="gt-df gt-fc gt-fw gt-ac gt-gap-3"> | |||||
{{range $provider := .SAMLProviders}} | |||||
<a class="{{$provider.Name}} ui button gt-df gt-ac gt-jc gt-py-3 saml-login-link" href="{{AppSubUrl}}/user/saml/{{$provider.Name}}"> | |||||
{{.IconHTML 28}} | |||||
{{ctx.Locale.Tr "sign_in_with_provider" $provider.Name}} | |||||
</a> | |||||
{{end}} | |||||
</div> | |||||
</div> | |||||
</div> | |||||
{{end}} | |||||
</form> | </form> | ||||
</div> | </div> |
```bash | ```bash | ||||
GITEA_SLOW_TEST_TIME="10s" GITEA_SLOW_FLUSH_TIME="5s" make test-sqlite | 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 | |||||
``` |
// Copyright 2023 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package integration | |||||
import ( | |||||
"crypto/tls" | |||||
"crypto/x509" | |||||
"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 | |||||
} | |||||
} | |||||
privateKey, cert, err := saml.GenerateSAMLSPKeypair() | |||||
assert.NoError(t, err) | |||||
// verify that the keypair can be parsed | |||||
keyPair, err := tls.X509KeyPair([]byte(cert), []byte(privateKey)) | |||||
assert.NoError(t, err) | |||||
keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) | |||||
assert.NoError(t, err) | |||||
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: "", // SimpleSAMLPhp requires that the SP certificate be specified in the server configuration rather than SP metadata | |||||
ServiceProviderPrivateKey: "", | |||||
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", | |||||
IconURL: "", | |||||
}, | |||||
})) | |||||
// 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, | |||||
} | |||||
httpReq, 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(httpReq) | |||||
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"}, | |||||
} | |||||
httpReq, err = http.NewRequest("POST", formRedirectURL.String(), strings.NewReader(form.Encode())) | |||||
assert.NoError(t, err) | |||||
httpReq.Header.Add("Content-Type", "application/x-www-form-urlencoded") | |||||
res, err = client.Do(httpReq) | |||||
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) | |||||
} |
// New authentication | // New authentication | ||||
if ($('.admin.new.authentication').length > 0) { | if ($('.admin.new.authentication').length > 0) { | ||||
$('#auth_type').on('change', function () { | $('#auth_type').on('change', function () { | ||||
hideElem($('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi, .saml')); | |||||
hideElem($('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi')); | |||||
$('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required], .saml input[required]').removeAttr('required'); | |||||
$('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required]').removeAttr('required'); | |||||
$('.binddnrequired').removeClass('required'); | $('.binddnrequired').removeClass('required'); | ||||
const authType = $(this).val(); | const authType = $(this).val(); | ||||
showElem($('.sspi')); | showElem($('.sspi')); | ||||
$('.sspi div.required input').attr('required', 'required'); | $('.sspi div.required input').attr('required', 'required'); | ||||
break; | break; | ||||
case '8': // SAML | |||||
showElem($('.saml')); | |||||
$('.saml div.required input').attr('required', 'required'); | |||||
break; | |||||
} | } | ||||
if (authType === '2' || authType === '5') { | if (authType === '2' || authType === '5') { | ||||
onSecurityProtocolChange(); | onSecurityProtocolChange(); |
}); | }); | ||||
} | } | ||||
} | } | ||||
export function initUserAuthSAML() { | |||||
const outer = document.getElementById('saml-login-navigator'); | |||||
if (!outer) return; | |||||
const inner = document.getElementById('saml-login-navigator-inner'); | |||||
checkAppUrl(); | |||||
for (const link of outer.querySelectorAll('.saml-login-link')) { | |||||
link.addEventListener('click', () => { | |||||
inner.classList.add('gt-invisible'); | |||||
outer.classList.add('is-loading'); | |||||
setTimeout(() => { | |||||
// recover previous content to let user try again | |||||
// usually redirection will be performed before this action | |||||
outer.classList.remove('is-loading'); | |||||
inner.classList.remove('gt-invisible'); | |||||
}, 5000); | |||||
}); | |||||
} | |||||
} |
import {initCommentContent, initMarkupContent} from './markup/content.js'; | import {initCommentContent, initMarkupContent} from './markup/content.js'; | ||||
import {initPdfViewer} from './render/pdf.js'; | import {initPdfViewer} from './render/pdf.js'; | ||||
import { | |||||
initUserAuthOauth2, | |||||
initUserAuthSAML | |||||
} from './features/user-auth.js'; | |||||
import {initUserAuthOauth2} from './features/user-auth.js'; | |||||
import { | import { | ||||
initRepoIssueDue, | initRepoIssueDue, | ||||
initRepoIssueReferenceRepositorySearch, | initRepoIssueReferenceRepositorySearch, | ||||
initCaptcha(); | initCaptcha(); | ||||
initUserAuthOauth2(); | initUserAuthOauth2(); | ||||
initUserAuthSAML(); | |||||
initUserAuthWebAuthn(); | initUserAuthWebAuthn(); | ||||
initUserAuthWebAuthnRegister(); | initUserAuthWebAuthnRegister(); | ||||
initUserSettings(); | initUserSettings(); |