├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── pam-script-saml.php ├── pam_script_auth ├── php.ini ├── test.local.sh └── test.sh /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test functionality and compatibility 2 | 3 | on: 4 | # run on push 5 | push: 6 | branches: [ master ] 7 | # and once every month 8 | schedule: 9 | - cron: '07 16 13 * *' 10 | jobs: 11 | run: 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | operating-system: ['ubuntu-latest'] 16 | php-versions: ['5.5', '5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0'] 17 | runs-on: ${{ matrix.operating-system }} 18 | name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v2 22 | 23 | - name: Setup PHP 24 | uses: shivammathur/setup-php@v2 25 | with: 26 | php-version: ${{ matrix.php-versions }} 27 | extensions: mbstring, xml, dom, :opcache 28 | coverage: none 29 | env: 30 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: PHP information 33 | run: | 34 | php -v 35 | php -m 36 | 37 | - name: Install Composer dependencies 38 | run: composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader 39 | 40 | - name: Show Composer dependencies 41 | run: composer show --tree --no-interaction 42 | 43 | - name: Run test 44 | run: bash ./test.sh 45 | env: 46 | ITERATIONS: 500 47 | IDP_METADATA: ${{ secrets.TEST_IDP_METADATA }} 48 | TRUSTED_SP: ${{ secrets.TEST_TRUSTED_SP }} 49 | PAM_AUTHTOK: ${{ secrets.TEST_PAM_AUTHTOK }} 50 | PAM_RHOST: ${{ secrets.TEST_PAM_RHOST }} 51 | PAM_TYPE: ${{ secrets.TEST_PAM_TYPE }} 52 | PAM_USER: ${{ secrets.TEST_PAM_USER }} 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | composer.phar 3 | test.env 4 | /vendor/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Christoph Kreutzer 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pam-script-saml 2 | 3 | This is a PAM module (using pam_script) which validates SAML assertions given as password. It is inspired by crudesaml, but implemented in PHP using LightSAML Core library. 4 | 5 | Currently (and probably definately) only the `auth` PAM type is supported. For all other types you usually want to use another module (in the simplest case e.g. `pam_permit.so`). 6 | 7 | License: [BSD 2-Clause](LICENSE) 8 | 9 | Inspired by [crudesaml](https://ftp.espci.fr/pub/crudesaml/), but doesn't depend on (a patched) liblasso3. 10 | 11 | ## Key features 12 | * Verification of SAML2 assertions as password replacement 13 | * configuration options similar to crudesaml 14 | 15 | ## Compatibility 16 | Integrates well with [SOGo Groupware](https://sogo.nu/) and the [Dovecot MDA](http://dovecot.org/) using PAM authentication. 17 | 18 | ## Configuration options 19 | Passed in the PAM configuration in the format `key=value` (analog to crudesaml). 20 | 21 | * `userid`: name of SAML attribute which contains the username. The value will be matched against the username passed by PAM. Default: `uid` 22 | * `grace`: Time frame (in seconds) allowing the validation of the assertion deviating from the given time frame in the assertion (for clock skew or longer authentication validity). Default: `600` 23 | * `saml_check_timeframe`: If `0` (disabled), validates the assertion also when it's expired. Default: `1` 24 | * `idp`: Path to metadata file from which IdP certificates for assertion signature validation are extracted (multiple allowed). Signature is not verified, if none is given (not recommended!). 25 | * `trusted_sp`: EntityID of SP which should be trusted (i.e. which is in the Audience {Assertion/Conditions/AudienceRestriction/Audience}). All are allowed, if none is given (not recommended!). 26 | * `only_from`: Comma-separated list of IPs which can authenticate. 27 | 28 | Logging can be enabled by using the `pam_script_auth` wrapper script and setting the `LOGFILE` variable. This helps troubleshooting a lot, since pam-script-saml is indicating where the validation fails. 29 | 30 | ## Installation 31 | 1. Download: 32 | 1. Clone via git: `git clone https://github.com/ck-ws/pam-script-saml.git` 33 | 2. Zipball: `https://github.com/ck-ws/pam-script-saml/archive/master.zip` 34 | 2. Install dependencies: `composer.phar install` 35 | 3. Make sure the following PHP extensions are installed: dom, mbstring, mcrypt, opcache (zend_extension) 36 | 4. Configure (see below) 37 | 38 | ## Configuration 39 | 1. Install [pam_script](https://github.com/jeroennijhof/pam_script) from source or from your distribution. 40 | 2. Install `pam-script-saml` in a directory of your choice (see above). 41 | 3. Use the given `pam_script_auth` file (or create a symlink from `pam_script_auth` to `pam-script-saml.php`) 42 | 4. configure the PAM module in `/etc/pam.d/` like this, for example: 43 | ```` 44 | auth required pam_script.so dir= userid=mail grace=900 [...] 45 | account required pam_permit.so 46 | session required pam_permit.so 47 | ```` 48 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "lightsaml/lightsaml": "^1.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /pam-script-saml.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | (uid) 13 | # - grace= (600) 14 | # - saml_check_timeframe=[0/1] (1) 15 | # - idp= 16 | # - trusted_sp= {Assertion/Conditions/AudienceRestriction/Audience} 17 | # - only_from= 18 | 19 | // init autoloader 20 | include 'vendor/autoload.php'; 21 | 22 | // only auth requests 23 | if(empty($_SERVER['PAM_TYPE']) || $_SERVER['PAM_TYPE'] !== 'auth') 24 | { 25 | echo 'This PAM module only supports the "auth" type.'.PHP_EOL; 26 | exit(1); 27 | } 28 | 29 | // get necessary ENV variables 30 | $pamUser = $_SERVER['PAM_USER']; 31 | $xmlSrc = $_SERVER['PAM_AUTHTOK']; 32 | $remoteHost = $_SERVER['PAM_RHOST']; 33 | 34 | // stop here if the "assertion" is less than or equal 32 chars (that can't be a XML doc) 35 | if(strlen($xmlSrc) <= 32) 36 | { 37 | echo 'No valid Assertion given: Document too short'.PHP_EOL; 38 | exit(3); 39 | } 40 | 41 | // get arguments 42 | $args = array_merge(array( 43 | 'userid' => 'uid', 44 | 'grace' => '600', 45 | 'saml_check_timeframe' => '1', 46 | //'only_from' => '127.0.0.1,::1', 47 | ), array_reduce(array_slice($argv, 1), function($res, $item) { 48 | list($opt, $val) = explode('=', $item, 2); 49 | $opt = preg_replace('/^(\'(.*)\'|"(.*)")$/', '$2$3', $opt); 50 | $val = preg_replace('/^(\'(.*)\'|"(.*)")$/', '$2$3', $val); 51 | if(in_array($opt, array('idp', 'trusted_sp'))) 52 | { 53 | if(!isset($res[$opt])) $res[$opt] = array(); 54 | $res[$opt][] = $val; 55 | } 56 | else 57 | { 58 | $res[$opt] = $val; 59 | } 60 | return $res; 61 | }, array())); 62 | 63 | // check if request is in only_from 64 | if(!empty($args['only_from']) && !empty($remoteHost) && !in_array($remoteHost, explode(',', $args['only_from']))) 65 | { 66 | echo 'This host is not allowed to authenticate using this PAM module.'.PHP_EOL; 67 | exit(2); 68 | } 69 | 70 | // unpack assertion 71 | $xmlSrc = base64_decode($xmlSrc, true); 72 | if($xmlSrc === false) 73 | { 74 | echo 'No valid Assertion given: Invalid characters in Base64 string'.PHP_EOL; 75 | exit(3); 76 | } 77 | $xml = false; 78 | $xml = @gzuncompress($xmlSrc); 79 | if($xml === false) 80 | { 81 | echo 'No valid Assertion given: Uncompress failed'.PHP_EOL; 82 | exit(3); 83 | } 84 | 85 | // Load assertion XML in lightSAML 86 | try 87 | { 88 | $assDeserializer = new \LightSaml\Model\Context\DeserializationContext(); 89 | $assDeserializer->getDocument()->loadXML($xml); 90 | // Check if it's a response (for instance from mod_auth_mellon with MellonDumpResponse On), 91 | // otherwise, treat it as assertion 92 | if($assDeserializer->getDocument()->firstChild->localName === "Response") 93 | { 94 | $response = new \LightSaml\Model\Protocol\Response(); 95 | $response->deserialize($assDeserializer->getDocument()->firstChild, $assDeserializer); 96 | $assertion = $response->getFirstAssertion(); 97 | } 98 | else 99 | { 100 | $assertion = new \LightSaml\Model\Assertion\Assertion(); 101 | $assertion->deserialize($assDeserializer->getDocument()->firstChild, $assDeserializer); 102 | } 103 | } 104 | catch(\Exception $e) 105 | { 106 | echo 'An error occured while parsing the Assertion: '.$e->getMessage().PHP_EOL; 107 | exit(3); 108 | } 109 | 110 | // validate signature 111 | if(!empty($args['idp'])) 112 | { 113 | $certs = array(); 114 | 115 | // load all signing keys from given IdP metadata 116 | foreach($args['idp'] as $idpMetadataXml) 117 | { 118 | try 119 | { 120 | $entityDescriptor = \LightSaml\Model\Metadata\EntityDescriptor::load($idpMetadataXml); 121 | $entityId = $entityDescriptor->getEntityID(); 122 | $idpSsoDescriptor = $entityDescriptor->getFirstIdpSsoDescriptor(); 123 | if(isset($idpSsoDescriptor)) 124 | { 125 | $idpSigningKeyDescriptors = $idpSsoDescriptor->getAllKeyDescriptorsByUse(\LightSaml\Model\Metadata\KeyDescriptor::USE_SIGNING); 126 | if(!empty($idpSigningKeyDescriptors)) 127 | { 128 | $certs[$entityId] = array(); 129 | foreach($idpSigningKeyDescriptors as $keyDescriptor) 130 | { 131 | $certs[$entityId][] = $keyDescriptor->getCertificate(); 132 | } 133 | } 134 | } 135 | } 136 | catch(\Exception $e) 137 | { 138 | echo 'An error occured while loading IdP metadata.'.PHP_EOL; 139 | exit(4); 140 | } 141 | } 142 | 143 | // validate signature against given IdP certs 144 | try 145 | { 146 | $signature = $assertion->getSignature(); 147 | $issuer = $assertion->getIssuer(); 148 | $idpEntityId = $issuer->getValue(); 149 | if(isset($signature)) 150 | { 151 | if(isset($certs[$idpEntityId])) 152 | { 153 | $ok = false; 154 | foreach($certs[$idpEntityId] as $cert) 155 | { 156 | $pubKey = \LightSaml\Credential\KeyHelper::createPublicKey($cert); 157 | $ok = $signature->validate($pubKey); 158 | if($ok) break; 159 | } 160 | // no given cert did validate 161 | if(!$ok) 162 | { 163 | echo 'No corresponding certificate for "'.$idpEntityId.'" could validate the given signature.'.PHP_EOL; 164 | exit(6); 165 | } 166 | } 167 | else 168 | { 169 | echo 'No corresponding certificate for "'.$idpEntityId.'" was found in the IdP metadata.'.PHP_EOL; 170 | exit(5); 171 | } 172 | } 173 | else 174 | { 175 | // no signature given, just check if any of our IdPs is the issuer 176 | if(!isset($certs[$idpEntityId])) 177 | { 178 | echo '"'.$idpEntityId.'" was not found in the given IdPs.'.PHP_EOL; 179 | exit(6); 180 | } 181 | } 182 | } 183 | catch(\Exception $e) 184 | { 185 | echo 'An error occured while validating the Assertion signature.'.PHP_EOL; 186 | exit(4); 187 | } 188 | } 189 | 190 | // validate assertion 191 | try 192 | { 193 | $nameIdValidator = new \LightSaml\Validator\Model\NameId\NameIdValidator(); 194 | $assertionValidator = new \LightSaml\Validator\Model\Assertion\AssertionValidator( 195 | $nameIdValidator, 196 | new \LightSaml\Validator\Model\Subject\SubjectValidator($nameIdValidator), 197 | new \LightSaml\Validator\Model\Statement\StatementValidator() 198 | ); 199 | $assertionTimeValidator = new \LightSaml\Validator\Model\Assertion\AssertionTimeValidator(); 200 | 201 | $assertionValidator->validateAssertion($assertion); 202 | if((bool)$args['saml_check_timeframe']) 203 | { 204 | $assertionTimeValidator->validateTimeRestrictions( 205 | $assertion, 206 | time(), 207 | (isset($args['grace']) && (int)$args['grace'] != 0) ? (int)$args['grace'] : 600 208 | ); 209 | } 210 | } 211 | catch(\LightSaml\Error\LightSamlValidationException $e) 212 | { 213 | echo 'The Assertion could not be validated: '.$e->getMessage().PHP_EOL; 214 | exit(7); 215 | } 216 | catch(\Exception $e) 217 | { 218 | echo 'An error occured while validating the Assertion.'.PHP_EOL; 219 | exit(4); 220 | } 221 | 222 | // match trusted_sp 223 | if(!empty($args['trusted_sp'])) 224 | { 225 | try 226 | { 227 | $conditions = $assertion->getConditions(); 228 | if(isset($conditions)) 229 | { 230 | $audienceRestrictions = $conditions->getAllAudienceRestrictions(); 231 | if(!empty($audienceRestrictions)) 232 | { 233 | $ok = false; 234 | foreach($audienceRestrictions as $audienceRestriction) 235 | { 236 | foreach($args['trusted_sp'] as $spEntityId) 237 | { 238 | $ok = $audienceRestriction->hasAudience($spEntityId); 239 | if($ok) break 2; 240 | } 241 | } 242 | // trusted_sp's are not in the audience 243 | if(!$ok) 244 | { 245 | echo 'No trusted_sp could be found in the given Assertion.'.PHP_EOL; 246 | exit(6); 247 | } 248 | } 249 | } 250 | } 251 | catch(\Exception $e) 252 | { 253 | echo 'An error occured while checking the audience of the Assertion.'.PHP_EOL; 254 | exit(4); 255 | } 256 | } 257 | 258 | // match attributes 259 | try 260 | { 261 | $attributeStatements = $assertion->getAllAttributeStatements(); 262 | if(isset($attributeStatements)) 263 | { 264 | $ok = false; 265 | foreach($attributeStatements as $attributeStatement) 266 | { 267 | $attributes = $attributeStatement->getAllAttributes(); 268 | foreach($attributes as $attribute) 269 | { 270 | if($attribute->getName() === $args['userid'] || $attribute->getFriendlyName() === $args['userid']) 271 | { 272 | $ok = in_array($pamUser, $attribute->getAllAttributeValues()); 273 | if($ok) break 2; 274 | } 275 | } 276 | } 277 | // there was no attribute with the name of userid or it didn't match 278 | if(!$ok) 279 | { 280 | echo 'Assertion did not contain "'.$args['userid'].'" attribute or it did not match with the PAM username.'.PHP_EOL; 281 | exit(6); 282 | } 283 | } 284 | else 285 | { 286 | echo 'Assertion contained no attributes. We need at least "'.$args['userid'].'".'.PHP_EOL; 287 | exit(6); 288 | } 289 | } 290 | catch(\Exception $e) 291 | { 292 | echo 'An error occured while checking the userid attribute of the Assertion.'.PHP_EOL; 293 | exit(4); 294 | } 295 | 296 | exit(0); 297 | -------------------------------------------------------------------------------- /pam_script_auth: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # set path for verbose logging 4 | LOGFILE= 5 | #LOGFILE=/tmp/pam-script-saml.log 6 | 7 | DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 8 | OUTPUT=$(/usr/bin/env php -c "$DIR/php.ini" -f "$DIR/pam-script-saml.php" "$@") 9 | RETCODE=$? 10 | 11 | if [[ -n "$LOGFILE" ]]; then 12 | echo "[$(/usr/bin/env date -R)] $OUTPUT" >> $LOGFILE 13 | fi 14 | 15 | exit $RETCODE 16 | -------------------------------------------------------------------------------- /php.ini: -------------------------------------------------------------------------------- 1 | [PHP] 2 | engine = On 3 | short_open_tag = Off 4 | zend.enable_gc = Off 5 | memory_limit = 4M 6 | error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT 7 | display_errors = On 8 | display_startup_errors = On 9 | log_errors = On 10 | register_argc_argv = On 11 | enable_post_data_reading = Off 12 | default_mimetype = "" 13 | enable_dl = Off 14 | file_uploads = Off 15 | 16 | [Date] 17 | date.timezone = "Europe/Berlin" 18 | -------------------------------------------------------------------------------- /test.local.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 3 | 4 | for PHP_PATH in /Applications/MAMP/bin/php/php*/bin; do 5 | echo "********************************************************************************" 6 | echo "PHP: ${PHP_PATH}" 7 | PATH="${PHP_PATH}:${PATH}" "${DIR}/test.sh" 8 | done 9 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 3 | 4 | echo "================================================================================" 5 | if [[ -f "${DIR}/test.env" ]]; then 6 | echo "Loading test environment data from: ${DIR}/test.env" 7 | . "${DIR}/test.env" 8 | fi 9 | 10 | echo "Checking test environment data..." 11 | if [[ -z "$ITERATIONS" || -z "$IDP_METADATA" || -z "$TRUSTED_SP" || 12 | -z "$PAM_AUTHTOK" || -z "$PAM_RHOST" || -z "$PAM_TYPE" || 13 | -z "$PAM_USER" ]]; then 14 | echo "Failed!" 15 | exit 2 16 | fi 17 | echo "Succeeded." 18 | 19 | IDP_METADATA_FILE=$(mktemp) 20 | echo "$IDP_METADATA" | tr -d '\r' > "${IDP_METADATA_FILE}" 21 | 22 | echo "--------------------------------------------------------------------------------" 23 | php -v 24 | echo "--------------------------------------------------------------------------------" 25 | RC=0 26 | START=$(date +%s) 27 | 28 | for ((i=1; i<=ITERATIONS; i++)); do 29 | php -c "${DIR}/php.ini" \ 30 | -f "${DIR}/pam-script-saml.php" \ 31 | userid=mail \ 32 | idp="${IDP_METADATA_FILE}" \ 33 | trusted_sp="${TRUSTED_SP}" \ 34 | grace=2147483647 \ 35 | only_from=127.0.0.1,::1 36 | RC=$? 37 | if [[ $RC -ne 0 ]]; then 38 | echo "An error occured in the test, aborting." 39 | break 40 | fi 41 | done 42 | 43 | END=$(date +%s) 44 | if [[ $RC -eq 0 ]]; then 45 | echo "Duration for $ITERATIONS iterations: $(( END-START ))s" 46 | fi 47 | echo "================================================================================" 48 | 49 | rm -f "${IDP_METADATA_FILE}" 50 | 51 | exit $RC 52 | --------------------------------------------------------------------------------