lexik/LexikJWTAuthenticationBundle

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 :(