mirror of https://github.com/go-gitea/gitea.git
Support SAML authentication (#25165)
Closes https://github.com/go-gitea/gitea/issues/5512 This PR adds basic SAML support - Adds SAML 2.0 as an auth source - Adds SAML configuration documentation - Adds integration test: - Use bare-bones SAML IdP to test protocol flow and test account is linked successfully (only runs on Postgres by default) - Adds documentation for configuring and running SAML integration test locally Future PRs: - Support group mapping - Support auto-registration (account linking) Co-Authored-By: @jackHay22 --------- Co-authored-by: jackHay22 <jack@allspice.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: KN4CK3R <admin@oldschoolhack.me> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Jason Song <i@wolfogre.com> Co-authored-by: morphelinho <morphelinho@users.noreply.github.com> Co-authored-by: Zettat123 <zettat123@gmail.com> Co-authored-by: Yarden Shoham <git@yardenshoham.com> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
parent
c4b0cb4d0d
commit
5bb8d1924d
|
@ -37,6 +37,14 @@ jobs:
|
||||||
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
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -349,3 +349,72 @@ If set `ENABLE_REVERSE_PROXY_FULL_NAME=true`, a user full name expected in `X-WE
|
||||||
You can also limit the reverse proxy's IP address range with `REVERSE_PROXY_TRUSTED_PROXIES` which default value is `127.0.0.0/8,::1/128`. By `REVERSE_PROXY_LIMIT`, you can limit trusted proxies level.
|
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`.
|
||||||
|
|
5
go.mod
5
go.mod
|
@ -91,6 +91,8 @@ require (
|
||||||
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
|
||||||
|
@ -143,6 +145,7 @@ require (
|
||||||
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
|
||||||
|
@ -216,6 +219,7 @@ require (
|
||||||
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
|
||||||
|
@ -225,6 +229,7 @@ require (
|
||||||
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
|
||||||
|
|
12
go.sum
12
go.sum
|
@ -130,6 +130,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
||||||
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=
|
||||||
|
@ -566,6 +568,9 @@ github.com/jhillyerd/enmime v1.1.0 h1:ubaIzg68VY7CMCe2YbHe6nkRvU9vujixTkNz3EBvZO
|
||||||
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=
|
||||||
|
@ -634,6 +639,8 @@ github.com/markbates/going v1.0.3 h1:mY45T5TvW+Xz5A6jY7lf4+NLg9D8+iuStIHyR7M8qsE
|
||||||
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=
|
||||||
|
@ -766,12 +773,17 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
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=
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base32"
|
"encoding/base32"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/gob"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
@ -81,6 +82,10 @@ func Init(ctx context.Context) error {
|
||||||
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
|
||||||
|
@ -605,21 +610,6 @@ func (err ErrOAuthApplicationNotFound) Unwrap() error {
|
||||||
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})
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"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"
|
||||||
|
@ -32,6 +33,7 @@ const (
|
||||||
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
|
||||||
|
@ -52,6 +54,7 @@ var Names = map[Type]string{
|
||||||
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
|
||||||
|
@ -121,6 +124,12 @@ type Source struct {
|
||||||
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"
|
||||||
|
@ -180,6 +189,11 @@ func (source *Source) IsSSPI() bool {
|
||||||
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)
|
||||||
|
@ -392,3 +406,27 @@ func IsErrSourceInUse(err error) bool {
|
||||||
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
|
||||||
|
}
|
||||||
|
|
|
@ -522,6 +522,9 @@ Content = Content
|
||||||
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.`
|
||||||
|
@ -3026,7 +3029,18 @@ auths.sspi_separator_replacement = Separator to use instead of \, / and @
|
||||||
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
|
||||||
|
|
|
@ -35,6 +35,7 @@ import (
|
||||||
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"
|
||||||
|
@ -138,6 +139,7 @@ func InitWebInstalled(ctx context.Context) {
|
||||||
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)
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
// 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"
|
||||||
|
@ -25,6 +28,7 @@ import (
|
||||||
"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"
|
||||||
|
@ -71,6 +75,7 @@ var (
|
||||||
{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})
|
||||||
|
@ -83,6 +88,16 @@ var (
|
||||||
{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
|
||||||
|
@ -98,6 +113,8 @@ func NewAuthSource(ctx *context.Context) {
|
||||||
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
|
||||||
|
@ -231,6 +248,52 @@ func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*sspi
|
||||||
}, 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)
|
||||||
|
@ -244,6 +307,8 @@ func NewAuthSourcePost(ctx *context.Context) {
|
||||||
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
|
||||||
|
@ -290,6 +355,13 @@ func NewAuthSourcePost(ctx *context.Context) {
|
||||||
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
|
||||||
|
@ -336,6 +408,7 @@ func EditAuthSource(ctx *context.Context) {
|
||||||
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 {
|
||||||
|
@ -344,6 +417,9 @@ func EditAuthSource(ctx *context.Context) {
|
||||||
}
|
}
|
||||||
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 {
|
||||||
|
@ -378,6 +454,8 @@ func EditAuthSourcePost(ctx *context.Context) {
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
@ -412,6 +490,12 @@ func EditAuthSourcePost(ctx *context.Context) {
|
||||||
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
|
||||||
|
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"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"
|
||||||
|
@ -170,6 +171,14 @@ func SignIn(ctx *context.Context) {
|
||||||
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
|
||||||
|
@ -193,6 +202,14 @@ func SignInPost(ctx *context.Context) {
|
||||||
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
|
||||||
|
@ -504,7 +521,7 @@ func SignUpPost(ctx *context.Context) {
|
||||||
Passwd: form.Password,
|
Passwd: form.Password,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil, false) {
|
if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil, false, auth.NoType) {
|
||||||
// error already handled
|
// error already handled
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -515,16 +532,16 @@ func SignUpPost(ctx *context.Context) {
|
||||||
|
|
||||||
// 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) bool {
|
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) {
|
if !createUserInContext(ctx, tpl, form, u, overwrites, gothUser, allowLink, authType) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return handleUserCreated(ctx, u, gothUser)
|
return handleUserCreated(ctx, u, gothUser, authType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) (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, authType auth.Type) (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 {
|
||||||
|
@ -541,10 +558,10 @@ func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *us
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: probably we should respect 'remember' user's choice...
|
// TODO: probably we should respect 'remember' user's choice...
|
||||||
linkAccount(ctx, user, *gothUser, true)
|
linkAccount(ctx, user, *gothUser, true, authType)
|
||||||
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)
|
showLinkingLogin(ctx, *gothUser, authType)
|
||||||
return false // user will be created only after linking login
|
return false // user will be created only after linking login
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -590,7 +607,7 @@ func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *us
|
||||||
// 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) (ok bool) {
|
func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.User, authType auth.Type) (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{
|
||||||
|
@ -606,7 +623,7 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.
|
||||||
|
|
||||||
// update external user information
|
// update external user information
|
||||||
if gothUser != nil {
|
if gothUser != nil {
|
||||||
if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser); err != nil {
|
if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser, authType); 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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,13 +48,13 @@ func LinkAccount(ctx *context.Context) {
|
||||||
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"
|
||||||
|
|
||||||
gothUser := ctx.Session.Get("linkAccountGothUser")
|
externalLinkUser := ctx.Session.Get("linkAccountUser")
|
||||||
if gothUser == nil {
|
if externalLinkUser == nil {
|
||||||
ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
|
ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gu, _ := gothUser.(goth.User)
|
gu := externalLinkUser.(auth.LinkAccountUser).GothUser
|
||||||
uname, err := getUserName(&gu)
|
uname, err := getUserName(&gu)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("UserSignIn", err)
|
ctx.ServerError("UserSignIn", err)
|
||||||
|
@ -135,12 +135,14 @@ func LinkAccountPostSignIn(ctx *context.Context) {
|
||||||
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"
|
||||||
|
|
||||||
gothUser := ctx.Session.Get("linkAccountGothUser")
|
externalLinkUserInterface := ctx.Session.Get("linkAccountUser")
|
||||||
if gothUser == nil {
|
if externalLinkUserInterface == 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
|
||||||
|
@ -152,10 +154,10 @@ func LinkAccountPostSignIn(ctx *context.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
linkAccount(ctx, u, gothUser.(goth.User), signInForm.Remember)
|
linkAccount(ctx, u, externalLinkUser.GothUser, signInForm.Remember, externalLinkUser.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool) {
|
func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool, authType auth.Type) {
|
||||||
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.
|
||||||
|
@ -168,7 +170,7 @@ func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, r
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = externalaccount.LinkAccountToUser(ctx, u, gothUser)
|
err = externalaccount.LinkAccountToUser(ctx, u, gothUser, authType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("UserLinkAccount", err)
|
ctx.ServerError("UserLinkAccount", err)
|
||||||
return
|
return
|
||||||
|
@ -222,14 +224,14 @@ func LinkAccountPostRegister(ctx *context.Context) {
|
||||||
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"
|
||||||
|
|
||||||
gothUserInterface := ctx.Session.Get("linkAccountGothUser")
|
externalLinkUser := ctx.Session.Get("linkAccountUser")
|
||||||
if gothUserInterface == nil {
|
if externalLinkUser == nil {
|
||||||
ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session"))
|
ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
gothUser, ok := gothUserInterface.(goth.User)
|
linkUser, ok := externalLinkUser.(auth.LinkAccountUser)
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountGothUser type is %t but not goth.User", gothUserInterface))
|
ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountUser type is %t but not goth.User", externalLinkUser))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -275,7 +277,7 @@ func LinkAccountPostRegister(ctx *context.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider)
|
authSource, err := auth.GetActiveAuthSourceByName(ctx, linkUser.GothUser.Provider, linkUser.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("CreateUser", err)
|
ctx.ServerError("CreateUser", err)
|
||||||
return
|
return
|
||||||
|
@ -285,21 +287,24 @@ func LinkAccountPostRegister(ctx *context.Context) {
|
||||||
Name: form.UserName,
|
Name: form.UserName,
|
||||||
Email: form.Email,
|
Email: form.Email,
|
||||||
Passwd: form.Password,
|
Passwd: form.Password,
|
||||||
LoginType: auth.OAuth2,
|
LoginType: authSource.Type,
|
||||||
LoginSource: authSource.ID,
|
LoginSource: authSource.ID,
|
||||||
LoginName: gothUser.UserID,
|
LoginName: linkUser.GothUser.UserID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &gothUser, false) {
|
if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &linkUser.GothUser, false, linkUser.Type) {
|
||||||
// error already handled
|
// error already handled
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if linkUser.Type == auth.OAuth2 {
|
||||||
source := authSource.Cfg.(*oauth2.Source)
|
source := authSource.Cfg.(*oauth2.Source)
|
||||||
if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil {
|
if err := syncGroupsToTeams(ctx, source, &linkUser.GothUser, u); err != nil {
|
||||||
ctx.ServerError("SyncGroupsToTeams", err)
|
ctx.ServerError("SyncGroupsToTeams", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// TODO we will support some form of group mapping for SAML
|
||||||
|
|
||||||
handleSignIn(ctx, u, false)
|
handleSignIn(ctx, u, false)
|
||||||
}
|
}
|
||||||
|
|
|
@ -841,7 +841,7 @@ func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirect
|
||||||
func SignInOAuth(ctx *context.Context) {
|
func SignInOAuth(ctx *context.Context) {
|
||||||
provider := ctx.Params(":provider")
|
provider := ctx.Params(":provider")
|
||||||
|
|
||||||
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
|
authSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.OAuth2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("SignIn", err)
|
ctx.ServerError("SignIn", err)
|
||||||
return
|
return
|
||||||
|
@ -892,7 +892,7 @@ func SignInOAuthCallback(ctx *context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// first look if the provider is still active
|
// first look if the provider is still active
|
||||||
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
|
authSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.OAuth2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("SignIn", err)
|
ctx.ServerError("SignIn", err)
|
||||||
return
|
return
|
||||||
|
@ -935,7 +935,7 @@ func SignInOAuthCallback(ctx *context.Context) {
|
||||||
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)
|
err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser, auth.OAuth2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("UserLinkAccount", err)
|
ctx.ServerError("UserLinkAccount", err)
|
||||||
return
|
return
|
||||||
|
@ -988,7 +988,7 @@ func SignInOAuthCallback(ctx *context.Context) {
|
||||||
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) {
|
if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled, auth.OAuth2) {
|
||||||
// error already handled
|
// error already handled
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -999,7 +999,7 @@ func SignInOAuthCallback(ctx *context.Context) {
|
||||||
}
|
}
|
||||||
} 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)
|
showLinkingLogin(ctx, gothUser, auth.OAuth2)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1063,9 +1063,12 @@ func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *g
|
||||||
return isAdmin, isRestricted
|
return isAdmin, isRestricted
|
||||||
}
|
}
|
||||||
|
|
||||||
func showLinkingLogin(ctx *context.Context, gothUser goth.User) {
|
func showLinkingLogin(ctx *context.Context, gothUser goth.User, authType auth.Type) {
|
||||||
if err := updateSession(ctx, nil, map[string]any{
|
if err := updateSession(ctx, nil, map[string]any{
|
||||||
"linkAccountGothUser": gothUser,
|
"linkAccountUser": auth.LinkAccountUser{
|
||||||
|
Type: authType,
|
||||||
|
GothUser: gothUser,
|
||||||
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
ctx.ServerError("updateSession", err)
|
ctx.ServerError("updateSession", err)
|
||||||
return
|
return
|
||||||
|
@ -1144,7 +1147,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
|
||||||
}
|
}
|
||||||
|
|
||||||
// update external user information
|
// update external user information
|
||||||
if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil {
|
if err := externalaccount.UpdateExternalUser(ctx, u, gothUser, auth.OAuth2); 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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"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"
|
||||||
|
@ -363,7 +364,7 @@ func RegisterOpenIDPost(ctx *context.Context) {
|
||||||
Email: form.Email,
|
Email: form.Email,
|
||||||
Passwd: password,
|
Passwd: password,
|
||||||
}
|
}
|
||||||
if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil, false) {
|
if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil, false, auth_model.NoType) {
|
||||||
// error already handled
|
// error already handled
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -379,7 +380,7 @@ func RegisterOpenIDPost(ctx *context.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !handleUserCreated(ctx, u, nil) {
|
if !handleUserCreated(ctx, u, nil, auth_model.NoType) {
|
||||||
// error already handled
|
// error already handled
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,172 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -667,6 +667,11 @@ func registerRoutes(m *web.Route) {
|
||||||
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 *****
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
// 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{}
|
|
@ -0,0 +1,29 @@
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
// 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)
|
||||||
|
}
|
|
@ -0,0 +1,109 @@
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -0,0 +1,202 @@
|
||||||
|
// 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{})
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
// 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)
|
||||||
|
}
|
|
@ -0,0 +1,89 @@
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// email
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -7,9 +7,8 @@ import (
|
||||||
"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
|
||||||
|
@ -21,10 +20,12 @@ type Store interface {
|
||||||
|
|
||||||
// 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 {
|
||||||
gothUser := store.Get("linkAccountGothUser")
|
externalLinkUserInterface := store.Get("linkAccountUser")
|
||||||
if gothUser == nil {
|
if externalLinkUserInterface == nil {
|
||||||
return fmt.Errorf("not in LinkAccount session")
|
return fmt.Errorf("not in LinkAccount session")
|
||||||
}
|
}
|
||||||
|
|
||||||
return LinkAccountToUser(ctx, user, gothUser.(goth.User))
|
externalLinkUser := externalLinkUserInterface.(auth.LinkAccountUser)
|
||||||
|
|
||||||
|
return LinkAccountToUser(ctx, user, externalLinkUser.GothUser, externalLinkUser.Type)
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,8 +16,8 @@ import (
|
||||||
"github.com/markbates/goth"
|
"github.com/markbates/goth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser goth.User) (*user_model.ExternalLoginUser, error) {
|
func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser goth.User, authType auth.Type) (*user_model.ExternalLoginUser, error) {
|
||||||
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider)
|
authSource, err := auth.GetActiveAuthSourceByName(ctx, gothUser.Provider, authType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -43,8 +43,8 @@ func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser go
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) error {
|
func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth.User, authType auth.Type) error {
|
||||||
externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser)
|
externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser, authType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -71,8 +71,8 @@ func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateExternalUser updates external user's information
|
// UpdateExternalUser updates external user's information
|
||||||
func UpdateExternalUser(ctx context.Context, user *user_model.User, gothUser goth.User) error {
|
func UpdateExternalUser(ctx context.Context, user *user_model.User, gothUser goth.User, authType auth.Type) error {
|
||||||
externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser)
|
externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser, authType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
// 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
|
||||||
|
|
||||||
|
@ -15,7 +16,7 @@ import (
|
||||||
// AuthenticationForm form for authentication
|
// AuthenticationForm form for authentication
|
||||||
type AuthenticationForm struct {
|
type AuthenticationForm struct {
|
||||||
ID int64
|
ID int64
|
||||||
Type int `binding:"Range(2,7)"`
|
Type int `binding:"Range(2,9)"`
|
||||||
Name string `binding:"Required;MaxSize(30)"`
|
Name string `binding:"Required;MaxSize(30)"`
|
||||||
Host string
|
Host string
|
||||||
Port int
|
Port int
|
||||||
|
@ -82,6 +83,18 @@ type AuthenticationForm struct {
|
||||||
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
|
||||||
|
|
|
@ -367,6 +367,69 @@
|
||||||
</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}}
|
||||||
|
@ -441,6 +504,9 @@
|
||||||
<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>
|
||||||
|
|
|
@ -53,6 +53,9 @@
|
||||||
<!-- 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>
|
||||||
|
@ -85,6 +88,9 @@
|
||||||
<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>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
<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>
|
|
@ -69,5 +69,22 @@
|
||||||
</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>
|
||||||
|
|
|
@ -110,3 +110,20 @@ SLOW_FLUSH = 5S ; 5s is the default value
|
||||||
```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
|
||||||
|
```
|
||||||
|
|
|
@ -0,0 +1,150 @@
|
||||||
|
// 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)
|
||||||
|
}
|
|
@ -103,9 +103,9 @@ export function initAdminCommon() {
|
||||||
// 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'));
|
hideElem($('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi, .saml'));
|
||||||
|
|
||||||
$('.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');
|
$('.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');
|
||||||
$('.binddnrequired').removeClass('required');
|
$('.binddnrequired').removeClass('required');
|
||||||
|
|
||||||
const authType = $(this).val();
|
const authType = $(this).val();
|
||||||
|
@ -137,6 +137,10 @@ export function initAdminCommon() {
|
||||||
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();
|
||||||
|
|
|
@ -20,3 +20,24 @@ export function initUserAuthOauth2() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -23,7 +23,10 @@ import {initFindFileInRepo} from './features/repo-findfile.js';
|
||||||
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} from './features/user-auth.js';
|
import {
|
||||||
|
initUserAuthOauth2,
|
||||||
|
initUserAuthSAML
|
||||||
|
} from './features/user-auth.js';
|
||||||
import {
|
import {
|
||||||
initRepoIssueDue,
|
initRepoIssueDue,
|
||||||
initRepoIssueReferenceRepositorySearch,
|
initRepoIssueReferenceRepositorySearch,
|
||||||
|
@ -179,6 +182,7 @@ onDomReady(() => {
|
||||||
initCaptcha();
|
initCaptcha();
|
||||||
|
|
||||||
initUserAuthOauth2();
|
initUserAuthOauth2();
|
||||||
|
initUserAuthSAML();
|
||||||
initUserAuthWebAuthn();
|
initUserAuthWebAuthn();
|
||||||
initUserAuthWebAuthnRegister();
|
initUserAuthWebAuthnRegister();
|
||||||
initUserSettings();
|
initUserSettings();
|
||||||
|
|
Loading…
Reference in New Issue