├── .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 |
--------------------------------------------------------------------------------