Help Needed! JWT with LDAP 401 `Invalid credentials` error
simondeeley opened this issue · 7 comments
Folks, I'm really stumped by this one! I'm trying to implement JWT on an application that uses an LDAP server as the "source of truth" for its users. The LDAP part is non-negotiable as it's how the company operate. The use of the JWT will allow users to remain logged in for periods of an hour (the default TTL for the token) before the application pings the LDAP server to refresh the user details and token. As you can imagine, the goal here is to reduce the network demand on pinging a database etc unnecessarily but also strike a careful balance between security in checking who's accessing what and when.
In short, I've followed every JWT / LDAP / insert-other-protocol-here-demo that I can find on the internet but I'm always his with 401: Invalid credentials.
error from the application when trying to authenticate a user.
The odd thing is, it does work. Or at least, running the following command in the console produces a valid JWT token.
docker compose exec php bin/console lexik:jwt:generate-token "billy" --user-class="Symfony\Component\Ldap\Security\LdapUser"
If I change the username to something not recognised, I get an appropriate 401: Username could not be found.
message back so I know the application is hitting the LDAP server to retrieve the correct details but whenever I use curl
or the built-in Swagger UI playgrounds to send HTTP requests, it fails each and every time with correct details being sent in the request, returning a 401: Invalid credentials error.
I've fiddled with the security.yaml
settings and pretty much had every combination going at some point. All valid configs fail with the same 401: Invalid credentials.
error. Help!
Here's my setup. This is a fresh install in a clean repo too, just to make sure I wasn't breaking anything by installing other packages.
composer.json
:
{
"name": "symfony/skeleton",
"type": "project",
"license": "MIT",
"description": "A minimal Symfony project recommended to create bare bones applications",
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"php": ">=8.1.13",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^3.0",
"doctrine/annotations": "^1.0",
"doctrine/doctrine-bundle": "^2.7",
"doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^2.13",
"lexik/jwt-authentication-bundle": "^2.16",
"nelmio/cors-bundle": "^2.2",
"phpdocumentor/reflection-docblock": "^5.3",
"phpstan/phpdoc-parser": "^1.14",
"symfony/asset": "6.2.*",
"symfony/console": "6.2.*",
"symfony/dotenv": "6.2.*",
"symfony/expression-language": "6.2.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "6.2.*",
"symfony/http-client": "6.2.*",
"symfony/ldap": "6.2.*",
"symfony/property-access": "6.2.*",
"symfony/property-info": "6.2.*",
"symfony/runtime": "6.2.*",
"symfony/security-bundle": "6.2.*",
"symfony/serializer": "6.2.*",
"symfony/twig-bundle": "6.2.*",
"symfony/validator": "6.2.*",
"symfony/yaml": "6.2.*"
},
"config": {
"allow-plugins": {
"symfony/flex": true,
"symfony/runtime": true
},
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "6.2.*",
"docker": true
}
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "6.2.*",
"symfony/css-selector": "6.2.*",
"symfony/phpunit-bridge": "^6.2"
}
}
services.yaml
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
Symfony\Component\Ldap\Ldap:
arguments: ['@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter']
tags:
- ldap
Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
arguments:
- host: '%env(resolve:LDAP_HOST)%'
port: '%env(resolve:LDAP_PORT)%'
encryption: '%env(resolve:LDAP_ENCRYPTION)%'
options:
protocol_version: 3
referrals: false
App\JwtDecorator:
decorates: 'api_platform.openapi.factory'
arguments: ['@.inner']
security.yaml
:
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
#Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
Symfony\Component\Ldap\Security\LdapUser: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
hide_user_not_found: false
providers:
jwt:
lexik_jwt: ~
users_in_memory:
memory:
users:
test_user: { password: '$3cr3t', roles: ['ROLE_USER', 'ROLE_TEST_USER'] }
test_admin: { password: '$3cr3t', roles: ['ROLE_ADMIN', 'ROLE_TEST_ADMIN'] }
users_ldap:
ldap:
service: Symfony\Component\Ldap\Ldap
base_dn: '%env(resolve:LDAP_BASE_DN)%'
search_dn: '%env(resolve:LDAP_SEARCH_DN)%'
search_password: '%env(resolve:LDAP_SEARCH_PASSWORD)%'
default_roles: ROLE_USER
uid_key: uid
#extra_fields: ['memberOf']
users:
chain:
providers: ['users_ldap', 'users_in_memory']
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api/
stateless: true
provider: jwt
jwt: ~
main:
json_login_ldap:
service: Symfony\Component\Ldap\Ldap
dn_string: '%env(resolve:LDAP_BASE_DN)%'
query_string: 'uid={user_identifier}'
search_dn: '%env(resolve:LDAP_SEARCH_DN)%'
search_password: '%env(resolve:LDAP_SEARCH_PASSWORD)%'
require_previous_session: false
check_path: auth
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
provider: users
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/$, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI
- { path: ^/api, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI docs
- { path: ^/auth/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Ldap\Security\LdapUser:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
lexik_jwt_authentication.yaml
:
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
routes.yaml
:
auth:
path: /auth/login
methods: ['POST']
For the LDAP server, I spun up a Docker container following the instructions here: https://github.com/osixia/docker-openldap
docker run -p 389:389 -p 636:636 --name my-openldap-container --detach osixia/openldap:1.5.0
The user "billy" is from their sample data, if you follow along with the examples on the README page.
If anyone can spot any glaring errors (I might have missed something obvious!) or if you have encountered similar issues I'd love to hear how you resolved them - I've been stuck on this for the past three days now and just can't get anywhere with it!
For info, the code is ran in a Docker container running on Apple Silicone (although I can't see this making any difference!) and as mentioned above, this is a clean repo with just the bare minimum to get the code working - or not! -
- Dunglas/symfony-skeleton
- API Platfom
- JWT Web Token Bundle
- LDAP PHP extension
- LDAP Symfony bundle
- PHPUnit for tests
Can you push a github repository that I can checkout to reproduce?
I've added a repo here: https://github.com/simondeeley/api-test. I've separated the code into a "feature/jwt" branch so you can compare the base/default repo before any JWT/LDAP etc code was added. Hope it helps.
Thanks, I'll have a look asap.
Any luck on getting a chance to see if you could figure out what was going on with this? I'm still no further forward :(
FYI - There's a thread on Stack Exchange reporting the same issues... https://stackoverflow.com/questions/69988971/symfony-5-3-ldap-new-authentication-is-always-not-valid-credentials