diff --git a/MANIFEST.in b/MANIFEST.in index f6edbec..9965876 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,10 @@ include *.in -include *.py include LICENSE include tox.ini include requirements.txt + +prune doc + +recursive-include matrix_synapse_saml_mozilla *.py +graft matrix_synapse_saml_mozilla/res recursive-include tests *.py diff --git a/README.md b/README.md index 0d86591..2b89eff 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# Synapse Mozilla SAML MXID Mapper +# Synapse Mozilla SAML MXID Mapper -Custom SAML auth response -> MXID mapping algorithm used during the Mozilla -Matrix trial run. +A Synapse plugin module which allows users to choose their username when they +first log in. ## Installation @@ -11,8 +11,6 @@ This plugin can be installed via [PyPi](https://pypi.org): pip install matrix-synapse-saml-mozilla ``` -## Usage - ### Config Add the following in your Synapse config: @@ -21,8 +19,22 @@ Add the following in your Synapse config: saml2_config: user_mapping_provider: module: "matrix_synapse_saml_mozilla.SamlMappingProvider" - config: - mxid_source_attribute: "uid" +``` + +Also, under the HTTP client `listener`, configure an `additional_resource` as per +the below: + +```yaml +listeners: + - port: <port> + type: http + + resources: + - names: [client] + + additional_resources: + "/_matrix/saml2/pick_username": + module: "matrix_synapse_saml_mozilla.pick_username_resource" ``` ### Configuration Options @@ -30,11 +42,13 @@ Add the following in your Synapse config: Synapse allows SAML mapping providers to specify custom configuration through the `saml2_config.user_mapping_provider.config` option. -The options supported by this provider are currently: +There are no options currently supported by this provider. + +## Implementation notes + +The login flow looks something like this: -* `mxid_source_attribute` - The SAML attribute (after mapping via the - attribute maps) to use to derive the Matrix - ID from. 'uid' by default. + ## Development and Testing @@ -42,7 +56,7 @@ This repository uses `tox` to run linting and tests. ### Linting -Code is linted with the `flake8` tool. Run `tox -e pep8` to check for linting +Code is linted with the `flake8` tool. Run `tox -e lint` to check for linting errors in the codebase. ### Tests diff --git a/doc/login_flow.svg b/doc/login_flow.svg new file mode 100644 index 0000000..cb2517f --- /dev/null +++ b/doc/login_flow.svg @@ -0,0 +1 @@ +<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="943" height="1377"><defs/><g><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g><rect fill="white" stroke="none" x="0" y="0" width="943" height="1377"/></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="16.5pt" font-style="normal" font-weight="normal" text-decoration="normal" x="355.76911651231967" y="24.50280495" text-anchor="start" dominant-baseline="alphabetic">Mozilla matrix login flow</text></g><g/><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 38.923050770539064 95.887643371 L 38.923050770539064 1377.8743983549996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="12.565541,5.445067766666667"/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 235.7823911040026 266.753869889 L 235.7823911040026 1217.1359978829996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="12.565541,5.445067766666667"/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 648.6303460099922 95.887643371 L 648.6303460099922 1377.8743983549996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="12.565541,5.445067766666667"/><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 880.8695691559558 95.887643371 L 880.8695691559558 1377.8743983549996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="12.565541,5.445067766666667"/></g><g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 8.167601650000002 51.782594461 L 69.67849989107813 51.782594461 L 69.67849989107813 95.887643371 L 8.167601650000002 95.887643371 L 8.167601650000002 51.782594461 Z" stroke-miterlimit="10" stroke-width="2.613632528" stroke-dasharray=""/></g><g><g/><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="25.8912972305" y="79.55244007099999" text-anchor="start" dominant-baseline="alphabetic">Riot</text></g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 602.3885077292969 51.945946494000005 L 694.8721842906875 51.945946494000005 L 694.8721842906875 95.887643371 L 602.3885077292969 95.887643371 L 602.3885077292969 51.945946494000005 Z" stroke-miterlimit="10" stroke-width="2.613632528" stroke-dasharray=""/></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="620.1122033097969" y="79.55244007099999" text-anchor="start" dominant-baseline="alphabetic">Synapse</text></g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 826.0794444372722 51.945946494000005 L 935.6596938746394 51.945946494000005 L 935.6596938746394 95.887643371 L 826.0794444372722 95.887643371 L 826.0794444372722 51.945946494000005 Z" stroke-miterlimit="10" stroke-width="2.613632528" stroke-dasharray=""/></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="843.8031400177722" y="79.55244007099999" text-anchor="start" dominant-baseline="alphabetic">SAML2 IdP</text></g></g><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 30.755449120539062 104.05524502099999 L 47.090652420539065 104.05524502099999 L 47.090652420539065 1361.5391950549995 L 30.755449120539062 1361.5391950549995 L 30.755449120539062 104.05524502099999" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 149.793814261 L 656.7979476599922 149.793814261 L 656.7979476599922 195.532383501 L 640.4627443599921 195.532383501 L 640.4627443599921 149.793814261" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 227.61478945400262 312.492439129 L 243.9499927540026 312.492439129 L 243.9499927540026 1217.1359978829996 L 227.61478945400262 1217.1359978829996 L 227.61478945400262 312.492439129" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 370.319058811 L 656.7979476599922 370.319058811 L 656.7979476599922 483.03196158099996 L 640.4627443599921 483.03196158099996 L 640.4627443599921 370.319058811" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 872.7019675059557 540.8585812629999 L 889.0371708059558 540.8585812629999 L 889.0371708059558 586.5971505029999 L 872.7019675059557 586.5971505029999 L 872.7019675059557 540.8585812629999" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 872.7019675059557 632.3357197429998 L 889.0371708059558 632.3357197429998 L 889.0371708059558 678.0742889829997 L 872.7019675059557 678.0742889829997 L 872.7019675059557 632.3357197429998" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 735.9009086649997 L 656.7979476599922 735.9009086649997 L 656.7979476599922 860.7018618769997 L 640.4627443599921 860.7018618769997 L 640.4627443599921 735.9009086649997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 906.4404311169997 L 656.7979476599922 906.4404311169997 L 656.7979476599922 952.1790003569996 L 640.4627443599921 952.1790003569996 L 640.4627443599921 906.4404311169997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 1010.0056200389996 L 656.7979476599922 1010.0056200389996 L 656.7979476599922 1067.8322397209995 L 640.4627443599921 1067.8322397209995 L 640.4627443599921 1010.0056200389996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 1125.6588594029995 L 656.7979476599922 1125.6588594029995 L 656.7979476599922 1171.3974286429996 L 640.4627443599921 1171.3974286429996 L 640.4627443599921 1125.6588594029995" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 1274.9626175649996 L 656.7979476599922 1274.9626175649996 L 656.7979476599922 1320.7011868049997 L 640.4627443599921 1320.7011868049997 L 640.4627443599921 1274.9626175649996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g><g><rect fill="white" stroke="none" x="297.20738408178903" y="128.558049971" width="93.13862861695313" height="21.23576429"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="299.65766457678905" y="143.259732941" text-anchor="start" dominant-baseline="alphabetic">GET /login</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 47.090652420539065 149.793814261 L 637.7402104766588 149.793814261" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,149.793814261) translate(-640.4627443599921,-149.793814261)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 142.98747955266666 L 640.4627443599921 149.793814261 L 626.8500749433255 156.60014896933333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="253.0883502683125" y="174.296619211" width="181.37669624390625" height="21.23576429"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="255.53863076331248" y="188.99830218099999" text-anchor="start" dominant-baseline="alphabetic">"type":"m.login.sso"</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 195.532383501 L 49.8131863038724 195.532383501" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(47.090652420539065,195.532383501) translate(-47.090652420539065,-195.532383501)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 60.703321837205735 188.72604879266666 L 47.090652420539065 195.532383501 L 60.703321837205735 202.33871820933334 Z"/></g></g><path fill="none" stroke="none"/><g><path fill="white" stroke="black" paint-order="fill stroke markers" d=" M 148.82079574322916 222.81217301200002 L 322.74398646477607 222.81217301200002 L 322.74398646477607 266.753869889 L 148.82079574322916 266.753869889 L 148.82079574322916 222.81217301200002 Z" stroke-miterlimit="10" stroke-width="2.613632528" stroke-dasharray=""/></g><g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="166.54449132372918" y="250.418666589" text-anchor="start" dominant-baseline="alphabetic">(Embedded) Browser</text></g><g><g><rect fill="white" stroke="none" x="134.90244044227086" y="291.25667483899997" width="4.90056099" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="137.35272093727085" y="305.95835780899995" text-anchor="start" dominant-baseline="alphabetic"></text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 47.090652420539065 312.492439129 L 224.89225557066928 312.492439129" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(227.61478945400262,312.492439129) translate(-227.61478945400262,-312.492439129)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 214.00212003733594 305.68610442066665 L 227.61478945400262 312.492439129 L 214.00212003733594 319.29877383733333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="338.2823102910013" y="336.995244079" width="207.8481165319922" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="338.2823102910013" y="353.33044737899996" width="174.2273432165625" height="16.988611432"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="340.7325907860013" y="351.69692704899995" text-anchor="start" dominant-baseline="alphabetic">GET /login/sso/redirect</text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="340.7325907860013" y="364.765089689" text-anchor="start" dominant-baseline="alphabetic">?redirectUrl=<clienturl></text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 370.319058811 L 637.7402104766588 370.319058811" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,370.319058811) translate(-640.4627443599921,-370.319058811)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 363.51272410266665 L 640.4627443599921 370.319058811 L 626.8500749433255 377.12539351933333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="676.127938231659" y="394.821863761" width="162.94903816285156" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="678.578218726659" y="409.52354673099995" text-anchor="start" dominant-baseline="alphabetic">Generate SAML request</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 656.7979476599922 416.05762805099994 L 722.1387608599922 416.05762805099994 L 722.1387608599922 437.29339234099996 L 659.5204815433256 437.29339234099996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(656.7979476599922,437.29339234099996) translate(-656.7979476599922,-437.29339234099996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 670.4106170766589 430.4870576326666 L 656.7979476599922 437.29339234099996 L 670.4106170766589 444.0997270493333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="406.3465650517435" y="461.79619729099994" width="71.71960701050781" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="408.7968455467435" y="476.4978802609999" text-anchor="start" dominant-baseline="alphabetic">302 to IdP</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 483.03196158099996 L 246.67252663733595 483.03196158099996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,483.03196158099996) translate(-243.9499927540026,-483.03196158099996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 476.2256268726666 L 243.9499927540026 483.03196158099996 L 257.5626621706693 489.8382962893333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="361.75195085568225" y="507.5347665309999" width="393.1480585485937" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="361.75195085568225" y="523.8699698309999" width="195.39319099488281" height="16.988611432"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="364.20223135068227" y="522.2364495009999" text-anchor="start" dominant-baseline="alphabetic">GET https:</text><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="452.4402989776354" y="522.2364495009999" text-anchor="start" dominant-baseline="alphabetic">//</text><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="470.087912503026" y="522.2364495009999" text-anchor="start" dominant-baseline="alphabetic">auth.mozilla.auth0.com/samlp/...</text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="364.20223135068227" y="535.3046121409999" text-anchor="start" dominant-baseline="alphabetic">?SAMLRequest=<SAML request></text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 540.8585812629999 L 869.9794336226224 540.8585812629999" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(872.7019675059557,540.8585812629999) translate(-872.7019675059557,-540.8585812629999)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 859.0892980892891 534.0522465546666 L 872.7019675059557 540.8585812629999 L 859.0892980892891 547.6649159713332 Z"/></g></g><g><g><rect fill="white" stroke="none" x="509.4380012707213" y="565.3613862129998" width="97.77595771851563" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="511.88828176572133" y="580.0630691829998" text-anchor="start" dominant-baseline="alphabetic">200 login form</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 872.7019675059557 586.5971505029999 L 246.67252663733595 586.5971505029999" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,586.5971505029999) translate(-243.9499927540026,-586.5971505029999)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 579.7908157946665 L 243.9499927540026 586.5971505029999 L 257.5626621706693 593.4034852113332 Z"/></g></g><g><g><rect fill="white" stroke="none" x="431.2403236584166" y="611.0999554529998" width="254.171312943125" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="433.69060415341664" y="625.8016384229998" text-anchor="start" dominant-baseline="alphabetic">submit login form with auth credentials</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 632.3357197429998 L 869.9794336226224 632.3357197429998" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(872.7019675059557,632.3357197429998) translate(-872.7019675059557,-632.3357197429998)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 859.0892980892891 625.5293850346665 L 872.7019675059557 632.3357197429998 L 859.0892980892891 639.1420544513331 Z"/></g></g><g><g><rect fill="white" stroke="none" x="362.81110918087757" y="656.8385246929997" width="391.0297418982031" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="365.2613896758776" y="671.5402076629997" text-anchor="start" dominant-baseline="alphabetic">200: auto-submitting HTML form including SAML Response</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 872.7019675059557 678.0742889829997 L 246.67252663733595 678.0742889829997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,678.0742889829997) translate(-243.9499927540026,-678.0742889829997)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 671.2679542746664 L 243.9499927540026 678.0742889829997 L 257.5626621706693 684.8806236913331 Z"/></g></g><g><g><rect fill="white" stroke="none" x="289.7513730961771" y="702.5770939329998" width="304.9099909216406" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="289.7513730961771" y="718.9122972329998" width="167.17206062378906" height="16.988611432"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="292.2016535911771" y="717.2787769029998" text-anchor="start" dominant-baseline="alphabetic">POST /_matrix/saml2/authn_response</text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="292.2016535911771" y="730.3469395429997" text-anchor="start" dominant-baseline="alphabetic">SAMLResponse=<response></text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 735.9009086649997 L 637.7402104766588 735.9009086649997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,735.9009086649997) translate(-640.4627443599921,-735.9009086649997)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 729.0945739566664 L 640.4627443599921 735.9009086649997 L 626.8500749433255 742.7072433733331 Z"/></g></g><g><g><rect fill="white" stroke="none" x="676.127938231659" y="760.4037136149997" width="136.8640619665625" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="678.578218726659" y="775.1053965849997" text-anchor="start" dominant-baseline="alphabetic">Check if known user</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 656.7979476599922 781.6394779049997 L 722.1387608599922 781.6394779049997 L 722.1387608599922 802.8752421949997 L 659.5204815433256 802.8752421949997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(656.7979476599922,802.8752421949997) translate(-656.7979476599922,-802.8752421949997)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 670.4106170766589 796.0689074866664 L 656.7979476599922 802.8752421949997 L 670.4106170766589 809.681576903333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="311.7710874516458" y="827.3780471449998" width="158.06123603882813" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="311.7710874516458" y="843.7132504449997" width="260.8705622107031" height="16.988611432"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="314.22136794664584" y="842.0797301149997" text-anchor="start" dominant-baseline="alphabetic">302 to username picker</text><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="314.22136794664584" y="855.1478927549997" text-anchor="start" dominant-baseline="alphabetic">including </text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="363.73423110582553" y="855.1478927549997" text-anchor="start" dominant-baseline="alphabetic">username_mapping_session</text><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="533.061013332388" y="855.1478927549997" text-anchor="start" dominant-baseline="alphabetic"> cookie</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 860.7018618769997 L 246.67252663733595 860.7018618769997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,860.7018618769997) translate(-243.9499927540026,-860.7018618769997)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 853.8955271686664 L 243.9499927540026 860.7018618769997 L 257.5626621706693 867.508196585333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="294.16328410691926" y="885.2046668269996" width="296.0861689001562" height="21.23576429"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="296.6135646019193" y="899.9063497969996" text-anchor="start" dominant-baseline="alphabetic">GET /_matrix/saml2/pick_username/</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 906.4404311169997 L 637.7402104766588 906.4404311169997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,906.4404311169997) translate(-640.4627443599921,-906.4404311169997)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 899.6340964086663 L 640.4627443599921 906.4404311169997 L 626.8500749433255 913.246765825333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="377.4312437016458" y="930.9432360669996" width="129.55024971070313" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="379.88152419664584" y="945.6449190369996" text-anchor="start" dominant-baseline="alphabetic">200 with form page</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 952.1790003569996 L 246.67252663733595 952.1790003569996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,952.1790003569996) translate(-243.9499927540026,-952.1790003569996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 945.3726656486663 L 243.9499927540026 952.1790003569996 L 257.5626621706693 958.985335065333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="272.1037748295755" y="976.6818053069996" width="340.2051874548437" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="272.1037748295755" y="993.0170086069996" width="146.00621284546875" height="16.988611432"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="274.55405532457553" y="991.3834882769996" text-anchor="start" dominant-baseline="alphabetic">GET /_matrix/saml2/pick_username/check</text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="274.55405532457553" y="1004.4516509169996" text-anchor="start" dominant-baseline="alphabetic">?username=<username></text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 1010.0056200389996 L 637.7402104766588 1010.0056200389996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,1010.0056200389996) translate(-640.4627443599921,-1010.0056200389996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 1003.1992853306663 L 640.4627443599921 1010.0056200389996 L 626.8500749433255 1016.811954747333 Z"/></g></g><g><g><rect fill="white" stroke="none" x="315.19585399217317" y="1034.5084249889994" width="254.02102912964844" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="315.19585399217317" y="1050.8436282889995" width="154.2688471228125" height="16.988611432"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="317.6461344871732" y="1049.2101079589995" text-anchor="start" dominant-baseline="alphabetic">200 </text><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="346.1714335594388" y="1049.2101079589995" text-anchor="start" dominant-baseline="alphabetic">{"available": true/false}</text><text fill="black" stroke="none" font-family="sans-serif" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="317.6461344871732" y="1062.2782705989996" text-anchor="start" dominant-baseline="alphabetic">or 200 </text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="354.1298991356107" y="1062.2782705989996" text-anchor="start" dominant-baseline="alphabetic">{"error": "..."}</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 1067.8322397209995 L 246.67252663733595 1067.8322397209995" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,1067.8322397209995) translate(-243.9499927540026,-1067.8322397209995)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 1061.0259050126663 L 243.9499927540026 1067.8322397209995 L 257.5626621706693 1074.6385744293327 Z"/></g></g><g><g><rect fill="white" stroke="none" x="263.27998332566926" y="1092.3350446709994" width="357.8527704626562" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="263.27998332566926" y="1108.6702479709995" width="138.95093025269531" height="16.988611432"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="265.7302638206693" y="1107.0367276409995" text-anchor="start" dominant-baseline="alphabetic">POST /_matrix/saml2/pick_username/submit</text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="265.7302638206693" y="1120.1048902809996" text-anchor="start" dominant-baseline="alphabetic">username=<username></text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 243.9499927540026 1125.6588594029995 L 637.7402104766588 1125.6588594029995" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,1125.6588594029995) translate(-640.4627443599921,-1125.6588594029995)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 1118.8525246946663 L 640.4627443599921 1125.6588594029995 L 626.8500749433255 1132.4651941113327 Z"/></g></g><g><g><rect fill="white" stroke="none" x="314.2907865483255" y="1150.1616643529997" width="255.83116401734375" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="316.74106704332553" y="1164.8633473229997" text-anchor="start" dominant-baseline="alphabetic">302 to original clienturl with loginToken</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 1171.3974286429996 L 246.67252663733595 1171.3974286429996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(243.9499927540026,1171.3974286429996) translate(-243.9499927540026,-1171.3974286429996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 257.5626621706693 1164.5910939346663 L 243.9499927540026 1171.3974286429996 L 257.5626621706693 1178.2037633513328 Z"/></g></g><g><g><rect fill="white" stroke="none" x="134.90244044227086" y="1195.9002335929997" width="4.90056099" height="21.23576429"/></g><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="137.35272093727085" y="1210.6019165629998" text-anchor="start" dominant-baseline="alphabetic"></text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 227.61478945400262 1217.1359978829996 L 49.8131863038724 1217.1359978829996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(47.090652420539065,1217.1359978829996) translate(-47.090652420539065,-1217.1359978829996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 60.703321837205735 1210.3296631746664 L 47.090652420539065 1217.1359978829996 L 60.703321837205735 1223.9423325913328 Z"/></g></g><g><g><rect fill="white" stroke="none" x="182.58255192846875" y="1241.6388028329995" width="101.96243537964844" height="20.255652092"/></g><g><rect fill="white" stroke="none" x="182.58255192846875" y="1257.9740061329996" width="322.3882929235937" height="16.988611432"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="185.03283242346873" y="1256.3404858029996" text-anchor="start" dominant-baseline="alphabetic">POST /login</text><text fill="black" stroke="none" font-family="monospace" font-size="8.8pt" font-style="normal" font-weight="normal" text-decoration="normal" x="185.03283242346873" y="1269.4086484429997" text-anchor="start" dominant-baseline="alphabetic">{"type": "m.login.token", "token": "<token>"}</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 47.090652420539065 1274.9626175649996 L 637.7402104766588 1274.9626175649996" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray=""/><g transform="translate(640.4627443599921,1274.9626175649996) translate(-640.4627443599921,-1274.9626175649996)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 626.8500749433255 1268.1562828566664 L 640.4627443599921 1274.9626175649996 L 626.8500749433255 1281.7689522733328 Z"/></g></g><g><g><rect fill="white" stroke="none" x="276.57198521704294" y="1299.4654225149998" width="134.40942634644531" height="21.23576429"/></g><text fill="black" stroke="none" font-family="monospace" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="279.02226571204295" y="1314.1671054849999" text-anchor="start" dominant-baseline="alphabetic">access token</text><text fill="black" stroke="none" font-family="sans-serif" font-size="11pt" font-style="normal" font-weight="normal" text-decoration="normal" x="384.9079468643867" y="1314.1671054849999" text-anchor="start" dominant-baseline="alphabetic"> etc</text></g><g><path fill="none" stroke="black" paint-order="fill stroke markers" d=" M 640.4627443599921 1320.7011868049997 L 49.8131863038724 1320.7011868049997" stroke-miterlimit="10" stroke-width="1.3612669416666667" stroke-dasharray="6.53408132"/><g transform="translate(47.090652420539065,1320.7011868049997) translate(-47.090652420539065,-1320.7011868049997)"><path fill="black" stroke="none" paint-order="stroke fill markers" d=" M 60.703321837205735 1313.8948520966665 L 47.090652420539065 1320.7011868049997 L 60.703321837205735 1327.507521513333 Z"/></g></g></g><g/></g></svg> \ No newline at end of file diff --git a/doc/login_flow.txt b/doc/login_flow.txt new file mode 100644 index 0000000..6520f7e --- /dev/null +++ b/doc/login_flow.txt @@ -0,0 +1,65 @@ + +title Mozilla matrix login flow + +participant Riot +participant "(Embedded) Browser" as B +participant "Synapse" as HS +participant "SAML2 IdP" as IdP + +activate Riot + +Riot->HS:""GET /login +activate HS +Riot<--HS:"""type":"m.login.sso" +deactivate HS + +create B +Riot->B: +activate B +B->HS:""GET /login/sso/redirect\n--?redirectUrl=<clienturl>--"" +activate HS +HS->HS:Generate SAML request +B<--HS:302 to IdP +deactivate HS + + +B->IdP: ""GET https://auth.mozilla.auth0.com/samlp/...\n--?SAMLRequest=<SAML request> +activate IdP +B<--IdP: 200 login form +deactivate IdP +B->IdP: submit login form with auth credentials +activate IdP +IdP-->B:200: auto-submitting HTML form including SAML Response +deactivate IdP + +B->HS:""POST /_matrix/saml2/authn_response\n--SAMLResponse=<response> +activate HS +HS->HS:Check if known user +B<--HS:302 to username picker\n--including ""username_mapping_session"" cookie +deactivate HS + +B->HS:""GET /_matrix/saml2/pick_username/ +activate HS +B<--HS: 200 with form page +deactivate HS + +B->HS:""GET /_matrix/saml2/pick_username/check\n--?username=<username> +activate HS +B<--HS:200 ""{"available": true/false}""\n--or 200 ""{"error": "..."}"" +deactivate HS + +B->HS:""POST /_matrix/saml2/pick_username/submit\n--username=<username> +activate HS +B<--HS:302 to original clienturl with loginToken +deactivate HS +Riot<-B: +deactivate B + +destroysilent B + +Riot->HS: ""POST /login\n--{"type": "m.login.token", "token": "<token>"} +activate HS +Riot<--HS:""access token"" etc +deactivate HS + + diff --git a/matrix_synapse_saml_mozilla/__init__.py b/matrix_synapse_saml_mozilla/__init__.py new file mode 100644 index 0000000..4da96ef --- /dev/null +++ b/matrix_synapse_saml_mozilla/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from matrix_synapse_saml_mozilla.mapping_provider import SamlMappingProvider +from matrix_synapse_saml_mozilla.username_picker import pick_username_resource + +__version__ = "0.0.1" + +__all__ = ["SamlMappingProvider", "pick_username_resource"] diff --git a/matrix_synapse_saml_mozilla/_sessions.py b/matrix_synapse_saml_mozilla/_sessions.py new file mode 100644 index 0000000..0c9bf79 --- /dev/null +++ b/matrix_synapse_saml_mozilla/_sessions.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import logging +from typing import Optional + +import attr +import time + + +SESSION_COOKIE_NAME = b"username_mapping_session" + +logger = logging.getLogger(__name__) + + +@attr.s +class UsernameMappingSession: + """Data we track about SAML2 sessions""" + + # user ID on the SAML server + remote_user_id = attr.ib(type=str) + + # displayname, per the SAML attributes + displayname = attr.ib(type=Optional[str]) + + # where to redirect the client back to + client_redirect_url = attr.ib(type=str) + + # expiry time for the session, in milliseconds + expiry_time_ms = attr.ib(type=int) + + +# a map from session id to session data +username_mapping_sessions = {} # type: dict[str, UsernameMappingSession] + + +def expire_old_sessions(gettime=time.time): + """Delete any sessions which have passed their expiry_time""" + to_expire = [] + now = int(gettime() * 1000) + + for session_id, session in username_mapping_sessions.items(): + if session.expiry_time_ms <= now: + to_expire.append(session_id) + + for session_id in to_expire: + logger.info("Expiring mapping session %s", session_id) + del username_mapping_sessions[session_id] + + +def get_mapping_session(session_id: str) -> Optional[UsernameMappingSession]: + """Look up the given session id, first expiring any old sessions""" + expire_old_sessions() + return username_mapping_sessions.get(session_id, None) diff --git a/matrix_synapse_saml_mozilla.py b/matrix_synapse_saml_mozilla/mapping_provider.py similarity index 50% rename from matrix_synapse_saml_mozilla.py rename to matrix_synapse_saml_mozilla/mapping_provider.py index ac1dbed..4392d91 100644 --- a/matrix_synapse_saml_mozilla.py +++ b/matrix_synapse_saml_mozilla/mapping_provider.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright 2019 The Matrix.org Foundation C.I.C. +# Copyright 2020 The Matrix.org Foundation C.I.C. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,100 +12,94 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +import logging +import random +import string +import time from typing import Tuple -import re + import attr -import string import saml2.response -__version__ = "0.0.1" +import synapse.module_api +from synapse.module_api.errors import RedirectException + +from matrix_synapse_saml_mozilla._sessions import ( + UsernameMappingSession, + username_mapping_sessions, + expire_old_sessions, + SESSION_COOKIE_NAME, +) + +logger = logging.getLogger(__name__) + + +MAPPING_SESSION_VALIDITY_PERIOD_MS = 15 * 60 * 1000 @attr.s class SamlConfig(object): - mxid_source_attribute = attr.ib() + pass class SamlMappingProvider(object): - def __init__(self, parsed_config: SamlConfig): + def __init__( + self, parsed_config: SamlConfig, module_api: synapse.module_api.ModuleApi + ): """A Mozilla-flavoured, Synapse user mapping provider Args: parsed_config: A configuration object. The result of self.parse_config """ - self._mxid_source_attribute = parsed_config.mxid_source_attribute - - mxid_localpart_allowed_characters = set( - "_-./=" + string.ascii_lowercase + string.digits - ) - self._dot_replace_pattern = re.compile( - ("[^%s]" % (re.escape("".join(mxid_localpart_allowed_characters)),)) - ) - self._multiple_to_single_dot_pattern = re.compile(r"\.{2,}") - self._string_end_dot_pattern = re.compile(r"\.$") + self._random = random.SystemRandom() def saml_response_to_user_attributes( - self, - saml_response: saml2.response.AuthnResponse, - failures: int = 0, + self, + saml_response: saml2.response.AuthnResponse, + failures: int, + client_redirect_url: str, ) -> dict: """Maps some text from a SAML response to attributes of a new user - Args: saml_response: A SAML auth response object failures: How many times a call to this function with this saml_response has resulted in a failure + client_redirect_url: where the client wants to redirect back to + Returns: dict: A dict containing new user attributes. Possible keys: * mxid_localpart (str): Required. The localpart of the user's mxid * displayname (str): The displayname of the user """ - # The calling function will catch the KeyError if this fails - mxid_source = saml_response.ava[self._mxid_source_attribute][0] - - # Truncate the username to the first found '@' character to prevent complete - # emails being leaked - pos = mxid_source.find("@") - if pos >= 0: - mxid_source = mxid_source[:pos] - mxid_localpart = self._dotreplace_for_mxid(mxid_source) - - # Append suffix integer if last call to this function failed to produce - # a usable mxid - localpart = mxid_localpart + (str(failures) if failures else "") - - # Retrieve the display name from the saml response + remote_user_id = saml_response.ava["uid"][0] displayname = saml_response.ava.get("displayName", [None])[0] - return { - "mxid_localpart": localpart, - "displayname": displayname, - } - - def _dotreplace_for_mxid(self, username: str) -> str: - """Replace non-allowed mxid characters with a '.' - - Args: - username (str): The username to process + expire_old_sessions() - Returns: - str: The processed username - """ - username = username.lower() - username = self._dot_replace_pattern.sub(".", username) + # make up a cryptorandom session id + session_id = "".join( + self._random.choice(string.ascii_letters) for _ in range(16) + ) - # regular mxids aren't allowed to start with an underscore either - username = re.sub("^_", "", username) + now = int(time.time() * 1000) + session = UsernameMappingSession( + remote_user_id=remote_user_id, + displayname=displayname, + client_redirect_url=client_redirect_url, + expiry_time_ms=now + MAPPING_SESSION_VALIDITY_PERIOD_MS, + ) - # Change all instances of multiple dots together into a single dot - username = self._multiple_to_single_dot_pattern.sub(".", username) + username_mapping_sessions[session_id] = session + logger.info("Recorded registration session id %s", session_id) - # Remove any trailing dots - username = self._string_end_dot_pattern.sub("", username) - return username + # Redirect to the username picker + e = RedirectException(b"/_matrix/saml2/pick_username/") + e.cookies.append( + b"%s=%s; path=/" % (SESSION_COOKIE_NAME, session_id.encode("ascii"),) + ) + raise e @staticmethod def parse_config(config: dict) -> SamlConfig: @@ -115,8 +109,7 @@ class SamlMappingProvider(object): Returns: SamlConfig: A custom config object """ - mxid_source_attribute = config.get("mxid_source_attribute", "uid") - return SamlConfig(mxid_source_attribute) + return SamlConfig() @staticmethod def get_saml_attributes(config: SamlConfig) -> Tuple[set, set]: @@ -131,4 +124,4 @@ class SamlMappingProvider(object): second set consists of those attributes which can be used if available, but are not necessary """ - return {"uid", config.mxid_source_attribute}, {"displayName"} + return {"uid"}, {"displayName"} diff --git a/matrix_synapse_saml_mozilla/res/index.html b/matrix_synapse_saml_mozilla/res/index.html new file mode 100644 index 0000000..c6463a8 --- /dev/null +++ b/matrix_synapse_saml_mozilla/res/index.html @@ -0,0 +1,20 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <title>Synapse Login</title> + <link rel="stylesheet" href="style.css" type="text/css" /> + </head> + <body> + <div class="card"> + <form method="post" class="form__input" id="form" action="submit"> + <input type="text" name="username" id="field-username" autofocus=""> + <label for="field-username"> + <span><span aria-hidden="true">Please pick your </span>username</span> + </label> + <input type="button" class="button button--full-width" id="button-submit" value="Submit"> + </form> + <div role=alert class="tooltip hidden" id="message"></div> + <script src="script.js"></script> + </div> + </body> +</html> diff --git a/matrix_synapse_saml_mozilla/res/script.js b/matrix_synapse_saml_mozilla/res/script.js new file mode 100644 index 0000000..1fa4e39 --- /dev/null +++ b/matrix_synapse_saml_mozilla/res/script.js @@ -0,0 +1,117 @@ +let inputField = document.getElementById("field-username"); +let inputForm = document.getElementById("form"); +let submitButton = document.getElementById("button-submit"); +let message = document.getElementById("message"); + +// Remove input field placeholder if the text field is not empty +let switchClass = function(input) { + if (input.value.length > 0) { + input.classList.add('has-contents'); + } + else { + input.classList.remove('has-contents'); + } +}; + +// Submit username and receive response +let showMessage = function(messageText) { + // Unhide the message text + message.classList.remove("hidden"); + + message.innerHTML = messageText; +}; + +let onResponse = function(response, success) { + // Display message + showMessage(response); + + if(success) { + inputForm.submit(); + return; + } + + // Enable submit button and input field + submitButton.classList.remove('button--disabled'); + submitButton.value = "Submit" +}; + +// We allow upper case characters here, but then lowercase before sending to the server +let allowedUsernameCharacters = RegExp("[^a-zA-Z0-9\\.\\_\\=\\-\\/]"); +let usernameIsValid = function(username) { + return !allowedUsernameCharacters.test(username); +} +let allowedCharactersString = "" + +"<code>a-z</code>, " + +"<code>0-9</code>, " + +"<code>.</code>, " + +"<code>_</code>, " + +"<code>-</code>, " + +"<code>/</code>, " + +"<code>=</code>"; + +let buildQueryString = function(params) { + return Object.keys(params) + .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) + .join('&'); +} + +let submitUsername = function(username) { + if(username.length == 0) { + onResponse("Please enter a username.", false); + return; + } + if(!usernameIsValid(username)) { + onResponse("Invalid username. Only the following characters are allowed: " + allowedCharactersString, false); + return; + } + + let check_uri = 'check?' + buildQueryString({"username": username}); + fetch(check_uri, { + "credentials": "include", + }).then((response) => { + if(!response.ok) { + // for non-200 responses, raise the body of the response as an exception + return response.text().then((text) => { throw text }); + } else { + return response.json() + } + }).then((json) => { + if(json.error) { + throw json.error; + } else if(json.available) { + onResponse("Success. Please wait a moment for your browser to redirect.", true); + } else { + onResponse("This username is not available, please choose another.", false); + } + }).catch((err) => { + onResponse("Error checking username availability: " + err, false); + }); +} + +let clickSubmit = function() { + if(submitButton.classList.contains('button--disabled')) { return; } + + // Disable submit button and input field + submitButton.classList.add('button--disabled'); + + // Submit username + submitButton.value = "Checking..."; + submitUsername(inputField.value); +}; + +submitButton.onclick = clickSubmit; + +// Listen for events on inputField +inputField.addEventListener('keypress', function(event) { + // Listen for Enter on input field + if(event.which === 13) { + event.preventDefault(); + clickSubmit(); + return true; + } + switchClass(inputField); +}); +inputField.addEventListener('change', function() { + switchClass(inputField); +}); + diff --git a/matrix_synapse_saml_mozilla/res/style.css b/matrix_synapse_saml_mozilla/res/style.css new file mode 100644 index 0000000..45377f8 --- /dev/null +++ b/matrix_synapse_saml_mozilla/res/style.css @@ -0,0 +1,178 @@ +body { + background: #ededf0; + color: #737373; + font-family: "Open Sans", sans-serif; + letter-spacing: 0.03em; + margin: 0; + padding: 0; + display: grid; + grid-template-rows: auto 1fr; } + +.card { + background-color: #fff; + padding: 2em; + position: relative; + margin: auto; + width: 100%; + box-shadow: 0 0.25em 0.25em 0 rgba(210, 210, 210, 0.5); + border-radius: 0.125em; } + @media (min-width: 25em) { + .card { + max-width: 26em; + padding: 2.5em; } } + @supports (display: grid) { + .card { + grid-row-start: 2; } + @media (min-height: 50em) { + .card { + top: -3em; + /* compensate for negative margin for footer links */ } } } + .card__back { + margin-bottom: 1em; } + .card__heading { + font-size: 1.4em; + font-weight: 400; + text-transform: capitalize; + padding-left: 2.125em; + position: relative; + min-height: 1.5em; } + .card__heading--iconless { + padding-left: 0; } + .card__heading img { + width: 1.5em; + height: 1.5em; + position: absolute; + left: 0; + top: 0; } + .card__heading--success { + color: #12bc00; } + .card__heading--error { + color: #ff0039; } + .card [data-screen]:focus { + outline: none; } + +* { + box-sizing: border-box; } + +form { + margin: 0; } + form * { + font-family: inherit; } + +label { + margin: 2em 0; + display: block; } + +input[type="text"], +input[type="email"], +input[type="password"] { + font-size: 100%; + background-color: #ededf0; + border: 1px solid #fff; + border-radius: .2em; + padding: .5em .9em; + display: block; + width: 100%; + margin-bottom: 1em; } + input[type="text"]:hover, input[type="text"]:focus, + input[type="email"]:hover, + input[type="email"]:focus, + input[type="password"]:hover, + input[type="password"]:focus { + border: 1px solid #0060df; + outline: none; } + .focus-styles input[type="text"]:focus, .focus-styles + input[type="email"]:focus, .focus-styles + input[type="password"]:focus { + border-color: transparent; } + +.form__input { + position: relative; } + p + .form__input { + margin-top: 2.5em; + /* leave space to fit a paragraph above a field */ } + .form__input label { + margin: 0; + position: absolute; + top: .5em; + left: .9em; } + .form__input input:focus + label, + .form__input input.has-contents + label { + position: absolute; + top: -1.5em; + color: #0060df; + font-weight: bold; } + .form__input input:focus + label > span, + .form__input input.has-contents + label > span { + font-size: 0.75em; } + +html, +body { + height: 100%; } + +.button { + text-align: center; + text-decoration: none; + padding: .93em 2em; + display: block; + font-size: 87.5%; + letter-spacing: .04em; + line-height: 1.57; + font-family: inherit; + border-radius: 2em; + background-color: #0060df; + color: #fff; + border: 1px solid transparent; + transition: background-color .1s ease-in-out; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; } + .button:hover { + background-color: #fff; + color: #0060df; + border-color: currentColor; + text-decoration: none; } + .button:active { + background-color: #0060df; + color: #fff; + border-color: #0060df; } + .button--full-width { + width: 100%; } + .button--secondary { + border-color: #b1b1b3; + background-color: transparent; + color: #000; + text-transform: none; } + .button--secondary:hover { + background-color: #000; + color: #fff; + border-color: transparent; } + .button--secondary:hover svg > path { + fill: #fff; } + .button--secondary:active { + background-color: transparent; + border-color: #000; + color: #000; } + .button--disabled { + border-color: #fff; + background-color: transparent; + color: #000; + text-transform: none; } + .button--disabled:hover { + background-color: #fff; + color: #000; + border-color: transparent; } + +.hidden { + display: none; } + +.tooltip { + background-color: #f9f9fa; + padding: 1em; + margin: 1em 0; } + .tooltip p:last-child { + margin-bottom: 0; } + .tooltip a:last-child { + margin-left: .5em; } + .tooltip:target { + display: block; } diff --git a/matrix_synapse_saml_mozilla/username_picker.py b/matrix_synapse_saml_mozilla/username_picker.py new file mode 100644 index 0000000..0d7a16f --- /dev/null +++ b/matrix_synapse_saml_mozilla/username_picker.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import html +import json +import logging +import urllib.parse +from typing import Any + +import pkg_resources +from twisted.web.resource import Resource +from twisted.web.server import NOT_DONE_YET, Request +from twisted.web.static import File + +import synapse.module_api +from synapse.module_api import run_in_background +from synapse.module_api.errors import SynapseError + +from matrix_synapse_saml_mozilla._sessions import ( + get_mapping_session, + username_mapping_sessions, + SESSION_COOKIE_NAME, +) + +""" +This file implements the "username picker" resource, which is mapped as an +additional_resource into the synapse resource tree. + +The top-level resource is just a File resource which serves up the static files in the +"res" directory, but it has a couple of children: + + * "submit", which does the mechanics of registering the new user, and redirects the + browser back to the client URL + + * "check" (TODO): checks if a userid is free. +""" + +logger = logging.getLogger(__name__) + + +def pick_username_resource( + parsed_config, module_api: synapse.module_api.ModuleApi +) -> Resource: + """Factory method to generate the top-level username picker resource""" + base_path = pkg_resources.resource_filename("matrix_synapse_saml_mozilla", "res") + res = File(base_path) + res.putChild(b"submit", SubmitResource(module_api)) + res.putChild(b"check", AvailabilityCheckResource(module_api)) + return res + + +def parse_config(config: dict): + return None + + +pick_username_resource.parse_config = parse_config + + +HTML_ERROR_TEMPLATE = """<!DOCTYPE html> +<html lang=en> + <head> + <meta charset="utf-8"> + <title>Error {code}</title> + </head> + <body> + <p>{msg}</p> + </body> +</html> +""" + + +def _wrap_for_html_exceptions(f): + async def wrapped(self, request): + try: + return await f(self, request) + except Exception: + logger.exception("Error handling request %s" % (request,)) + _return_html_error(500, "Internal server error", request) + + return wrapped + + +def _wrap_for_text_exceptions(f): + async def wrapped(self, request): + try: + return await f(self, request) + except Exception: + logger.exception("Error handling request %s" % (request,)) + body = b"Internal server error" + request.setResponseCode(500) + request.setHeader(b"Content-Type", b"text/plain; charset=utf-8") + request.setHeader(b"Content-Length", b"%i" % (len(body),)) + request.write(body) + request.finish() + + return wrapped + + +class AsyncResource(Resource): + """Extends twisted.web.Resource to add support for async_render_X methods""" + + def render(self, request: Request): + method = request.method.decode("ascii") + m = getattr(self, "async_render_" + method, None) + if not m and method == "HEAD": + m = getattr(self, "async_render_GET", None) + if not m: + return super().render(request) + + async def run(): + with request.processing(): + return await m(request) + + run_in_background(run) + return NOT_DONE_YET + + +class SubmitResource(AsyncResource): + def __init__(self, module_api: synapse.module_api.ModuleApi): + super().__init__() + self._module_api = module_api + + @_wrap_for_html_exceptions + async def async_render_POST(self, request: Request): + session_id = request.getCookie(SESSION_COOKIE_NAME) + if not session_id: + _return_html_error(400, "missing session_id", request) + return + + session_id = session_id.decode("ascii", errors="replace") + session = get_mapping_session(session_id) + if not session: + logger.info("Session ID %s not found", session_id) + _return_html_error(403, "Unknown session", request) + return + + # we don't clear the session from the dict until the ID is successfully + # registered, so the user can go round and have another go if need be. + # + # this means there's theoretically a race where a single user can register + # two accounts. I'm going to assume that's not a dealbreaker. + + if b"username" not in request.args: + _return_html_error(400, "missing username", request) + return + localpart = request.args[b"username"][0].decode("utf-8", errors="replace") + logger.info("Registering username %s", localpart) + try: + registered_user_id = await self._module_api.register_user( + localpart=localpart, displayname=session.displayname + ) + except SynapseError as e: + logger.warning("Error during registration: %s", e) + _return_html_error(e.code, e.msg, request) + return + + await self._module_api.record_user_external_id( + "saml", session.remote_user_id, registered_user_id + ) + + del username_mapping_sessions[session_id] + + login_token = self._module_api.generate_short_term_login_token( + registered_user_id + ) + redirect_url = _add_login_token_to_redirect_url( + session.client_redirect_url, login_token + ) + + # delete the cookie + request.addCookie( + SESSION_COOKIE_NAME, + b"", + expires=b"Thu, 01 Jan 1970 00:00:00 GMT", + path=b"/", + ) + request.redirect(redirect_url) + request.finish() + + +class AvailabilityCheckResource(AsyncResource): + def __init__(self, module_api: synapse.module_api.ModuleApi): + super().__init__() + self._module_api = module_api + + @_wrap_for_text_exceptions + async def async_render_GET(self, request: Request): + # make sure that there is a valid mapping session, to stop people dictionary- + # scanning for accounts + session_id = request.getCookie(SESSION_COOKIE_NAME) + if not session_id: + _return_json({"error": "missing session_id"}, request) + return + + session_id = session_id.decode("ascii", errors="replace") + session = get_mapping_session(session_id) + if not session: + logger.info("Couldn't find session id %s", session_id) + _return_json({"error": "unknown session"}, request) + return + + if b"username" not in request.args: + _return_json({"error": "missing username"}, request) + return + localpart = request.args[b"username"][0].decode("utf-8", errors="replace") + logger.info("Checking for availability of username %s", localpart) + try: + user_id = self._module_api.get_qualified_user_id(localpart) + registered_id = await self._module_api.check_user_exists(user_id) + available = registered_id is None + except Exception as e: + logger.warning( + "Error checking for availability of %s: %s %s" % (localpart, type(e), e) + ) + available = False + response = {"available": available} + _return_json(response, request) + + +def _add_login_token_to_redirect_url(url, token): + url_parts = list(urllib.parse.urlparse(url)) + query = dict(urllib.parse.parse_qsl(url_parts[4])) + query.update({"loginToken": token}) + url_parts[4] = urllib.parse.urlencode(query) + return urllib.parse.urlunparse(url_parts) + + +def _return_html_error(code: int, msg: str, request: Request): + """Sends an HTML error page""" + body = HTML_ERROR_TEMPLATE.format(code=code, msg=html.escape(msg)).encode("utf-8") + request.setResponseCode(code) + request.setHeader(b"Content-Type", b"text/html; charset=utf-8") + request.setHeader(b"Content-Length", b"%i" % (len(body),)) + request.write(body) + try: + request.finish() + except RuntimeError as e: + logger.info("Connection disconnected before response was written: %r", e) + + +def _return_json(json_obj: Any, request: Request): + json_bytes = json.dumps(json_obj).encode("utf-8") + + request.setHeader(b"Content-Type", b"application/json") + request.setHeader(b"Content-Length", b"%d" % (len(json_bytes),)) + request.setHeader(b"Cache-Control", b"no-cache, no-store, must-revalidate") + request.setHeader(b"Access-Control-Allow-Origin", b"*") + request.setHeader( + b"Access-Control-Allow-Methods", b"GET, POST, PUT, DELETE, OPTIONS" + ) + request.setHeader( + b"Access-Control-Allow-Headers", + b"Origin, X-Requested-With, Content-Type, Accept, Authorization", + ) + request.write(json_bytes) + try: + request.finish() + except RuntimeError as e: + logger.info("Connection disconnected before response was written: %r", e) diff --git a/setup.cfg b/setup.cfg index ab5366c..f3d6356 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,3 +2,15 @@ max-line-length = 90 # W503 requires that binary operators be at the end, not start, of lines. Erik doesn't like it. ignore = W503 + +[isort] +line_length = 88 +not_skip = __init__.py +sections = FUTURE,STDLIB,THIRDPARTY,SYNAPSE,FIRSTPARTY,TESTS,LOCALFOLDER +default_section = THIRDPARTY +known_synapse = synapse +known_first_party = matrix_synapse_saml_mozilla +known_tests = tests +multi_line_output = 3 +include_trailing_comma = true +combine_as_imports = true diff --git a/setup.py b/setup.py index 5e70e50..e15a6c4 100755 --- a/setup.py +++ b/setup.py @@ -34,25 +34,22 @@ def exec_file(path_segments, name): the constant and executing it.""" result = {} code = read_file(path_segments) - lines = [line for line in code.split('\n') if line.startswith(name)] + lines = [line for line in code.split("\n") if line.startswith(name)] exec("\n".join(lines), result) return result[name] setup( name="matrix-synapse-saml-mozilla", - version=exec_file(("matrix_synapse_saml_mozilla.py",), "__version__"), + version=exec_file(("matrix_synapse_saml_mozilla/__init__.py",), "__version__"), py_modules=["matrix-synapse-saml-mozilla"], description="An Mozilla-flavoured SAML MXID mapper for Synapse", - install_requires=[ - "attr>=0.3.1", - "pysaml2>=4.5.0", - ], + install_requires=["attr>=0.3.1", "pysaml2>=4.5.0"], long_description=read_file(("README.md",)), long_description_content_type="text/markdown", classifiers=[ - 'Development Status :: 4 - Beta', - 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 3', + "Development Status :: 4 - Beta", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", ], ) diff --git a/tests/__init__.py b/tests/__init__.py index c04c70f..4bd8d25 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,16 +1,15 @@ from typing import Tuple + from matrix_synapse_saml_mozilla import SamlMappingProvider def create_mapping_provider() -> Tuple[SamlMappingProvider, dict]: # Default configuration - config_dict = { - "mxid_source_attribute": "uid" - } + config_dict = {} # Convert the config dictionary to a SamlMappingProvider.SamlConfig object config = SamlMappingProvider.parse_config(config_dict) # Create a new instance of the provider with the specified config # Return the config dict as well for other test methods to use - return SamlMappingProvider(config), config_dict + return SamlMappingProvider(config, None), config_dict diff --git a/tests/test_attributes.py b/tests/test_attributes.py index e599f6d..a89af77 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -14,103 +14,58 @@ # limitations under the License. import logging +import re +import time import unittest -from typing import Optional -from . import create_mapping_provider - -logging.basicConfig() +from synapse.api.errors import RedirectException +from matrix_synapse_saml_mozilla._sessions import username_mapping_sessions -def _make_test_saml_response( - provider_config: dict, - source_attribute_value: str, - display_name: Optional[str] = None -): - """Create a fake object based off of saml2.response.AuthnResponse - - Args: - provider_config: The config dictionary used when creating the provider object - source_attribute_value: The desired value that the mapping provider will - pull out of the response object to turn into a Matrix UserID. - display_name: The desired displayname that the mapping provider will pull - out of the response object to turn into a Matrix user displayname. - - Returns: - An object masquerading as a saml2.response.AuthnResponse object - """ +from . import create_mapping_provider - class FakeResponse(object): +logging.basicConfig() - def __init__(self): - self.ava = { - provider_config["mxid_source_attribute"]: [source_attribute_value], - } - if display_name: - self.ava["displayName"] = [display_name] +class FakeResponse: + def __init__(self, source_uid, display_name): + self.ava = { + "uid": [source_uid], + } - return FakeResponse() + if display_name: + self.ava["displayName"] = [display_name] class SamlUserAttributeTestCase(unittest.TestCase): - - def _attribute_test( - self, - input_uid: str, - input_displayname: Optional[str], - output_localpart: str, - output_displayname: Optional[str], - ): - """Creates a dummy response, feeds it to the provider and checks the output - - Args: - input_uid: The value of the mxid_source_attribute that the provider will - base the generated localpart off of. - input_displayname: The saml auth response displayName value that the - provider will generate a Matrix user displayname from. - output_localpart: The expected mxid localpart. - output_displayname: The expected matrix displayname. - + def test_redirect(self): + """Creates a dummy response, feeds it to the provider and checks that it + redirects to the username picker. """ provider, config = create_mapping_provider() - response = _make_test_saml_response(config, input_uid, input_displayname) - - attribute_dict = provider.saml_response_to_user_attributes(response) - self.assertEqual(attribute_dict["mxid_localpart"], output_localpart) - self.assertEqual(attribute_dict["displayname"], output_displayname) - - def test_normal_user(self): - self._attribute_test("john*doe2000#@example.com", None, "john.doe2000", None) - - def test_normal_user_displayname(self): - self._attribute_test( - "john*doe2000#@example.com", "Jonny", "john.doe2000", "Jonny" - ) - - def test_multiple_adjacent_symbols(self): - self._attribute_test("bob%^$&#!bobby@example.com", None, "bob.bobby", None) - - def test_username_does_not_end_with_dot(self): - """This is allowed in mxid syntax, but is not aesthetically pleasing""" - self._attribute_test("bob.bobby$@example.com", None, "bob.bobby", None) - - def test_username_no_email(self): - self._attribute_test("bob.bobby", None, "bob.bobby", None) - - def test_username_starting_with_underscore(self): - self._attribute_test( - "_twilight (sparkle)@somewhere.com", None, "twilight.sparkle", None + response = FakeResponse(123435, "Jonny") + + # we expect this to redirect to the username picker + with self.assertRaises(RedirectException) as cm: + provider.saml_response_to_user_attributes(response, 0, "http://client/") + self.assertEqual(cm.exception.location, b"/_matrix/saml2/pick_username/") + + cookieheader = cm.exception.cookies[0] + regex = re.compile(b"^username_mapping_session=([a-zA-Z]+);") + m = regex.search(cookieheader) + if not m: + self.fail("cookie header %s does not match %s" % (cookieheader, regex)) + + session_id = m.group(1).decode("ascii") + self.assertIn( + session_id, username_mapping_sessions, "session id not found in map" ) - - def test_existing_user(self): - provider, config = create_mapping_provider() - response = _make_test_saml_response(config, "wibble%@wobble.com", None) - - attribute_dict = provider.saml_response_to_user_attributes(response) - - # Simulate a failure on the first attempt - attribute_dict = provider.saml_response_to_user_attributes(response, failures=1) - - self.assertEqual(attribute_dict["mxid_localpart"], "wibble1") - self.assertEqual(attribute_dict["displayname"], None) + session = username_mapping_sessions[session_id] + self.assertEqual(session.remote_user_id, 123435) + self.assertEqual(session.displayname, "Jonny") + self.assertEqual(session.client_redirect_url, "http://client/") + + # the expiry time should be about 15 minutes away + expected_expiry = (time.time() + 15 * 60) * 1000 + self.assertGreaterEqual(session.expiry_time_ms, expected_expiry - 1000) + self.assertLessEqual(session.expiry_time_ms, expected_expiry + 1000) diff --git a/tox.ini b/tox.ini index 262f7b3..6e1367a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,25 +1,33 @@ [tox] -envlist = packaging, pep8 +envlist = packaging, lint, tests [testenv] setenv = PYTHONDONTWRITEBYTECODE = no_byte_code - PYTHONPATH = . [testenv:tests] +deps = + git+git://github.com/matrix-org/synapse@rav/mozilla_username_hacks#egg=matrix-synapse + commands = python -m unittest discover [testenv:packaging] +skip_install = True deps = check-manifest commands = check-manifest -[testenv:pep8] +[testenv:lint] skip_install = True basepython = python3 deps = flake8 + # We pin so that our tests don't start failing on new releases of black. + black==19.10b0 + isort commands = - flake8 saml_mapping_provider.py tests/* + python -m black --check --diff . + flake8 matrix_synapse_saml_mozilla tests + isort -c -df -sp setup.cfg -rc matrix_synapse_saml_mozilla tests