mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-29 10:57:44 +09:00 
			
		
		
		
	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:
		
							
								
								
									
										8
									
								
								.github/workflows/pull-db-tests.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/pull-db-tests.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||||
|   | |||||||
							
								
								
									
										25
									
								
								assets/go-licenses.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										25
									
								
								assets/go-licenses.json
									
									
									
										generated
									
									
									
								
							
										
											
												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 | ||||||
| 	} | 	} | ||||||
|   | |||||||
							
								
								
									
										172
									
								
								routers/web/auth/saml.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								routers/web/auth/saml.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ***** | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								services/auth/source/saml/assert_interface_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								services/auth/source/saml/assert_interface_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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{} | ||||||
							
								
								
									
										29
									
								
								services/auth/source/saml/init.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								services/auth/source/saml/init.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | } | ||||||
							
								
								
									
										38
									
								
								services/auth/source/saml/name_id_format.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								services/auth/source/saml/name_id_format.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  | } | ||||||
							
								
								
									
										109
									
								
								services/auth/source/saml/providers.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								services/auth/source/saml/providers.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | } | ||||||
							
								
								
									
										202
									
								
								services/auth/source/saml/source.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								services/auth/source/saml/source.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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{}) | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								services/auth/source/saml/source_authenticate.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								services/auth/source/saml/source_authenticate.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  | } | ||||||
							
								
								
									
										89
									
								
								services/auth/source/saml/source_callout.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								services/auth/source/saml/source_callout.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | } | ||||||
							
								
								
									
										32
									
								
								services/auth/source/saml/source_metadata.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								services/auth/source/saml/source_metadata.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								services/auth/source/saml/source_register.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								services/auth/source/saml/source_register.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										62
									
								
								templates/admin/auth/source/saml.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								templates/admin/auth/source/saml.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | ``` | ||||||
|   | |||||||
							
								
								
									
										150
									
								
								tests/integration/saml_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								tests/integration/saml_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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(); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user