diff --git a/.github/workflows/deptrac.yml b/.github/workflows/deptrac.yml index b8d7bcfba..ebaf4df1e 100644 --- a/.github/workflows/deptrac.yml +++ b/.github/workflows/deptrac.yml @@ -19,54 +19,5 @@ on: - '.github/workflows/deptrac.yml' jobs: - build: - name: Dependency Tracing - runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.1' - tools: phive - extensions: intl, json, mbstring, xml - coverage: none - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - - - name: Cache composer dependencies - uses: actions/cache@v3 - with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Create Deptrac cache directory - run: mkdir -p build/ - - - name: Cache Deptrac results - uses: actions/cache@v3 - with: - path: build - key: ${{ runner.os }}-deptrac-${{ github.sha }} - restore-keys: ${{ runner.os }}-deptrac- - - - name: Install dependencies - run: | - if [ -f composer.lock ]; then - composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader - else - composer update --no-progress --no-interaction --prefer-dist --optimize-autoloader - fi - - - name: Trace dependencies - run: | - sudo phive --no-progress install --global --trust-gpg-keys B8F640134AB1782E,A98E898BB53EB748 qossmic/deptrac - deptrac analyze --cache-file=build/deptrac.cache + deptrac: + uses: codeigniter4/.github/.github/workflows/deptrac.yml@main diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 61269b119..e6e623ce8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -9,9 +9,11 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: 3.x - - run: pip install mkdocs-material + - run: pip3 install mkdocs-material + - run: pip3 install mkdocs-git-revision-date-localized-plugin + - run: pip3 install mkdocs-redirects - run: mkdocs gh-deploy --force diff --git a/.github/workflows/no-merge-commits.yml b/.github/workflows/no-merge-commits.yml new file mode 100644 index 000000000..11173fbb4 --- /dev/null +++ b/.github/workflows/no-merge-commits.yml @@ -0,0 +1,22 @@ +name: Detect Merge Commits + +on: + pull_request: + +permissions: + contents: read + pull-requests: read + +jobs: + test: + name: Check for merge commits + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run test + uses: NexusPHP/no-merge-commits@v2.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/phpcpd.yml b/.github/workflows/phpcpd.yml index ecd45d1c6..2704b80fc 100644 --- a/.github/workflows/phpcpd.yml +++ b/.github/workflows/phpcpd.yml @@ -15,22 +15,8 @@ on: - '.github/workflows/phpcpd.yml' jobs: - build: - name: Code Copy-Paste Detection - runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.0' - tools: phpcpd - extensions: dom, mbstring - coverage: none - - - name: Detect duplicate code - run: phpcpd src/ tests/ --exclude src/Database/Migrations/2020-12-28-223112_create_auth_tables.php + phpcpd: + uses: codeigniter4/.github/.github/workflows/phpcpd.yml@main + with: + dirs: "src/ tests/" + options: "--exclude src/Database/Migrations/2020-12-28-223112_create_auth_tables.php --exclude src/Authentication/Authenticators/HmacSha256.php --exclude tests/Authentication/Authenticators/AccessTokenAuthenticatorTest.php" diff --git a/.github/workflows/phpcsfixer.yml b/.github/workflows/phpcsfixer.yml index cbf5566a8..ee1221ac2 100644 --- a/.github/workflows/phpcsfixer.yml +++ b/.github/workflows/phpcsfixer.yml @@ -15,45 +15,5 @@ on: - '.github/workflows/phpcsfixer.yml' jobs: - build: - name: PHP ${{ matrix.php-versions }} Coding Standards - runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[ci skip]')" - strategy: - fail-fast: false - matrix: - php-versions: ['7.4', '8.0', '8.1'] - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - extensions: json, tokenizer - coverage: none - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - - - name: Cache composer dependencies - uses: actions/cache@v3 - with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Install dependencies - run: | - if [ -f composer.lock ]; then - composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader - else - composer update --no-progress --no-interaction --prefer-dist --optimize-autoloader - fi - - - name: Check code for standards compliance - run: vendor/bin/php-cs-fixer fix --verbose --ansi --dry-run --using-cache=no --diff + phpcsfixer: + uses: codeigniter4/.github/.github/workflows/phpcsfixer.yml@main diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 9e0f6339a..58e2add51 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -19,56 +19,5 @@ on: - '.github/workflows/phpstan.yml' jobs: - build: - name: PHP ${{ matrix.php-versions }} Static Analysis - runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[ci skip]')" - strategy: - fail-fast: false - matrix: - php-versions: ['7.4', '8.0', '8.1'] - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - tools: phpstan, phpunit - extensions: intl, json, mbstring, xml - coverage: none - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - - - name: Cache composer dependencies - uses: actions/cache@v3 - with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Create PHPStan cache directory - run: mkdir -p build/phpstan - - - name: Cache PHPStan results - uses: actions/cache@v3 - with: - path: build/phpstan - key: ${{ runner.os }}-phpstan-${{ github.sha }} - restore-keys: ${{ runner.os }}-phpstan- - - - name: Install dependencies - run: | - if [ -f composer.lock ]; then - composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader - else - composer update --no-progress --no-interaction --prefer-dist --optimize-autoloader - fi - - - name: Run static analysis - run: vendor/bin/phpstan analyze + phpstan: + uses: codeigniter4/.github/.github/workflows/phpstan.yml@main diff --git a/.github/workflows/phpunit-lang.yml b/.github/workflows/phpunit-lang.yml index f8f1db140..dbd7f5a2b 100644 --- a/.github/workflows/phpunit-lang.yml +++ b/.github/workflows/phpunit-lang.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/phpunit-lowest.yml b/.github/workflows/phpunit-lowest.yml new file mode 100644 index 000000000..242a89803 --- /dev/null +++ b/.github/workflows/phpunit-lowest.yml @@ -0,0 +1,23 @@ +name: PHPUnit Lowest + +on: + pull_request: + branches: + - develop + paths: + - '**.php' + - 'composer.*' + - 'phpunit*' + - '.github/workflows/phpunit-lowest.yml' + push: + branches: + - develop + paths: + - '**.php' + - 'composer.*' + - 'phpunit*' + - '.github/workflows/phpunit-lowest.yml' + +jobs: + phpunit: + uses: codeigniter4/.github/.github/workflows/phpunit-lowest.yml@main diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index f88c90578..9bcd5ec40 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -19,167 +19,5 @@ on: - '.github/workflows/phpunit.yml' jobs: - main: - name: PHP ${{ matrix.php-versions }} - ${{ matrix.db-platforms }} - ${{ matrix.dependencies }} - runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[ci skip]')" - strategy: - matrix: - php-versions: ['7.4', '8.0', '8.1', '8.2'] - db-platforms: ['MySQLi', 'SQLite3'] - mysql-versions: ['5.7'] - dependencies: ['highest'] - include: - # MySQL 8.0 - - php-versions: '7.4' - db-platforms: MySQLi - mysql-versions: '8.0' - dependencies: 'highest' - # Lowest Dependency - - php-versions: '7.4' - db-platforms: MySQLi - mysql-versions: '5.7' - dependencies: 'lowest' - # Postgre - - php-versions: '7.4' - db-platforms: Postgre - mysql-versions: '5.7' - dependencies: 'highest' - # SQLSRV - - php-versions: '7.4' - db-platforms: SQLSRV - mysql-versions: '5.7' - dependencies: 'highest' - # OCI8 - - php-versions: '7.4' - db-platforms: OCI8 - mysql-versions: '5.7' - dependencies: 'highest' - - services: - mysql: - image: mysql:${{ matrix.mysql-versions }} - env: - MYSQL_ALLOW_EMPTY_PASSWORD: yes - MYSQL_DATABASE: test - ports: - - 3306:3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 - - postgres: - image: postgres - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: test - ports: - - 5432:5432 - options: --health-cmd=pg_isready --health-interval=10s --health-timeout=5s --health-retries=3 - - mssql: - image: mcr.microsoft.com/mssql/server:2019-CU10-ubuntu-20.04 - env: - SA_PASSWORD: 1Secure*Password1 - ACCEPT_EULA: Y - MSSQL_PID: Developer - ports: - - 1433:1433 - options: --health-cmd="/opt/mssql-tools/bin/sqlcmd -S 127.0.0.1 -U sa -P 1Secure*Password1 -Q 'SELECT @@VERSION'" --health-interval=10s --health-timeout=5s --health-retries=3 - - oracle: - image: quillbuilduser/oracle-18-xe - env: - ORACLE_ALLOW_REMOTE: true - ports: - - 1521:1521 - options: --health-cmd="/opt/oracle/product/18c/dbhomeXE/bin/sqlplus -s sys/Oracle18@oracledbxe/XE as sysdba <<< 'SELECT 1 FROM DUAL'" --health-interval=10s --health-timeout=5s --health-retries=3 - - steps: - - name: Create database for MSSQL Server - if: matrix.db-platforms == 'SQLSRV' - run: sqlcmd -S 127.0.0.1 -U sa -P 1Secure*Password1 -Q "CREATE DATABASE test" - - - name: Install Oracle InstantClient - if: matrix.db-platforms == 'OCI8' - run: | - sudo apt-get install wget libaio1 alien - sudo wget https://download.oracle.com/otn_software/linux/instantclient/185000/oracle-instantclient18.5-basic-18.5.0.0.0-3.x86_64.rpm - sudo wget https://download.oracle.com/otn_software/linux/instantclient/185000/oracle-instantclient18.5-devel-18.5.0.0.0-3.x86_64.rpm - sudo wget https://download.oracle.com/otn_software/linux/instantclient/185000/oracle-instantclient18.5-sqlplus-18.5.0.0.0-3.x86_64.rpm - sudo alien oracle-instantclient18.5-basic-18.5.0.0.0-3.x86_64.rpm - sudo alien oracle-instantclient18.5-devel-18.5.0.0.0-3.x86_64.rpm - sudo alien oracle-instantclient18.5-sqlplus-18.5.0.0.0-3.x86_64.rpm - sudo dpkg -i oracle-instantclient18.5-basic_18.5.0.0.0-4_amd64.deb oracle-instantclient18.5-devel_18.5.0.0.0-4_amd64.deb oracle-instantclient18.5-sqlplus_18.5.0.0.0-4_amd64.deb - echo "LD_LIBRARY_PATH=/lib/oracle/18.5/client64/lib/" >> $GITHUB_ENV - echo "NLS_LANG=AMERICAN_AMERICA.UTF8" >> $GITHUB_ENV - echo "C_INCLUDE_PATH=/usr/include/oracle/18.5/client64" >> $GITHUB_ENV - echo 'NLS_DATE_FORMAT=YYYY-MM-DD HH24:MI:SS' >> $GITHUB_ENV - echo 'NLS_TIMESTAMP_FORMAT=YYYY-MM-DD HH24:MI:SS' >> $GITHUB_ENV - echo 'NLS_TIMESTAMP_TZ_FORMAT=YYYY-MM-DD HH24:MI:SS' >> $GITHUB_ENV - - - name: Create database for Oracle Database - if: matrix.db-platforms == 'OCI8' - run: echo -e "ALTER SESSION SET CONTAINER = XEPDB1;\nCREATE BIGFILE TABLESPACE \"TEST\" DATAFILE '/opt/oracle/product/18c/dbhomeXE/dbs/TEST' SIZE 10M AUTOEXTEND ON MAXSIZE UNLIMITED SEGMENT SPACE MANAGEMENT AUTO EXTENT MANAGEMENT LOCAL AUTOALLOCATE;\nCREATE USER \"ORACLE\" IDENTIFIED BY \"ORACLE\" DEFAULT TABLESPACE \"TEST\" TEMPORARY TABLESPACE TEMP QUOTA UNLIMITED ON \"TEST\";\nGRANT CONNECT,RESOURCE TO \"ORACLE\";\nexit;" | /lib/oracle/18.5/client64/bin/sqlplus -s sys/Oracle18@localhost:1521/XE as sysdba - - - name: Checkout - uses: actions/checkout@v3 - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - tools: composer, phive, phpunit - extensions: intl, json, mbstring, gd, xdebug, xml, sqlite3, sqlsrv, oci8, pgsql - coverage: xdebug - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - - - name: Cache composer dependencies - uses: actions/cache@v3 - with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Install dependencies - run: | - if [ -f composer.lock ]; then - composer install ${{ env.COMPOSER_UPDATE_FLAGS }} --no-progress --no-interaction --prefer-dist --optimize-autoloader - else - composer update ${{ env.COMPOSER_UPDATE_FLAGS }} --no-progress --no-interaction --prefer-dist --optimize-autoloader - fi - env: - COMPOSER_UPDATE_FLAGS: ${{ matrix.dependencies == 'lowest' && '--prefer-lowest' || '' }} - - - name: Test with PHPUnit - run: vendor/bin/phpunit --verbose --coverage-text --testsuite main - env: - DB: ${{ matrix.db-platforms }} - TERM: xterm-256color - TACHYCARDIA_MONITOR_GA: enabled - - - if: matrix.php-versions == '8.0' - name: Run Coveralls - continue-on-error: true - run: | - sudo phive --no-progress install --global --trust-gpg-keys E82B2FB314E9906E php-coveralls - php-coveralls --verbose --coverage_clover=build/phpunit/clover.xml --json_path build/phpunit/coveralls-upload.json - env: - COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COVERALLS_PARALLEL: true - COVERALLS_FLAG_NAME: PHP ${{ matrix.php-versions }} - ${{ matrix.db-platforms }} - - coveralls: - needs: [main] - name: Coveralls Finished - runs-on: ubuntu-latest - steps: - - name: Upload Coveralls results - uses: coverallsapp/github-action@master - continue-on-error: true - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - parallel-finished: true + phpunit: + uses: codeigniter4/.github/.github/workflows/phpunit.yml@main diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index 67be11c56..53c76e7f9 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -19,52 +19,5 @@ on: - '.github/workflows/psalm.yml' jobs: - build: - name: Psalm Analysis - runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.0' - tools: phpstan, phpunit - extensions: intl, json, mbstring, xml - coverage: none - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - - - name: Cache composer dependencies - uses: actions/cache@v3 - with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Create Psalm cache directory - run: mkdir -p build/psalm - - - name: Cache Psalm results - uses: actions/cache@v3 - with: - path: build/psalm - key: ${{ runner.os }}-psalm-${{ github.sha }} - restore-keys: ${{ runner.os }}-psalm- - - - name: Install dependencies - run: | - if [ -f composer.lock ]; then - composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader - else - composer update --no-progress --no-interaction --prefer-dist --optimize-autoloader - fi - - - name: Run Psalm analysis - run: vendor/bin/psalm + psalm: + uses: codeigniter4/.github/.github/workflows/psalm.yml@main diff --git a/.github/workflows/rector.yml b/.github/workflows/rector.yml index 0abf2719b..8c19b162d 100644 --- a/.github/workflows/rector.yml +++ b/.github/workflows/rector.yml @@ -19,48 +19,5 @@ on: - '.github/workflows/rector.yml' jobs: - build: - name: PHP ${{ matrix.php-versions }} Rector Analysis - runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[ci skip]')" - strategy: - fail-fast: false - matrix: - php-versions: ['7.4', '8.0', '8.1'] - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - tools: phpstan - extensions: intl, json, mbstring, xml - coverage: none - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - - - name: Cache composer dependencies - uses: actions/cache@v3 - with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Install dependencies - run: | - if [ -f composer.lock ]; then - composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader - else - composer update --no-progress --no-interaction --prefer-dist --optimize-autoloader - fi - - - name: Analyze for refactoring - run: | - composer global require --dev rector/rector:^0.15.1 - rector process --dry-run --no-progress-bar + rector: + uses: codeigniter4/.github/.github/workflows/rector.yml@main diff --git a/.github/workflows/smart-commenting.yml b/.github/workflows/smart-commenting.yml new file mode 100644 index 000000000..a8d8690a7 --- /dev/null +++ b/.github/workflows/smart-commenting.yml @@ -0,0 +1,62 @@ +name: Smart Commenting +on: + pull_request: + types: + - labeled +jobs: + + add-comment-for-GPG-Signing: + if: github.event.label.name == 'GPG-Signing needed' + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Add comment for GPG-sign + uses: peter-evans/create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + You must GPG-sign your work, certifying that you either wrote the work or otherwise have the right to pass it on to an open-source project. See Developer's Certificate of Origin. + See [signing][1]. + + **Note that all your commits must be signed.** If you have an unsigned commit, you can sign the previous commits by referring to [gpg-signing-old-commits][2]. + + [1]: https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#signing + [2]: https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/workflow.md#gpg-signing-old-commits + + add-comment-for-tests: + if: github.event.label.name == 'tests needed' + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Add comment for PHPUnit test + uses: peter-evans/create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + **Unit Testing:** + Unit testing is expected for all CodeIgniter components. We use PHPUnit, and run unit tests using GitHub Actions for each PR submitted or changed. + + **So please write a unit test or update the existing tests.** + + See [unit testing][1] for more info. + + [1]: https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md#unit-testing + + add-comment-for-conflict: + if: github.event.label.name == 'stale' + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Add comment for resolving a merge conflict + uses: peter-evans/create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + We detected conflicts in your PR against the base branch :speak_no_evil: + You may want to sync :arrows_counterclockwise: your branch with upstream! + See [resolving a merge conflict using the Git][1] for more info. + + [1]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/addressing-merge-conflicts/resolving-a-merge-conflict-using-the-command-line diff --git a/.github/workflows/unused.yml b/.github/workflows/unused.yml index baec23af2..1758dda62 100644 --- a/.github/workflows/unused.yml +++ b/.github/workflows/unused.yml @@ -17,42 +17,5 @@ on: - '.github/workflows/unused.yml' jobs: - build: - name: Unused Package Detection - runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[ci skip]')" - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.0' - tools: composer, composer-unused - extensions: intl, json, mbstring, xml - coverage: none - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get composer cache directory - run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV - - - name: Cache composer dependencies - uses: actions/cache@v3 - with: - path: ${{ env.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Install dependencies - run: | - if [ -f composer.lock ]; then - composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader - else - composer update --no-progress --no-interaction --prefer-dist --optimize-autoloader - fi - - - name: Detect unused packages - run: composer-unused -vvv --output-format=github --ansi --no-interaction --no-progress + unused: + uses: codeigniter4/.github/.github/workflows/unused.yml@main diff --git a/LICENSE b/LICENSE index 8a8446d16..422da62e7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ The MIT License (MIT) -Copyright (c) 2021 Lonnie Ezell +Copyright (c) 2020-2022 Lonnie Ezell +Copyright (c) 2022-2023 CodeIgniter Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 73b4f360c..8df491a39 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,14 @@ [![Unit Tests](https://github.com/codeigniter4/shield/workflows/PHPUnit/badge.svg)](https://github.com/codeigniter4/shield/actions/workflows/phpunit.yml) [![Static Analysis](https://github.com/codeigniter4/shield/workflows/PHPStan/badge.svg)](https://github.com/codeigniter4/shield/actions/workflows/phpstan.yml) +[![PHP-CS-Fixer](https://github.com/codeigniter4/shield/actions/workflows/phpcsfixer.yml/badge.svg)](https://github.com/codeigniter4/shield/actions/workflows/phpcsfixer.yml) +[![Rector](https://github.com/codeigniter4/shield/actions/workflows/rector.yml/badge.svg)](https://github.com/codeigniter4/shield/actions/workflows/rector.yml) +[![Psalm](https://github.com/codeigniter4/shield/actions/workflows/psalm.yml/badge.svg)](https://github.com/codeigniter4/shield/actions/workflows/psalm.yml) [![Architecture](https://github.com/codeigniter4/shield/workflows/Deptrac/badge.svg)](https://github.com/codeigniter4/shield/actions/workflows/deptrac.yml) [![Coverage Status](https://coveralls.io/repos/github/codeigniter4/shield/badge.svg?branch=develop)](https://coveralls.io/github/codeigniter4/shield?branch=develop) -Shield is an authentication and authorization framework for CodeIgniter 4. While it does provide a base set of tools +Shield is the official authentication and authorization framework for CodeIgniter 4. +While it does provide a base set of tools that are commonly used in websites, it is designed to be flexible and easily customizable. The primary goals for Shield are: @@ -15,38 +19,44 @@ The primary goals for Shield are: ## Authentication Methods -Shield provides two primary methods **Session-based** and **Personal Access Codes** -of authentication out of the box. +Shield provides two primary methods **Session-based** and **Access Token** +authentication out of the box. -It also provides **JSON Web Tokens** authentication. +It also provides **HMAC SHA256 Token** and **JSON Web Token** authentication. ### Session-based -This is your typical email/username/password system you see everywhere. It includes a secure "remember me" functionality. +This is your typical email/username/password system you see everywhere. It includes a secure "remember-me" functionality. This can be used for standard web applications, as well as for single page applications. Includes full controllers and basic views for all standard functionality, like registration, login, forgot password, etc. -### Personal Access Codes +### Access Token -These are much like the access codes that GitHub uses, where they are unique to a single user, and a single user +These are much like the access tokens that GitHub uses, where they are unique to a single user, and a single user can have more than one. This can be used for API authentication of third-party users, and even for allowing access for a mobile application that you build. -### JSON Web Tokens +### HMAC SHA256 Token + +This is a slightly more complicated improvement on Access Token authentication. +The main advantage with HMAC is the shared Secret Key +is not passed in the request, but is instead used to create a hash signature of the request body. + +### JSON Web Token JWT or JSON Web Token is a compact and self-contained way of securely transmitting information between parties as a JSON object. It is commonly used for authentication and authorization purposes in web applications. -## Some Important Features +## Important Features -* Session-based authentication (traditional email/password with remember me) +* Session-based authentication (traditional ID/Password with Remember-me) * Stateless authentication using Personal Access Tokens * Optional Email verification on account registration -* Optional Email-based Two Factor Authentication after login -* Magic Login Links when a user forgets their password -* Flexible groups-based access control (think roles, but more flexible) -* Users can be granted additional permissions +* Optional Email-based Two-Factor Authentication after login +* Magic Link Login when a user forgets their password +* Flexible Groups-based access control (think Roles, but more flexible) +* Users can be granted additional Permissions See the [An Official Auth Library](https://codeigniter.com/news/shield) for more Info. @@ -56,13 +66,14 @@ See the [An Official Auth Library](https://codeigniter.com/news/shield) for more Usage of Shield requires the following: -- A [CodeIgniter 4.2.7+](https://github.com/codeigniter4/CodeIgniter4/) based project +- A [CodeIgniter 4.3.5+](https://github.com/codeigniter4/CodeIgniter4/) based project - [Composer](https://getcomposer.org/) for package management - PHP 7.4.3+ ### Installation Installation is done through Composer. + ```console composer require codeigniter4/shield ``` @@ -73,14 +84,15 @@ See the docs diff --git a/UPGRADING.md b/UPGRADING.md index 1ffe19509..1a6bcd5cd 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,5 +1,34 @@ # Upgrade Guide +## Version 1.0.0-beta.6 to 1.0.0-beta.7 + +### The minimum CodeIgniter version + +Shield requires CodeIgniter 4.3.5 or later. +Versions prior to 4.3.5 have known vulnerabilities. +See https://github.com/codeigniter4/CodeIgniter4/security/advisories + +### Mandatory Config Changes + +#### New Config\AuthToken + +A new Config file **AuthToken.php** has been introduced. Run `php spark shield:setup` +again to install it into **app/Config/**, or install it manually. + +Then change the default settings as necessary. When using Token authentication, +the default value has been changed from all accesses to be recorded in the +``token_logins`` table to only accesses that fail authentication to be recorded. + +#### Config\Auth + +The following items have been moved. They are no longer used and should be removed. + +- `$authenticatorHeader` and `$unusedTokenLifetime` are moved to `Config\AuthToken`. + +The following items have been added. Copy the properties in **src/Config/Auth.php**. + +- `$usernameValidationRules` and `$emailValidationRules` are added. + ## Version 1.0.0-beta.3 to 1.0.0-beta.4 ### Important Password Changes diff --git a/admin/RELEASE.md b/admin/RELEASE.md index 27f73b24a..69cb216b2 100644 --- a/admin/RELEASE.md +++ b/admin/RELEASE.md @@ -71,7 +71,7 @@ the changelog. * Watch for the "docs" action and verify that the user guide updated: * [docs](https://github.com/codeigniter4/shield/actions/workflows/docs.yml) * Fast-forward `develop` branch to catch the merge commit from `master` - (note: pushing to develop is restricted to administrators): + (note: pushing to `develop` is restricted to administrators): ```console git fetch origin git checkout develop @@ -79,6 +79,9 @@ the changelog. git merge origin/master git push origin HEAD # Only administrators can push to the protected branch. ``` + **At this point, `master` must be merged into `develop`.** Otherwise, the + GitHub-generated release note from `develop` for the next release will not be + generated correctly. * Publish any Security Advisories that were resolved from private forks (note: publishing is restricted to administrators) * Announce the release on the forums and Slack channel diff --git a/admin/how_to_build_docs.md b/admin/how_to_build_docs.md new file mode 100644 index 000000000..9162fd73b --- /dev/null +++ b/admin/how_to_build_docs.md @@ -0,0 +1,39 @@ +# How to Build Shield Documentation + +We use Material for MkDocs for our documentation. +See https://squidfunk.github.io/mkdocs-material/getting-started/. + +## Requirements + +- Python3 +- pip + +## Installation + +```console +pip3 install mkdocs +pip3 install mkdocs-material +pip3 install mkdocs-git-revision-date-localized-plugin +pip3 install mkdocs-redirects +``` + +## Build the Docs + +```consolse +mkdocs build +``` + +## See the Docs + +```consolse +mkdocs serve +``` + +## Deploy Manually + +The documentation is built and deployed automatically by GitHub Actions. But if +you need to deploy manually, run the following command: + +```console +mkdocs gh-deploy +``` diff --git a/admin/pre-commit b/admin/pre-commit index 296e140e2..617482977 100644 --- a/admin/pre-commit +++ b/admin/pre-commit @@ -19,21 +19,6 @@ if [ "$STAGED_PHP_FILES" != "" ]; then done fi -if [ "$FILES" != "" ]; then - echo "Running PHPStan..." - - if [ -d /proc/cygdrive ]; then - ./vendor/bin/phpstan analyse - else - php ./vendor/bin/phpstan analyse - fi - - if [ $? != 0 ]; then - echo "Fix the phpstan error(s) before commit." - exit 1 - fi -fi - if [ "$FILES" != "" ]; then echo "Running PHP CS Fixer..." diff --git a/composer.json b/composer.json index 42ee9f2d5..d7a4327bd 100644 --- a/composer.json +++ b/composer.json @@ -17,16 +17,27 @@ } ], "homepage": "https://github.com/codeigniter4/shield", + "support": { + "issues": "https://github.com/codeigniter4/shield/issues", + "forum": "https://github.com/codeigniter4/shield/discussions", + "source": "https://github.com/codeigniter4/shield", + "docs": "https://codeigniter4.github.io/shield/", + "slack": "https://codeigniterchat.slack.com" + }, "require": { "php": "^7.4.3 || ^8.0", "codeigniter4/settings": "^2.1" }, "require-dev": { + "codeigniter/phpstan-codeigniter": "^1.3", "codeigniter4/devkit": "^1.0", - "codeigniter4/framework": "^4.2.7", + "codeigniter4/framework": "^4.3.5", + "firebase/php-jwt": "^6.4", "mikey179/vfsstream": "^1.6.7", "mockery/mockery": "^1.0", - "firebase/php-jwt": "^6.4" + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan-strict-rules": "^1.5", + "rector/rector": "0.18.5" }, "provide": { "codeigniter4/authentication-implementation": "1.0" @@ -41,12 +52,11 @@ "psr-4": { "CodeIgniter\\Shield\\": "src" }, + "files": [ + "src/Helpers/auth_helper.php" + ], "exclude-from-classmap": [ "**/Database/Migrations/**" - ], - "files": [ - "src/Helpers/auth_helper.php", - "src/Helpers/email_helper.php" ] }, "autoload-dev": { @@ -58,7 +68,8 @@ "config": { "allow-plugins": { "phpstan/extension-installer": true - } + }, + "sort-packages": true }, "scripts": { "post-update-cmd": [ @@ -69,7 +80,6 @@ "psalm", "rector process --dry-run" ], - "sa": "@analyze", "ci": [ "Composer\\Config::disableProcessTimeout", "@cs", @@ -80,17 +90,11 @@ ], "cs": "php-cs-fixer fix --ansi --verbose --dry-run --diff", "cs-fix": "php-cs-fixer fix --ansi --verbose --diff", - "deduplicate": "phpcpd app/ src/ --exclude src/Database/Migrations/2020-12-28-223112_create_auth_tables.php", + "deduplicate": "phpcpd app/ src/ --exclude src/Database/Migrations/2020-12-28-223112_create_auth_tables.php --exclude src/Authentication/Authenticators/HmacSha256.php", "inspect": "deptrac analyze --cache-file=build/deptrac.cache", "mutate": "infection --threads=2 --skip-initial-tests --coverage=build/phpunit", + "sa": "@analyze", "style": "@cs-fix", "test": "phpunit" - }, - "support": { - "forum": "https://github.com/codeigniter4/shield/discussions", - "slack": "https://codeigniterchat.slack.com", - "source": "https://github.com/codeigniter4/shield", - "issues": "https://github.com/codeigniter4/shield/issues", - "docs": "https://github.com/codeigniter4/shield/blob/develop/docs/index.md" } } diff --git a/docs/addons/jwt.md b/docs/addons/jwt.md index efcc54c8c..ad87cfb4e 100644 --- a/docs/addons/jwt.md +++ b/docs/addons/jwt.md @@ -1,7 +1,8 @@ # JWT Authentication -> **Note** -> Shield now supports only JWS (Singed JWT). JWE (Encrypted JWT) is not supported. +!!! note + + Shield now supports only JWS (Singed JWT). JWE (Encrypted JWT) is not supported. ## What is JWT? @@ -54,9 +55,9 @@ To use JWT Authentication, you need additional setup and configuration. You need to add the following constants: ```php - public const RECORD_LOGIN_ATTEMPT_NONE = 0; // Do not record at all - public const RECORD_LOGIN_ATTEMPT_FAILURE = 1; // Record only failures - public const RECORD_LOGIN_ATTEMPT_ALL = 2; // Record all login attempts + public const RECORD_LOGIN_ATTEMPT_NONE = 0; // Do not record at all + public const RECORD_LOGIN_ATTEMPT_FAILURE = 1; // Record only failures + public const RECORD_LOGIN_ATTEMPT_ALL = 2; // Record all login attempts ``` You need to add JWT Authenticator: @@ -65,20 +66,20 @@ To use JWT Authentication, you need additional setup and configuration. // ... - public array $authenticators = [ - 'tokens' => AccessTokens::class, - 'session' => Session::class, - 'jwt' => JWT::class, - ]; + public array $authenticators = [ + 'tokens' => AccessTokens::class, + 'session' => Session::class, + 'jwt' => JWT::class, + ]; ``` If you want to use JWT Authenticator in Authentication Chain, add `jwt`: ```php - public array $authenticationChain = [ - 'session', - 'tokens', - 'jwt' - ]; + public array $authenticationChain = [ + 'session', + 'tokens', + 'jwt' + ]; ``` ## Configuration @@ -87,17 +88,18 @@ Configure **app/Config/AuthJWT.php** for your needs. ### Set the Default Claims -> **Note** -> A payload contains the actual data being transmitted, such as user ID, role, -> or expiration time. Items in a payload is called *claims*. +!!! note + + A payload contains the actual data being transmitted, such as user ID, role, + or expiration time. Items in a payload is called *claims*. Set the default payload items to the property `$defaultClaims`. E.g.: ```php - public array $defaultClaims = [ - 'iss' => 'https://codeigniter.com/', - ]; +public array $defaultClaims = [ + 'iss' => 'https://codeigniter.com/', +]; ``` The default claims will be included in all tokens issued by Shield. @@ -107,7 +109,7 @@ The default claims will be included in all tokens issued by Shield. Set your secret key in the `$keys` property, or set it in your `.env` file. E.g.: -```dotenv +```text authjwt.keys.default.0.secret = 8XBFsF6HThIa7OV/bSynahEch+WbKrGcuiJVYPiwqPE= ``` @@ -121,8 +123,9 @@ with the following command: php -r 'echo base64_encode(random_bytes(32));' ``` -> **Note** -> The secret key is used for signing and validating tokens. +!!! note + + The secret key is used for signing and validating tokens. ## Issuing JWTs @@ -147,8 +150,7 @@ use CodeIgniter\API\ResponseTrait; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Shield\Authentication\Authenticators\Session; use CodeIgniter\Shield\Authentication\JWTManager; -use CodeIgniter\Shield\Authentication\Passwords; -use CodeIgniter\Shield\Config\AuthSession; +use CodeIgniter\Shield\Validation\ValidationRules; class LoginController extends BaseController { @@ -163,7 +165,7 @@ class LoginController extends BaseController $rules = $this->getValidationRules(); // Validate credentials - if (! $this->validateData($this->request->getJSON(true), $rules)) { + if (! $this->validateData($this->request->getJSON(true), $rules, [], config('Auth')->DBGroup)) { return $this->fail( ['errors' => $this->validator->getErrors()], $this->codes['unauthorized'] @@ -212,26 +214,16 @@ class LoginController extends BaseController */ protected function getValidationRules(): array { - return setting('Validation.login') ?? [ - 'email' => [ - 'label' => 'Auth.email', - 'rules' => config(AuthSession::class)->emailValidationRules, - ], - 'password' => [ - 'label' => 'Auth.password', - 'rules' => 'required|' . Passwords::getMaxLenghtRule(), - 'errors' => [ - 'max_byte' => 'Auth.errorPasswordTooLongBytes', - ], - ], - ]; + $rules = new ValidationRules(); + + return $rules->getLoginRules(); } } ``` You could send a request with the existing user's credentials by curl like this: -```console +```curl curl --location 'http://localhost:8080/auth/jwt' \ --header 'Content-Type: application/json' \ --data-raw '{"email" : "admin@example.jp" , "password" : "passw0rd!"}' @@ -242,7 +234,7 @@ the `Authorization` header as a `Bearer` token. You could send a request with the `Authorization` header by curl like this: -```console +```curl curl --location --request GET 'http://localhost:8080/api/users' \ --header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTaGllbGQgVGVzdCBBcHAiLCJzdWIiOiIxIiwiaWF0IjoxNjgxODA1OTMwLCJleHAiOjE2ODE4MDk1MzB9.DGpOmRPOBe45whVtEOSt53qJTw_CpH0V8oMoI_gm2XI' ``` diff --git a/docs/assets/css/dark_mode.css b/docs/assets/css/dark_mode.css new file mode 100644 index 000000000..8bc72d6a8 --- /dev/null +++ b/docs/assets/css/dark_mode.css @@ -0,0 +1,74 @@ +[data-md-color-scheme="slate"] { + --md-primary-fg-color: #6a290d; + --md-primary-fg-color--light: #8d7474; + --md-primary-fg-color--dark: #6d554d; + + .hljs-title, + .hljs-title.class_, + .hljs-title.class_.inherited__, + .hljs-title.function_ { + color: #c9a69b; + } + + .hljs-meta .hljs-string, + .hljs-regexp, + .hljs-string { + color: #a3b4c7; + } + + .hljs-attr, + .hljs-attribute, + .hljs-literal, + .hljs-meta, + .hljs-number, + .hljs-operator, + .hljs-selector-attr, + .hljs-selector-class, + .hljs-selector-id, + .hljs-variable { + color: #c1b79f; + } + + .hljs-doctag, + .hljs-keyword, + .hljs-meta .hljs-keyword, + .hljs-template-tag, + .hljs-template-variable, + .hljs-type, + .hljs-variable.language_ { + color: #c97100; + } + + .hljs-subst { + color: #ddba52 + } + + .md-typeset .note > .admonition-title, + .md-typeset .note > summary { + background-color: #0000001a; + } + + .md-typeset .admonition.note, + .md-typeset details.note { + border-color: #675647; + } + + .md-typeset .note > .admonition-title:before, + .md-typeset .note > summary:before { + background-color: #65686d; + -webkit-mask-image: var(--md-admonition-icon--note); + mask-image: var(--md-admonition-icon--note); + } + + .md-typeset .admonition.warning, + .md-typeset details.warning { + border-color: #776144; + } + + .md-typeset .warning > .admonition-title:before, + .md-typeset .warning > summary:before { + background-color: #d9913bc2; + -webkit-mask-image: var(--md-admonition-icon--warning); + mask-image: var(--md-admonition-icon--warning); + } +} diff --git a/docs/assets/js/curl.min.js b/docs/assets/js/curl.min.js new file mode 100644 index 000000000..924af722a --- /dev/null +++ b/docs/assets/js/curl.min.js @@ -0,0 +1,14 @@ +/*! `curl` grammar compiled for Highlight.js 11.3.1 */ +(()=>{var e=(()=>{"use strict";return e=>{const n={className:"string",begin:/"/, +end:/"/,contains:[e.BACKSLASH_ESCAPE,{className:"variable",begin:/\$\(/, +end:/\)/,contains:[e.BACKSLASH_ESCAPE]}],relevance:0},a={className:"number", +variants:[{begin:e.C_NUMBER_RE}],relevance:0};return{name:"curl", +aliases:["curl"],keywords:"curl",case_insensitive:!0,contains:[{ +className:"literal",begin:/(--request|-X)\s/,contains:[{className:"symbol", +begin:/(get|post|delete|options|head|put|patch|trace|connect)/,end:/\s/, +returnEnd:!0}],returnEnd:!0,relevance:10},{className:"literal",begin:/--/, +end:/[\s"]/,returnEnd:!0,relevance:0},{className:"literal",begin:/-\w/, +end:/[\s"]/,returnEnd:!0,relevance:0},n,{className:"string",begin:/\\"/, +relevance:0},{className:"string",begin:/'/,end:/'/,relevance:0 +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,{match:/(\/[a-z._-]+)+/}]}}})() +;hljs.registerLanguage("curl",e)})(); \ No newline at end of file diff --git a/docs/assets/hljs.js b/docs/assets/js/hljs.js similarity index 100% rename from docs/assets/hljs.js rename to docs/assets/js/hljs.js diff --git a/docs/authentication.md b/docs/authentication.md deleted file mode 100644 index 3b177d3dc..000000000 --- a/docs/authentication.md +++ /dev/null @@ -1,305 +0,0 @@ -# Authentication - -- [Authentication](#authentication) - - [Available Authenticators](#available-authenticators) - - [Auth Helper](#auth-helper) - - [Authenticator Responses](#authenticator-responses) - - [isOK()](#isok) - - [reason()](#reason) - - [extraInfo()](#extrainfo) - - [Session Authenticator](#session-authenticator) - - [attempt()](#attempt) - - [check()](#check) - - [loggedIn()](#loggedin) - - [logout()](#logout) - - [forget()](#forget) - - [Access Token Authenticator](#access-token-authenticator) - - [Access Token/API Authentication](#access-tokenapi-authentication) - - [Generating Access Tokens](#generating-access-tokens) - - [Revoking Access Tokens](#revoking-access-tokens) - - [Retrieving Access Tokens](#retrieving-access-tokens) - - [Access Token Lifetime](#access-token-lifetime) - - [Access Token Scopes](#access-token-scopes) - -Authentication is the process of determining that a visitor actually belongs to your website, -and identifying them. Shield provides a flexible and secure authentication system for your -web apps and APIs. - -## Available Authenticators - -Shield ships with 2 authenticators that will serve several typical situations within web app development: the -Session authenticator, which uses username/email/password to authenticate against and stores it in the session, -and the Access Tokens authenticator which uses private access tokens passed in the headers. - -The available authenticators are defined in `Config\Auth`: - -```php -public $authenticators = [ - // alias => classname - 'session' => Session::class, - 'tokens' => AccessTokens::class, -]; -``` - -The default authenticator is also defined in the configuration file, and uses the alias given above: - -```php -public $defaultAuthenticator = 'session'; -``` - -## Auth Helper - -The auth functionality is designed to be used with the `auth_helper` that comes with Shield. This -helper method provides the `auth()` function which returns a convenient interface to the most frequently -used functionality within the auth libraries. - -```php -// get the current user -auth()->user(); - -// get the current user's id -auth()->id(); -// or -user_id(); - -// get the User Provider (UserModel by default) -auth()->getProvider(); -``` - -> **Note** -> The `auth_helper` is autoloaded by Composer. If you want to *override* the functions, -> you need to define them in **app/Common.php**. - -## Authenticator Responses - -Many of the authenticator methods will return a `CodeIgniter\Shield\Result` class. This provides a consistent -way of checking the results and can have additional information returned along with it. The class -has the following methods: - -### isOK() - -Returns a boolean value stating whether the check was successful or not. - -### reason() - -Returns a message that can be displayed to the user when the check fails. - -### extraInfo() - -Can return a custom bit of information. These will be detailed in the method descriptions below. - - -## Session Authenticator - -The Session authenticator stores the user's authentication within the user's session, and on a secure cookie -on their device. This is the standard password-based login used in most web sites. It supports a -secure remember me feature, and more. This can also be used to handle authentication for -single page applications (SPAs). - -### attempt() - -When a user attempts to login with their email and password, you would call the `attempt()` method -on the auth class, passing in their credentials. - -```php -$credentials = [ - 'email' => $this->request->getPost('email'), - 'password' => $this->request->getPost('password') -]; - -$loginAttempt = auth()->attempt($credentials); - -if (! $loginAttempt->isOK()) { - return redirect()->back()->with('error', $loginAttempt->reason()); -} -``` - -Upon a successful `attempt()`, the user is logged in. The Response object returned will provide -the user that was logged in as `extraInfo()`. - -```php -$result = auth()->attempt($credentials); - -if ($result->isOK()) { - $user = $result->extraInfo(); -} -``` - -If the attempt fails a `failedLogin` event is triggered with the credentials array as -the only parameter. Whether or not they pass, a login attempt is recorded in the `auth_logins` table. - -If `allowRemembering` is `true` in the `Auth` config file, you can tell the Session authenticator -to set a secure remember-me cookie. - -```php -$loginAttempt = auth()->remember()->attempt($credentials); -``` - -### check() - -If you would like to check a user's credentials without logging them in, you can use the `check()` -method. - -```php -$credentials = [ - 'email' => $this->request->getPost('email'), - 'password' => $this->request->getPost('password') -]; - -$validCreds = auth()->check($credentials); - -if (! $validCreds->isOK()) { - return redirect()->back()->with('error', $validCreds->reason()); -} -``` - -The Result instance returned contains the valid user as `extraInfo()`. - -### loggedIn() - -You can determine if a user is currently logged in with the aptly titled method, `loggedIn()`. - -```php -if (auth()->loggedIn()) { - // Do something. -} -``` - -### logout() - -You can call the `logout()` method to log the user out of the current session. This will destroy and -regenerate the current session, purge any remember-me tokens current for this user, and trigger a -`logout` event. - -```php -auth()->logout(); -``` - -### forget() - -The `forget` method will purge all remember-me tokens for the current user, making it so they -will not be remembered on the next visit to the site. - - - -## Access Token Authenticator - -The Access Token authenticator supports the use of revoke-able API tokens without using OAuth. These are commonly -used to provide third-party developers access to your API. These tokens typically have a very long -expiration time, often years. - -These are also suitable for use with mobile applications. In this case, the user would register/sign-in -with their email/password. The application would create a new access token for them, with a recognizable -name, like John's iPhone 12, and return it to the mobile application, where it is stored and used -in all future requests. - -### Access Token/API Authentication - -Using access tokens requires that you either use/extend `CodeIgniter\Shield\Models\UserModel` or -use the `CodeIgniter\Shield\Authentication\Traits\HasAccessTokens` on your own user model. This trait -provides all of the custom methods needed to implement access tokens in your application. The necessary -database table, `auth_identities`, is created in Shield's only migration class, which must be run -before first using any of the features of Shield. - -### Generating Access Tokens - -Access tokens are created through the `generateAccessToken()` method on the user. This takes a name to -give to the token as the first argument. The name is used to display it to the user so they can -differentiate between multiple tokens. - -```php -$token = $user->generateAccessToken('Work Laptop'); -``` - -This creates the token using a cryptographically secure random string. The token -is hashed (sha256) before saving it to the database. The method returns an instance of -`CodeIgniters\Shield\Authentication\Entities\AccessToken`. The only time a plain text -version of the token is available is in the `AccessToken` returned immediately after creation. - -**The plain text version should be displayed to the user immediately so they can copy it for -their use.** If a user loses it, they cannot see the raw version anymore, but they can generate -a new token to use. - -```php -$token = $user->generateAccessToken('Work Laptop'); - -// Only available immediately after creation. -echo $token->raw_token; -``` - -### Revoking Access Tokens - -Access tokens can be revoked through the `revokeAccessToken()` method. This takes the plain-text -access token as the only argument. Revoking simply deletes the record from the database. - -```php -$user->revokeAccessToken($token); -``` - -Typically, the plain text token is retrieved from the request's headers as part of the authentication -process. If you need to revoke the token for another user as an admin, and don't have access to the -token, you would need to get the user's access tokens and delete them manually. - -You can revoke all access tokens with the `revokeAllAccessTokens()` method. - -```php -$user->revokeAllAccessTokens(); -``` - -### Retrieving Access Tokens - -The following methods are available to help you retrieve a user's access tokens: - -```php -// Retrieve a single token by plain text token -$token = $user->getAccessToken($rawToken); - -// Retrieve a single token by it's database ID -$token = $user->getAccessTokenById($id); - -// Retrieve all access tokens as an array of AccessToken instances. -$tokens = $user->accessTokens(); -``` - -### Access Token Lifetime - -Tokens will expire after a specified amount of time has passed since they have been used. -By default, this is set to 1 year. You can change this value by setting the `accessTokenLifetime` -value in the `Auth` config file. This is in seconds so that you can use the -[time constants](https://codeigniter.com/user_guide/general/common_functions.html#time-constants) -that CodeIgniter provides. - -```php -public $unusedTokenLifetime = YEAR; -``` - -### Access Token Scopes - -Each token can be given one or more scopes they can be used within. These can be thought of as -permissions the token grants to the user. Scopes are provided when the token is generated and -cannot be modified afterword. - -```php -$token = $user->gererateAccessToken('Work Laptop', ['posts.manage', 'forums.manage']); -``` - -By default a user is granted a wildcard scope which provides access to all scopes. This is the -same as: - -```php -$token = $user->gererateAccessToken('Work Laptop', ['*']); -``` - -During authentication, the token the user used is stored on the user. Once authenticated, you -can use the `tokenCan()` and `tokenCant()` methods on the user to determine if they have access -to the specified scope. - -```php -if ($user->tokenCan('posts.manage')) { - // do something.... -} - -if ($user->tokenCant('forums.manage')) { - // do something.... -} -``` diff --git a/docs/customization.md b/docs/customization.md deleted file mode 100644 index 6e21b7564..000000000 --- a/docs/customization.md +++ /dev/null @@ -1,326 +0,0 @@ -# Customizing Shield - -- [Customizing Shield](#customizing-shield) - - [Custom Table Names](#custom-table-names) - - [Route Configuration](#route-configuration) - - [Custom Redirect URLs](#custom-redirect-urls) - - [Customize Login Redirect](#customize-login-redirect) - - [Customize Register Redirect](#customize-register-redirect) - - [Customize Logout Redirect](#customize-logout-redirect) - - [Extending the Controllers](#extending-the-controllers) - - [Integrating Custom View Libraries](#integrating-custom-view-libraries) - - [Custom Validation Rules](#custom-validation-rules) - - [Registration](#registration) - - [Login](#login) - - [Custom User Provider](#custom-user-provider) - - [Custom Login Identifier](#custom-login-identifier) - -## Custom Table Names - -If you want to change the default table names, you can change the table names -in **app/Config/Auth.php**. - -```php -public array $tables = [ - 'users' => 'users', - 'identities' => 'auth_identities', - 'logins' => 'auth_logins', - 'token_logins' => 'auth_token_logins', - 'remember_tokens' => 'auth_remember_tokens', - 'groups_users' => 'auth_groups_users', - 'permissions_users' => 'auth_permissions_users', -]; -``` - -Set the table names that you want in the array values. - -> **Note** You must change the table names before running database migrations. - -## Route Configuration - -If you need to customize how any of the auth features are handled, you will likely need to update the routes to point to the correct controllers. You can still use the `service('auth')->routes()` helper, but you will need to pass the `except` option with a list of routes to customize: - -```php -service('auth')->routes($routes, ['except' => ['login', 'register']]); -``` - -Then add the routes to your customized controllers: - -```php -$routes->get('login', '\App\Controllers\Auth\LoginController::loginView'); -$routes->get('register', '\App\Controllers\Auth\RegisterController::registerView'); -``` - -## Custom Redirect URLs - -### Customize Login Redirect - -You can customize where a user is redirected to on login with the `loginRedirect()` method of the **app/Config/Auth.php** config file. This is handy if you want to redirect based on user group or other criteria. - -```php -public function loginRedirect(): string -{ - $url = auth()->user()->inGroup('admin') - ? '/admin' - : setting('Auth.redirects')['login']; - - return $this->getUrl($url); -} -``` - -Oftentimes, you will want to have different redirects for different user groups. A simple example -might be that you want admins redirected to `/admin` while all other groups redirect to `/`. -The **app/Config/Auth.php** config file also includes methods that you can add additional logic to in order to -achieve this: - -```php -public function loginRedirect(): string -{ - if (auth()->user()->can('admin.access')) { - return '/admin'; - } - - $url = setting('Auth.redirects')['login']; - - return $this->getUrl($url); -} -``` - -### Customize Register Redirect - -You can customize where a user is redirected to after registration in the `registerRedirect()` method of the **app/Config/Auth.php** config file. - -```php -public function registerRedirect(): string -{ - $url = setting('Auth.redirects')['register']; - - return $this->getUrl($url); -} -``` - -### Customize Logout Redirect - -The logout redirect can also be overridden by the `logoutRedirect()` method of the **app/Config/Auth.php** config file. This will not be used as often as login and register, but you might find the need. For example, if you programatically logged a user out you might want to take them to a page that specifies why they were logged out. Otherwise, you might take them to the home page or even the login page. - -```php -public function logoutRedirect(): string -{ - $url = setting('Auth.redirects')['logout']; - - return $this->getUrl($url); -} -``` - -## Extending the Controllers - -Shield has the following controllers that can be extended to handle -various parts of the authentication process: - -- **ActionController** handles the after-login and after-registration actions, like Two Factor Authentication and Email Verification. -- **LoginController** handles the login process. -- **RegisterController** handles the registration process. Overriding this class allows you to customize the User Provider, the User Entity, and the validation rules. -- **MagicLinkController** handles the "lost password" process that allows a user to login with a link sent to their email. This allows you to - override the message that is displayed to a user to describe what is happening, if you'd like to provide more information than simply swapping out the view used. - -It is not recommended to copy the entire controller into **app/Controllers** and change its namespace. Instead, you should create a new controller that extends -the existing controller and then only override the methods needed. This allows the other methods to stay up to date with any security -updates that might happen in the controllers. - -```php -themedView($view, $data, $options); - } -} -``` - -## Custom Validation Rules - -### Registration - -Shield has the following rules for registration: - -```php -[ - 'username' => [ - 'label' => 'Auth.username', - 'rules' => [ - 'required', - 'max_length[30]', - 'min_length[3]', - 'regex_match[/\A[a-zA-Z0-9\.]+\z/]', - 'is_unique[users.username]', - ], - ], - 'email' => [ - 'label' => 'Auth.email', - 'rules' => [ - 'required', - 'max_length[254]', - 'valid_email', - 'is_unique[auth_identities.secret]', - ], - ], - 'password' => [ - 'label' => 'Auth.password', - 'rules' => 'required|strong_password', - ], - 'password_confirm' => [ - 'label' => 'Auth.passwordConfirm', - 'rules' => 'required|matches[password]', - ], -]; -``` - -> **Note** If you customize the table names, the table names -> (`users` and `auth_identities`) in the above rules will be automatically -> changed. The rules are implemented in -> `RegisterController::getValidationRules()`. - -If you need a different set of rules for registration, you can specify them in your `Validation` configuration (**app/Config/Validation.php**) like: - -```php - //-------------------------------------------------------------------- - // Rules For Registration - //-------------------------------------------------------------------- - public $registration = [ - 'username' => [ - 'label' => 'Auth.username', - 'rules' => [ - 'required', - 'max_length[30]', - 'min_length[3]', - 'regex_match[/\A[a-zA-Z0-9\.]+\z/]', - 'is_unique[users.username]', - ], - ], - 'email' => [ - 'label' => 'Auth.email', - 'rules' => [ - 'required', - 'max_length[254]', - 'valid_email', - 'is_unique[auth_identities.secret]', - ], - ], - 'password' => [ - 'label' => 'Auth.password', - 'rules' => 'required|strong_password', - ], - 'password_confirm' => [ - 'label' => 'Auth.passwordConfirm', - 'rules' => 'required|matches[password]', - ], - ]; -``` - -> **Note** If you customize the table names, set the correct table names in the -> rules. - -### Login - -Similar to the process for validation rules in the **Registration** section, you can add rules for the login form to **app/Config/Validation.php** and change the rules. - -```php - //-------------------------------------------------------------------- - // Rules For Login - //-------------------------------------------------------------------- - public $login = [ - // 'username' => [ - // 'label' => 'Auth.username', - // 'rules' => 'required|max_length[30]|min_length[3]|regex_match[/\A[a-zA-Z0-9\.]+\z/]', - // ], - 'email' => [ - 'label' => 'Auth.email', - 'rules' => 'required|max_length[254]|valid_email', - ], - 'password' => [ - 'label' => 'Auth.password', - 'rules' => 'required', - ], - ]; -``` - -## Custom User Provider - -If you want to customize user attributes, you need to create your own -[User Provider](./concepts.md#user-providers) class. -The only requirement is that your new class MUST extend the provided `CodeIgniter\Shield\Models\UserModel`. - -Shield has a CLI command to quickly create a custom `UserModel` class by running the following -command in the terminal: - -```console -php spark shield:model UserModel -``` - -The class name is optional. If none is provided, the generated class name would be `UserModel`. - -After creating the class, set the `$userProvider` property in **app/Config/Auth.php** as follows: - -```php -public string $userProvider = \App\Models\UserModel::class; -``` - -## Custom Login Identifier - -If your application has a need to use something other than `email` or `username`, you may specify any valid column within the `users` table that you may have added. This allows you to easily use phone numbers, employee or school IDs, etc as the user identifier. You must implement the following steps to set this up: - -This only works with the Session authenticator. - -1. Create a [migration](http://codeigniter.com/user_guide/dbmgmt/migration.html) that adds a new column to the `users` table. -2. Edit `app/Config/Auth.php` so that the new column you just created is within the `$validFields` array. - - ```php - public array $validFields = [ - 'employee_id' - ]; - ``` - - If you have multiple login forms on your site that use different credentials, you must have all of the valid identifying fields in the array. - - ```php - public array $validFields = [ - 'email', - 'employee_id' - ]; - ``` - > **Warning** - > It is very important for security that if you add a new column for identifier you must write a new **Validation Rules** and then set it using the [custom-validation-rules](https://github.com/codeigniter4/shield/blob/develop/docs/customization.md#custom-validation-rules) description. - -3. Edit the login form to change the name of the default `email` input to the new field name. - - ```php - -
- -
- ``` diff --git a/docs/customization/extending_controllers.md b/docs/customization/extending_controllers.md new file mode 100644 index 000000000..ea03a54ee --- /dev/null +++ b/docs/customization/extending_controllers.md @@ -0,0 +1,31 @@ +# Extending the Controllers + +Shield has the following controllers that can be extended to handle +various parts of the authentication process: + +- **ActionController** handles the after-login and after-registration actions, like Two Factor Authentication and Email Verification. +- **LoginController** handles the login process. +- **RegisterController** handles the registration process. Overriding this class allows you to customize the User Provider, the User Entity, and the validation rules. +- **MagicLinkController** handles the "lost password" process that allows a user to login with a link sent to their email. This allows you to + override the message that is displayed to a user to describe what is happening, if you'd like to provide more information than simply swapping out the view used. + +It is not recommended to copy the entire controller into **app/Controllers** and change its namespace. Instead, you should create a new controller that extends +the existing controller and then only override the methods needed. This allows the other methods to stay up to date with any security +updates that might happen in the controllers. + +```php +themedView($view, $data, $options); + } +} +``` diff --git a/docs/customization/login_identifier.md b/docs/customization/login_identifier.md new file mode 100644 index 000000000..aa51d4b4d --- /dev/null +++ b/docs/customization/login_identifier.md @@ -0,0 +1,35 @@ +# Customizing Login Identifier + +If your application has a need to use something other than `email` or `username`, you may specify any valid column within the `users` table that you may have added. This allows you to easily use phone numbers, employee or school IDs, etc as the user identifier. You must implement the following steps to set this up: + +This only works with the Session authenticator. + +1. Create a [migration](http://codeigniter.com/user_guide/dbmgmt/migration.html) that adds a new column to the `users` table. +2. Edit `app/Config/Auth.php` so that the new column you just created is within the `$validFields` array. + + ```php + public array $validFields = [ + 'employee_id' + ]; + ``` + + If you have multiple login forms on your site that use different credentials, you must have all of the valid identifying fields in the array. + + ```php + public array $validFields = [ + 'email', + 'employee_id' + ]; + ``` + !!! warning + + It is very important for security that if you add a new column for identifier, you must write a new **Validation Rules** and then set it using the [Customizing Validation Rules](./validation_rules.md) description. + +3. Edit the login form to change the name of the default `email` input to the new field name. + + ```php + +
+ +
+ ``` diff --git a/docs/customization/redirect_urls.md b/docs/customization/redirect_urls.md new file mode 100644 index 000000000..5ecf6834b --- /dev/null +++ b/docs/customization/redirect_urls.md @@ -0,0 +1,60 @@ +# Customizing Redirect URLs + +## Customize Login Redirect + +You can customize where a user is redirected to on login with the `loginRedirect()` method of the **app/Config/Auth.php** config file. This is handy if you want to redirect based on user group or other criteria. + +```php +public function loginRedirect(): string +{ + $url = auth()->user()->inGroup('admin') + ? '/admin' + : setting('Auth.redirects')['login']; + + return $this->getUrl($url); +} +``` + +Oftentimes, you will want to have different redirects for different user groups. A simple example +might be that you want admins redirected to `/admin` while all other groups redirect to `/`. +The **app/Config/Auth.php** config file also includes methods that you can add additional logic to in order to +achieve this: + +```php +public function loginRedirect(): string +{ + if (auth()->user()->can('admin.access')) { + return '/admin'; + } + + $url = setting('Auth.redirects')['login']; + + return $this->getUrl($url); +} +``` + +## Customize Register Redirect + +You can customize where a user is redirected to after registration in the `registerRedirect()` method of the **app/Config/Auth.php** config file. + +```php +public function registerRedirect(): string +{ + $url = setting('Auth.redirects')['register']; + + return $this->getUrl($url); +} +``` + +## Customize Logout Redirect + +The logout redirect can also be overridden by the `logoutRedirect()` method of the **app/Config/Auth.php** config file. This will not be used as often as login and register, but you might find the need. For example, if you programatically logged a user out you might want to take them to a page that specifies why they were logged out. Otherwise, you might take them to the home page or even the login page. + +```php +public function logoutRedirect(): string +{ + $url = setting('Auth.redirects')['logout']; + + return $this->getUrl($url); +} +``` diff --git a/docs/customization/route_config.md b/docs/customization/route_config.md new file mode 100644 index 000000000..46d1c6597 --- /dev/null +++ b/docs/customization/route_config.md @@ -0,0 +1,64 @@ +# Customizing Routes + +## Change Some Routes + +If you need to customize how any of the auth features are handled, you will likely need to update the routes to point to the correct controllers. + +You can still use the `service('auth')->routes()` helper, but you will need to pass the `except` option with a list of routes to customize: + +```php +service('auth')->routes($routes, ['except' => ['login', 'register']]); +``` + +Then add the routes to your customized controllers: + +```php +$routes->get('login', '\App\Controllers\Auth\LoginController::loginView'); +$routes->get('register', '\App\Controllers\Auth\RegisterController::registerView'); +``` + +After customization, check your routes with the [spark routes](https://codeigniter.com/user_guide/incoming/routing.html#spark-routes) command. + +## Use Locale Routes + +You can use the `{locale}` placeholder in your routes +(see [Locale Detection](https://codeigniter.com/user_guide/outgoing/localization.html#in-routes)). + +```php +$routes->group('{locale}', static function($routes) { + service('auth')->routes($routes); +}); +``` + +The above code registers the following routes: + +```text ++--------+----------------------------------+--------------------+--------------------------------------------------------------------+----------------+---------------+ +| Method | Route | Name | Handler | Before Filters | After Filters | ++--------+----------------------------------+--------------------+--------------------------------------------------------------------+----------------+---------------+ +| GET | {locale}/register | register | \CodeIgniter\Shield\Controllers\RegisterController::registerView | | toolbar | +| GET | {locale}/login | login | \CodeIgniter\Shield\Controllers\LoginController::loginView | | toolbar | +| GET | {locale}/login/magic-link | magic-link | \CodeIgniter\Shield\Controllers\MagicLinkController::loginView | | toolbar | +| GET | {locale}/login/verify-magic-link | verify-magic-link | \CodeIgniter\Shield\Controllers\MagicLinkController::verify | | toolbar | +| GET | {locale}/logout | logout | \CodeIgniter\Shield\Controllers\LoginController::logoutAction | | toolbar | +| GET | {locale}/auth/a/show | auth-action-show | \CodeIgniter\Shield\Controllers\ActionController::show | | toolbar | +| POST | {locale}/register | register | \CodeIgniter\Shield\Controllers\RegisterController::registerAction | | toolbar | +| POST | {locale}/login | » | \CodeIgniter\Shield\Controllers\LoginController::loginAction | | toolbar | +| POST | {locale}/login/magic-link | » | \CodeIgniter\Shield\Controllers\MagicLinkController::loginAction | | toolbar | +| POST | {locale}/auth/a/handle | auth-action-handle | \CodeIgniter\Shield\Controllers\ActionController::handle | | toolbar | +| POST | {locale}/auth/a/verify | auth-action-verify | \CodeIgniter\Shield\Controllers\ActionController::verify | | toolbar | ++--------+----------------------------------+--------------------+--------------------------------------------------------------------+----------------+---------------+ +``` + +If you set the global filter in the **app/Config/Filters.php** file, you need to +update the paths for `except`: + +```php +public $globals = [ + 'before' => [ + // ... + 'session' => ['except' => ['*/login*', '*/register', '*/auth/a/*']], + ], + // ... +]; +``` diff --git a/docs/customization/table_names.md b/docs/customization/table_names.md new file mode 100644 index 000000000..daad7d7f8 --- /dev/null +++ b/docs/customization/table_names.md @@ -0,0 +1,22 @@ +# Customizing Table Names + +If you want to change the default table names, you can change the table names +in **app/Config/Auth.php**. + +```php +public array $tables = [ + 'users' => 'users', + 'identities' => 'auth_identities', + 'logins' => 'auth_logins', + 'token_logins' => 'auth_token_logins', + 'remember_tokens' => 'auth_remember_tokens', + 'groups_users' => 'auth_groups_users', + 'permissions_users' => 'auth_permissions_users', +]; +``` + +Set the table names that you want in the array values. + +!!! note + + You must change the table names before running database migrations. diff --git a/docs/customization/user_provider.md b/docs/customization/user_provider.md new file mode 100644 index 000000000..12246043c --- /dev/null +++ b/docs/customization/user_provider.md @@ -0,0 +1,20 @@ +# Customizing User Provider + +If you want to customize user attributes, you need to create your own +[User Provider](../getting_started/concepts.md#user-providers) class. +The only requirement is that your new class MUST extend the provided `CodeIgniter\Shield\Models\UserModel`. + +Shield has a CLI command to quickly create a custom `UserModel` class by running the following +command in the terminal: + +```console +php spark shield:model UserModel +``` + +The class name is optional. If none is provided, the generated class name would be `UserModel`. + +After creating the class, set the `$userProvider` property in **app/Config/Auth.php** as follows: + +```php +public string $userProvider = \App\Models\UserModel::class; +``` diff --git a/docs/customization/validation_rules.md b/docs/customization/validation_rules.md new file mode 100644 index 000000000..074009d23 --- /dev/null +++ b/docs/customization/validation_rules.md @@ -0,0 +1,132 @@ +# Customizing Validation Rules + +## Registration + +Shield has the following rules for registration by default: + +```php +[ + 'username' => [ + 'label' => 'Auth.username', + 'rules' => [ + 'required', + 'max_length[30]', + 'min_length[3]', + 'regex_match[/\A[a-zA-Z0-9\.]+\z/]', + 'is_unique[users.username]', + ], + ], + 'email' => [ + 'label' => 'Auth.email', + 'rules' => [ + 'required', + 'max_length[254]', + 'valid_email', + 'is_unique[auth_identities.secret]', + ], + ], + 'password' => [ + 'label' => 'Auth.password', + 'rules' => [ + 'required', + 'max_byte[72]', + 'strong_password[]', + ], + 'errors' => [ + 'max_byte' => 'Auth.errorPasswordTooLongBytes' + ] + ], + 'password_confirm' => [ + 'label' => 'Auth.passwordConfirm', + 'rules' => 'required|matches[password]', + ], +]; +``` + +!!! note + + If you customize the table names, the table names(`users` and `auth_identities`) in the above rules will be automatically changed. + The rules are implemented in `RegisterController::getValidationRules()`. + +If you need a different set of rules for registration, you can specify them in your `Validation` configuration (**app/Config/Validation.php**) like: + +```php +//-------------------------------------------------------------------- +// Rules For Registration +//-------------------------------------------------------------------- +public $registration = [ + 'username' => [ + 'label' => 'Auth.username', + 'rules' => [ + 'required', + 'max_length[30]', + 'min_length[3]', + 'regex_match[/\A[a-zA-Z0-9\.]+\z/]', + 'is_unique[users.username]', + ], + ], + 'email' => [ + 'label' => 'Auth.email', + 'rules' => [ + 'required', + 'max_length[254]', + 'valid_email', + 'is_unique[auth_identities.secret]', + ], + ], + 'password' => [ + 'label' => 'Auth.password', + 'rules' => 'required|max_byte[72]|strong_password[]', + 'errors' => [ + 'max_byte' => 'Auth.errorPasswordTooLongBytes' + ] + ], + 'password_confirm' => [ + 'label' => 'Auth.passwordConfirm', + 'rules' => 'required|matches[password]', + ], +]; +``` + +!!! note + + If you customize the table names, set the correct table names in the rules. + +## Login + +Similar to the process for validation rules in the **Registration** section, you can add rules for the login form to **app/Config/Validation.php** and change the rules. + +```php +//-------------------------------------------------------------------- +// Rules For Login +//-------------------------------------------------------------------- +public $login = [ + // 'username' => [ + // 'label' => 'Auth.username', + // 'rules' => [ + // 'required', + // 'max_length[30]', + // 'min_length[3]', + // 'regex_match[/\A[a-zA-Z0-9\.]+\z/]', + // ], + // ], + 'email' => [ + 'label' => 'Auth.email', + 'rules' => [ + 'required', + 'max_length[254]', + 'valid_email' + ], + ], + 'password' => [ + 'label' => 'Auth.password', + 'rules' => [ + 'required', + 'max_byte[72]', + ], + 'errors' => [ + 'max_byte' => 'Auth.errorPasswordTooLongBytes', + ] + ], +]; +``` diff --git a/docs/getting_started/authenticators.md b/docs/getting_started/authenticators.md new file mode 100644 index 000000000..1e5d31987 --- /dev/null +++ b/docs/getting_started/authenticators.md @@ -0,0 +1,19 @@ +# Authenticators + +## Authenticator List + +Shield provides the following Authenticators: + +- **Session** authenticator provides traditional ID/Password authentication. + It uses username/email/password to authenticate against and stores the user + information in the session. See [Using Session Authenticator](../quick_start_guide/using_session_auth.md) + and [Session Authenticator](../references/authentication/session.md) for usage. +- **AccessTokens** authenticator provides stateless authentication using Personal + Access Tokens passed in the HTTP headers. + See [Protecting an API with Access Tokens](../guides/api_tokens.md) and + [Access Token Authenticator](../references/authentication/tokens.md) for usage. +- **HmacSha256** authenticator provides stateless authentication using HMAC Keys. + See [Protecting an API with HMAC Keys](../guides/api_hmac_keys.md) and + [HMAC SHA256 Token Authenticator](../references/authentication/hmac.md) for usage. +- **JWT** authenticator provides stateless authentication using JSON Web Token. To use this, + you need additional setup. See [JWT Authentication](../addons/jwt.md). diff --git a/docs/concepts.md b/docs/getting_started/concepts.md similarity index 85% rename from docs/concepts.md rename to docs/getting_started/concepts.md index eaf57379a..04ad6db9f 100644 --- a/docs/concepts.md +++ b/docs/getting_started/concepts.md @@ -2,13 +2,6 @@ This document covers some of the base concepts used throughout the library. -- [Shield Concepts](#shield-concepts) - - [Repository State](#repository-state) - - [Settings](#settings) - - [User Providers](#user-providers) - - [User Identities](#user-identities) - - [Password Validators](#password-validators) - ## Repository State Shield is designed so that the initial setup of your application can all happen in code with nothing required to be @@ -28,7 +21,7 @@ on the standard Config class if nothing is found in the database. Shield has a model to handle user persistence. Shield calls this the "User Provider" class. A default model is provided for you by the `CodeIgniter\Shield\Models\UserModel` class. -You can use your own model to customize user attributes. See [Customizing Shield](./customization.md#custom-user-provider) for details. +You can use your own model to customize user attributes. See [Customizing User Provider](../customization/user_provider.md) for details. ## User Identities @@ -87,14 +80,13 @@ public $passwordValidators = [ You use `strong_password` rule for password validation explained above. -> **Note** -> The `strong_password` rule only supports use cases to check the user's own password. -> It fetches the authenticated user's data for **NothingPersonalValidator** -> if the visitor is authenticated. -> -> If you want to have use cases that set and check another user's password, -> you can't use `strong_password`. You need to use `service('passwords')` directly -> to check the password. -> -> But remember, it is not good practice to set passwords for other users. -> This is because the password should be known only by that user. +!!! note + + The `strong_password` rule only supports use cases to check the user's own password. + It fetches the authenticated user's data for **NothingPersonalValidator** + if the visitor is authenticated. + If you want to have use cases that set and check another user's password, + you can't use `strong_password`. You need to use `service('passwords')` directly + to check the password. + But remember, it is not good practice to set passwords for other users. + This is because the password should be known only by that user. diff --git a/docs/getting_started/configuration.md b/docs/getting_started/configuration.md new file mode 100644 index 000000000..7d621fcb0 --- /dev/null +++ b/docs/getting_started/configuration.md @@ -0,0 +1,27 @@ +# Configuration + +## Config files + +Shield has a lot of Config items. Change the default values as needed. + +If you have completed the setup according to this documentation, you will have +the following configuration files: + +- **app/Config/Auth.php** +- **app/Config/AuthGroups.php** - For Authorization +- **app/Config/AuthToken.php** - For AccessTokens and HmacSha256 Authentication +- **app/Config/AuthJWT.php** - For JWT Authentication + +Note that you do not need to have configuration files for features you do not use. + +This section describes the major Config items that are not described elsewhere. + +## AccessTokens Authenticator + +### Access Token Lifetime + +By default, Access Tokens can be used for 1 year since the last use. This can be easily modified in the **app/Config/AuthToken.php** config file. + +```php +public int $unusedTokenLifetime = YEAR; +``` diff --git a/docs/getting_started/install.md b/docs/getting_started/install.md new file mode 100644 index 000000000..c608bd3f3 --- /dev/null +++ b/docs/getting_started/install.md @@ -0,0 +1,149 @@ +# Installation + +These instructions assume that you have already [installed the CodeIgniter 4 app starter](https://codeigniter.com/user_guide/installation/installing_composer.html) as the basis for your new project, set up your **.env** file, and created a database that you can access via the Spark CLI script. + +## Requirements + +- [Composer](https://getcomposer.org) +- Codeigniter **v4.3.5** or later +- A created database that you can access via the Spark CLI script + - InnoDB (not MyISAM) is required if MySQL is used. + +## Composer Installation + +Installation is done through [Composer](https://getcomposer.org). The example assumes you have it installed globally. +If you have it installed as a phar, or otherwise you will need to adjust the way you call composer itself. + +```console +composer require codeigniter4/shield +``` + +### Troubleshooting + +#### IMPORTANT: composer error + +If you get the following error: + +```console +Could not find a version of package codeigniter4/shield matching your minimum-stability (stable). +Require it with an explicit version constraint allowing its desired stability. +``` + +1. Run the following commands to change your [minimum-stability](https://getcomposer.org/doc/articles/versions.md#minimum-stability) in your project `composer.json`: + + ```console + composer config minimum-stability dev + composer config prefer-stable true + ``` + +2. Or specify an explicit version: + + ```console + composer require codeigniter4/shield:dev-develop + ``` + + The above specifies `develop` branch. + See + + ```console + composer require codeigniter4/shield:^1.0.0-beta + ``` + + The above specifies `v1.0.0-beta` or later and before `v2.0.0`. + See + +## Initial Setup + +There are a few setup items to do before you can start using Shield in +your project. + +### Command Setup + +1. Run the following command. This command handles steps 1-6 of *Manual Setup*. + + ```console + php spark shield:setup + ``` + + !!! note + + If you want to customize table names, you must change the table names before running database migrations. + See [Customizing Table Names](../customization/table_names.md). + +### Manual Setup + +1. Copy the **Auth.php**, **AuthGroups.php**, and **AuthToken.php** from **vendor/codeigniter4/shield/src/Config/** into your project's config folder and update the namespace to `Config`. You will also need to have these classes extend the original classes. See the example below. These files contain all the settings, group, and permission information for your application and will need to be modified to meet the needs of your site. + + ```php + // new file - app/Config/Auth.php + helpers = array_merge($this->helpers, ['setting']); + + // Do Not Edit This Line + parent::initController($request, $response, $logger); + } + ``` + + This requires that all of your controllers extend the `BaseController`, but that's a good practice anyway. + +3. **Routes Setup** The default auth routes can be setup with a single call in **app/Config/Routes.php**: + + ```php + service('auth')->routes($routes); + ``` + +4. **Security Setup** Set `Config\Security::$csrfProtection` to `'session'` for security reasons, if you use Session Authenticator. + +5. Configure **app/Config/Email.php** to allow Shield to send emails with the [Email Class](https://codeigniter.com/user_guide/libraries/email.html). + + ```php + get('/hmac/token', static function () { + $token = auth()->user()->generateHmacToken(service('request')->getVar('token_name')); + + return json_encode(['key' => $token->secret, 'secretKey' => $token->secret2]); +}); +``` + +You can access all the user's HMAC keys with the `hmacTokens()` method on that user. + +```php +$tokens = $user->hmacTokens(); +foreach ($tokens as $token) { + // +} +``` + +### Usage + +In order to use HMAC Keys/Token the `Authorization` header will be set to the following in the request: + +``` +Authorization: HMAC-SHA256 : +``` + +The code to do this will look something like this: + +```php +header("Authorization: HMAC-SHA256 {$key}:" . hash_hmac('sha256', $requestBody, $secretKey)); +``` + +## HMAC Keys Permissions + +HMAC keys can be given `scopes`, which are basically permission strings, for the HMAC Token/Keys. This is generally not +the same as the permission the user has, but is used to specify the permissions on the API itself. If not specified, the +token is granted all access to all scopes. This might be enough for a smaller API. + +```php +$token = $user->generateHmacToken('token-name', ['users-read']); +return json_encode(['key' => $token->secret, 'secretKey' => $token->secret2]); +``` + +!!! note + + At this time, scope names should avoid using a colon (`:`) as this causes issues with the route filters being + correctly recognized. + +When handling incoming requests you can check if the token has been granted access to the scope with the `hmacTokenCan()` method. + +```php +if ($user->hmacTokenCan('users-read')) { + // +} +``` + +### Revoking Keys/Tokens + +Tokens can be revoked by deleting them from the database with the `revokeHmacToken($key)` or `revokeAllHmacTokens()` methods. + +```php +$user->revokeHmacToken($key); +$user->revokeAllHmacTokens(); +``` + +## Protecting Routes + +The first way to specify which routes are protected is to use the `hmac` controller filter. + +For example, to ensure it protects all routes under the `/api` route group, you would use the `$filters` setting +on **app/Config/Filters.php**. + +```php +public $filters = [ + 'hmac' => ['before' => ['api/*']], +]; +``` + +You can also specify the filter should run on one or more routes within the routes file itself: + +```php +$routes->group('api', ['filter' => 'hmac'], function($routes) { + // +}); +$routes->get('users', 'UserController::list', ['filter' => 'hmac:users-read']); +``` + +When the filter runs, it checks the `Authorization` header for a `HMAC-SHA256` value that has the computed token. It then +parses the raw token and looks it up the `key` portion in the database. Once found, it will rehash the body of the request +to validate the remainder of the Authorization raw token. If it passes the signature test it can determine the correct user, +which will then be available through an `auth()->user()` call. + +!!! note + + Currently only a single scope can be used on a route filter. If multiple scopes are passed in, only the first one is checked. diff --git a/docs/guides/api_tokens.md b/docs/guides/api_tokens.md index ed9dee901..60f238b50 100644 --- a/docs/guides/api_tokens.md +++ b/docs/guides/api_tokens.md @@ -2,7 +2,9 @@ Access Tokens can be used to authenticate users for your own site, or when allowing third-party developers to access your API. When making requests using access tokens, the token should be included in the `Authorization` header as a `Bearer` token. -> **Note** By default, `$authenticatorHeader['tokens']` is set to `Authorization`. You can change this value by setting the `$authenticatorHeader['tokens']` value in the **app/Config/Auth.php** config file. +!!! note + + By default, `$authenticatorHeader['tokens']` is set to `Authorization`. You can change this value by setting the `$authenticatorHeader['tokens']` value in the **app/Config/AuthToken.php** config file. Tokens are issued with the `generateAccessToken()` method on the user. This returns a `CodeIgniter\Shield\Entities\AccessToken` instance. Tokens are hashed using a SHA-256 algorithm before being saved to the database. The access token returned when you generate it will include a `raw_token` field that contains the plain-text, un-hashed, token. You should display this to your user at once so they have a chance to copy it somewhere safe, as this is the only time this will be available. After this request, there is no way to get the raw token. @@ -33,8 +35,9 @@ Access tokens can be given `scopes`, which are basically permission strings, for return $user->generateAccessToken('token-name', ['users-read'])->raw_token; ``` -> **Note** -> At this time, scope names should avoid using a colon (`:`) as this causes issues with the route filters being correctly recognized. +!!! note + + At this time, scope names should avoid using a colon (`:`) as this causes issues with the route filters being correctly recognized. When handling incoming requests you can check if the token has been granted access to the scope with the `tokenCan()` method. @@ -46,10 +49,11 @@ if ($user->tokenCan('users-read')) { ### Revoking Tokens -Tokens can be revoked by deleting them from the database with the `revokeAccessToken($rawToken)` or `revokeAllAccessTokens()` methods. +Tokens can be revoked by deleting them from the database with the `revokeAccessToken($rawToken)`, `revokeAccessTokenBySecret($secret)` or `revokeAllAccessTokens()` methods. ```php $user->revokeAccessToken($rawToken); +$user->revokeAccessTokenBySecret($secret); $user->revokeAllAccessTokens(); ``` @@ -76,5 +80,6 @@ $routes->get('users', 'UserController::list', ['filter' => 'tokens:users-read']) When the filter runs, it checks the `Authorization` header for a `Bearer` value that has the raw token. It then hashes the raw token and looks it up in the database. Once found, it can determine the correct user, which will then be available through an `auth()->user()` call. -> **Note** -> Currently only a single scope can be used on a route filter. If multiple scopes are passed in, only the first one is checked. +!!! note + + Currently only a single scope can be used on a route filter. If multiple scopes are passed in, only the first one is checked. diff --git a/docs/guides/mobile_apps.md b/docs/guides/mobile_apps.md index 237eeeafd..c974bf496 100644 --- a/docs/guides/mobile_apps.md +++ b/docs/guides/mobile_apps.md @@ -26,7 +26,7 @@ class LoginController extends BaseController $rules = setting('Validation.login') ?? [ 'email' => [ 'label' => 'Auth.email', - 'rules' => config('AuthSession')->emailValidationRules, + 'rules' => config('Auth')->emailValidationRules, ], 'password' => [ 'label' => 'Auth.password', @@ -38,7 +38,7 @@ class LoginController extends BaseController ], ]; - if (! $this->validateData($this->request->getPost(), $rules)) { + if (! $this->validateData($this->request->getPost(), $rules, [], config('Auth')->DBGroup)) { return $this->response ->setJSON(['errors' => $this->validator->getErrors()]) ->setStatusCode(401); @@ -68,8 +68,7 @@ class LoginController extends BaseController When making all future requests to the API, the mobile client should return the raw token in the `Authorization` header as a `Bearer` token. -> **Note** -> -> By default, `$authenticatorHeader['tokens']` is set to `Authorization`. You can change the header name by setting the `$authenticatorHeader['tokens']` value in the **app/Config/Auth.php** config file. -> -> e.g. if `$authenticatorHeader['tokens']` is set to `PersonalAccessCodes` then the mobile client should return the raw token in the `PersonalAccessCodes` header as a `Bearer` token. +!!! note + + By default, `$authenticatorHeader['tokens']` is set to `Authorization`. You can change the header name by setting the `$authenticatorHeader['tokens']` value in the **app/Config/AuthToken.php** config file. + e.g. if `$authenticatorHeader['tokens']` is set to `PersonalAccessCodes` then the mobile client should return the raw token in the `PersonalAccessCodes` header as a `Bearer` token. diff --git a/docs/guides/strengthen_password.md b/docs/guides/strengthen_password.md index 5d9345022..fbf6dc9a0 100644 --- a/docs/guides/strengthen_password.md +++ b/docs/guides/strengthen_password.md @@ -15,13 +15,12 @@ It is the recommended minimum value by NIST. However, some organizations recomme The longer the password, the stronger it is. Consider increasing the value. -> **Note** -> -> This checking works when you validate passwords with the `strong_password` -> validation rule. -> -> If you disable `CompositionValidator` (enabled by default) in `$passwordValidators`, -> this checking will not work. +!!! note + + This checking works when you validate passwords with the `strong_password[]` + validation rule. + If you disable `CompositionValidator` (enabled by default) in `$passwordValidators`, + this checking will not work. ## Password Hashing Algorithm @@ -107,7 +106,7 @@ By default, Shield has the validation rules for maximum password length. - 72 bytes for PASSWORD_BCRYPT - 255 characters for others -You can customize the validation rule. See [Customizing Shield](../customization.md). +You can customize the validation rule. See [Customizing Validation Rules](../customization/validation_rules.md). ## $supportOldDangerousPassword @@ -117,6 +116,6 @@ setting for using passwords stored in older versions of Shield that were [vulner This setting is deprecated. If you have this setting set to `true`, you should change it to `false` as soon as possible, and remove old hashed password in your database. -> **Note** -> -> This setting will be removed in v1.0.0 official release. +!!! note + + This setting will be removed in v1.0.0 official release. diff --git a/docs/index.md b/docs/index.md index 8d1103e7a..dc4865884 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,23 +1,54 @@ # Shield Documentation -* [Installation Guide](install.md) -* [Concepts You Need To Know](concepts.md) -* [Quick Start Guide](quickstart.md) -* [Authentication](authentication.md) -* [Authorization](authorization.md) -* [Auth Actions](auth_actions.md) -* [Events](events.md) -* [Testing](testing.md) -* [Customization](customization.md) -* [Forcing Password Reset](forcing_password_reset.md) -* [Banning Users](banning_users.md) - -## Guides - -* [Protecting an API with Access Tokens](guides/api_tokens.md) -* [Mobile Authentication with Access Tokens](guides/mobile_apps.md) -* [How to Strengthen the Password](guides/strengthen_password.md) - -## Addons - -* [JWT Authentication](addons/jwt.md) +## What is Shield? 🤔 + +Shield is the official authentication and authorization framework for CodeIgniter 4. While +it does provide a base set of tools that are commonly used in websites, it is +designed to be flexible and easily customizable. + +### Primary Goals 🥅 + +The primary goals for Shield are: + +1. It must be very flexible and allow developers to extend/override almost any part of it. +2. It must have security at its core. It is an auth lib after all. +3. To cover many auth needs right out of the box, but be simple to add additional functionality to. + +### Important Features 🌠 + +* **Session-based Authentication** (traditional **ID/Password** with **Remember-me**) +* **Stateless Authentication** using **Access Token**, **HMAC SHA256 Token**, or **JWT** +* Optional **Email verification** on account registration +* Optional **Email-based Two-Factor Authentication** after login +* **Magic Link Login** when a user forgets their password +* Flexible **Group-based Access Control** (think Roles, but more flexible), and users can be granted additional **Permissions** +* A simple **Auth Helper** that provides access to the most common auth actions +* Save initial settings in your code, so it can be in version control, but can also be updated in the database, thanks to our [Settings](https://github.com/codeigniter4/settings) library +* Highly configurable +* **User Entity** and **User Provider** (`UserModel`) ready for you to use or extend +* Built to extend and modify + * Easily extendable controllers + * All required views that can be used as is or swapped out for your own + +### License 📑 + +Shield is licensed under the MIT License - see the [LICENSE](https://github.com/codeigniter4/shield/blob/develop/LICENSE) file for details. + +### Acknowledgements 🙌🏼 + +Every open-source project depends on it's contributors to be a success. The following users have +contributed in one manner or another in making Shield: + + + Contributors + + +Made with [contrib.rocks](https://contrib.rocks). + +The following articles/sites have been fundamental in shaping the security and best practices used +within this library, in no particular order: + +- [Google Cloud: 13 best practices for user account, authentication, and password management, 2021 edition](https://cloud.google.com/blog/products/identity-security/account-authentication-and-password-management-best-practices) +- [NIST Digital Identity Guidelines](https://pages.nist.gov/800-63-3/sp800-63b.html) +- [Implementing Secure User Authentication in PHP Applications with Long-Term Persistence (Login with "Remember Me" Cookies) ](https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence) +- [Password Storage - OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) diff --git a/docs/install.md b/docs/install.md deleted file mode 100644 index 74f5b3af4..000000000 --- a/docs/install.md +++ /dev/null @@ -1,294 +0,0 @@ -# Installation - -- [Installation](#installation) - - [Requirements](#requirements) - - [Composer Installation](#composer-installation) - - [Troubleshooting](#troubleshooting) - - [IMPORTANT: composer error](#important-composer-error) - - [Initial Setup](#initial-setup) - - [Command Setup](#command-setup) - - [Manual Setup](#manual-setup) - - [Controller Filters](#controller-filters) - - [Protect All Pages](#protect-all-pages) - - [Rate Limiting](#rate-limiting) - - [Forcing Password Reset](#forcing-password-reset) - -These instructions assume that you have already [installed the CodeIgniter 4 app starter](https://codeigniter.com/user_guide/installation/installing_composer.html) as the basis for your new project, set up your **.env** file, and created a database that you can access via the Spark CLI script. - -## Requirements - -- [Composer](https://getcomposer.org) -- Codeigniter **v4.2.7** or later -- A created database that you can access via the Spark CLI script - -## Composer Installation - -Installation is done through [Composer](https://getcomposer.org). The example assumes you have it installed globally. -If you have it installed as a phar, or otherwise you will need to adjust the way you call composer itself. - -```console -composer require codeigniter4/shield -``` - -### Troubleshooting - -#### IMPORTANT: composer error - -If you get the following error: - -```console -Could not find a version of package codeigniter4/shield matching your minimum-stability (stable). -Require it with an explicit version constraint allowing its desired stability. -``` - -1. Run the following commands to change your [minimum-stability](https://getcomposer.org/doc/articles/versions.md#minimum-stability) in your project `composer.json`: - - ```console - composer config minimum-stability dev - composer config prefer-stable true - ``` - -2. Or specify an explicit version: - - ```console - composer require codeigniter4/shield:dev-develop - ``` - - The above specifies `develop` branch. - See - - ```console - composer require codeigniter4/shield:^1.0.0-beta - ``` - - The above specifies `v1.0.0-beta` or later and before `v2.0.0`. - See - -## Initial Setup - -### Command Setup - -1. Run the following command. This command handles steps 1-5 of *Manual Setup* and runs the migrations. - - ```console - php spark shield:setup - ``` - - > **Note** If you want to customize table names, you must change the table names - > before running database migrations. - > See [Customizing Shield](./customization.md#custom-table-names). - -2. Configure **app/Config/Email.php** to allow Shield to send emails with the [Email Class](https://codeigniter.com/user_guide/libraries/email.html). - - ```php - helpers = array_merge($this->helpers, ['setting']); - - // Do Not Edit This Line - parent::initController($request, $response, $logger); - } - ``` - - This requires that all of your controllers extend the `BaseController`, but that's a good practice anyway. - -3. **Routes Setup** The default auth routes can be setup with a single call in **app/Config/Routes.php**: - - ```php - service('auth')->routes($routes); - ``` - -4. **Security Setup** Set `Config\Security::$csrfProtection` to `'session'` (or set `security.csrfProtection = session` in your **.env** file) for security reasons, if you use Session Authenticator. - -5. **Migration** Run the migrations. - - > **Note** If you want to customize table names, you must change the table names - > before running database migrations. - > See [Customizing Shield](./customization.md#custom-table-names). - - ```console - php spark migrate --all - ``` - - #### Note: migration error - - When you run `spark migrate --all`, if you get `Class "SQLite3" not found` error: - - 1. Remove sample migration files in **tests/_support/Database/Migrations/** - 2. Or install `sqlite3` php extension - - If you get `Specified key was too long` error: - - 1. Use InnoDB, not MyISAM. - -6. Configure **app/Config/Email.php** to allow Shield to send emails. - - ```php - \CodeIgniter\Shield\Filters\SessionAuth::class, - 'tokens' => \CodeIgniter\Shield\Filters\TokenAuth::class, - 'chain' => \CodeIgniter\Shield\Filters\ChainAuth::class, - 'auth-rates' => \CodeIgniter\Shield\Filters\AuthRates::class, - 'group' => \CodeIgniter\Shield\Filters\GroupFilter::class, - 'permission' => \CodeIgniter\Shield\Filters\PermissionFilter::class, - 'force-reset' => \CodeIgniter\Shield\Filters\ForcePasswordResetFilter::class, - 'jwt' => \CodeIgniter\Shield\Filters\JWTAuth::class, -]; -``` - -Filters | Description ---- | --- -session and tokens | The `Session` and `AccessTokens` authenticators, respectively. -chained | The filter will check both authenticators in sequence to see if the user is logged in through either of authenticators, allowing a single API endpoint to work for both an SPA using session auth, and a mobile app using access tokens. -jwt | The `JWT` authenticator. See [JWT Authentication](./addons/jwt.md). -auth-rates | Provides a good basis for rate limiting of auth-related routes. -group | Checks if the user is in one of the groups passed in. -permission | Checks if the user has the passed permissions. -force-reset | Checks if the user requires a password reset. - -These can be used in any of the [normal filter config settings](https://codeigniter.com/user_guide/incoming/filters.html#globals), or [within the routes file](https://codeigniter.com/user_guide/incoming/routing.html#applying-filters). - -> **Note** These filters are already loaded for you by the registrar class located at **src/Config/Registrar.php**. - -### Protect All Pages - -If you want to limit all routes (e.g. `localhost:8080/admin`, `localhost:8080/panel` and ...), you need to add the following code in the **app/Config/Filters.php** file. - -```php -public $globals = [ - 'before' => [ - // ... - 'session' => ['except' => ['login*', 'register', 'auth/a/*']], - ], - // ... -]; -``` - -### Rate Limiting - -To help protect your authentication forms from being spammed by bots, it is recommended that you use -the `auth-rates` filter on all of your authentication routes. This can be done with the following -filter setup: - -```php -public $filters = [ - 'auth-rates' => [ - 'before' => [ - 'login*', 'register', 'auth/*' - ] - ] -]; -``` - -### Forcing Password Reset - -If your application requires a force password reset functionality, ensure that you exclude the auth pages and the actual password reset page from the `before` global. This will ensure that your users do not run into a *too many redirects* error. See: - -```php -public $globals = [ - 'before' => [ - //... - //... - 'force-reset' => ['except' => ['login*', 'register', 'auth/a/*', 'change-password', 'logout']] - ] -]; -``` -In the example above, it is assumed that the page you have created for users to change their password after successful login is **change-password**. - -> **Note** If you have grouped or changed the default format of the routes, ensure that your code matches the new format(s) in the **app/Config/Filter.php** file. - -For example, if you configured your routes like so: - -```php -$routes->group('accounts', static function($routes) { - service('auth')->routes($routes); -}); -``` -Then the global `before` filter for `session` should look like so: - -```php -public $globals = [ - 'before' => [ - // ... - 'session' => ['except' => ['accounts/login*', 'accounts/register', 'accounts/auth/a/*']] - ] -] -``` -The same should apply for the Rate Limiting and Forcing Password Reset. diff --git a/docs/quick_start_guide/using_authorization.md b/docs/quick_start_guide/using_authorization.md new file mode 100644 index 000000000..36bd918e0 --- /dev/null +++ b/docs/quick_start_guide/using_authorization.md @@ -0,0 +1,129 @@ +# Using Authorization + +## Configuration + +### Change Available Groups + +The available groups are defined in the **app/Config/AuthGroups.php** config file, under the `$groups` property. Add new entries to the array, or remove existing ones to make them available throughout your application. + +```php +public array $groups = [ + 'superadmin' => [ + 'title' => 'Super Admin', + 'description' => 'Complete control of the site.', + ], + // +]; +``` + +### Set the Default Group + +When a user registers on your site, they are assigned the group specified at `Config\AuthGroups::$defaultGroup`. Change this to one of the keys in the `$groups` array to update this. + +### Change Available Permissions + +The permissions on the site are stored in the `AuthGroups` config file also. Each one is defined by a string that represents a context and a permission, joined with a decimal point. + +```php +public array $permissions = [ + 'admin.access' => 'Can access the sites admin area', + 'admin.settings' => 'Can access the main site settings', + 'users.manage-admins' => 'Can manage other admins', + 'users.create' => 'Can create new non-admin users', + 'users.edit' => 'Can edit existing non-admin users', + 'users.delete' => 'Can delete existing non-admin users', + 'beta.access' => 'Can access beta-level features', +]; +``` + +### Assign Permissions to a Group + +Each group can have its own specific set of permissions. These are defined in `Config\AuthGroups::$matrix`. You can specify each permission by it's full name, or using the context and an asterisk (*) to specify all permissions within that context. + +```php +public array $matrix = [ + 'superadmin' => [ + 'admin.*', + 'users.*', + 'beta.access', + ], + // +]; +``` + +## Assign Permissions to a User + +Permissions can also be assigned directly to a user, regardless of what groups they belong to. This is done programatically on the `User` Entity. + +```php +$user = auth()->user(); + +$user->addPermission('users.create', 'beta.access'); +``` + +This will add all new permissions. You can also sync permissions so that the user ONLY has the given permissions directly assigned to them. Any not in the provided list are removed from the user. + +```php +$user = auth()->user(); + +$user->syncPermissions('users.create', 'beta.access'); +``` + +## Check If a User Has Permission + +When you need to check if a user has a specific permission use the `can()` method on the `User` entity. This method checks permissions within the groups they belong to and permissions directly assigned to the user. + +```php +if (! auth()->user()->can('users.create')) { + return redirect()->back()->with('error', 'You do not have permissions to access that page.'); +} +``` + +!!! note + + The example above can also be done through a [controller filter](https://codeigniter.com/user_guide/incoming/filters.html) if you want to apply it to multiple pages of your site. + +## Adding a Group To a User + +Groups are assigned to a user via the `addGroup()` method. You can pass multiple groups in and they will all be assigned to the user. + +```php +$user = auth()->user(); +$user->addGroup('admin', 'beta'); +``` + +This will add all new groups. You can also sync groups so that the user ONLY belongs to the groups directly assigned to them. Any not in the provided list are removed from the user. + +```php +$user = auth()->user(); +$user->syncGroups('admin', 'beta'); +``` + +## Removing a Group From a User + +Groups are removed from a user via the `removeGroup()` method. Multiple groups may be removed at once by passing all of their names into the method. + +```php +$user = auth()->user(); +$user->removeGroup('admin', 'beta'); +``` + +## Checking If User Belongs To a Group + +You can check if a user belongs to a group with the `inGroup()` method. + +```php +$user = auth()->user(); +if ($user->inGroup('admin')) { + // do something +} +``` + +You can pass more than one group to the method and it will return `true` if the user belongs to any of the specified groups. + +```php +$user = auth()->user(); +if ($user->inGroup('admin', 'beta')) { + // do something +} +``` diff --git a/docs/quick_start_guide/using_session_auth.md b/docs/quick_start_guide/using_session_auth.md new file mode 100644 index 000000000..645d2a1b2 --- /dev/null +++ b/docs/quick_start_guide/using_session_auth.md @@ -0,0 +1,110 @@ +# Using Session Authenticator + +**Session** authenticator provides traditional ID/Password authentication. + +Learning any new authentication system can be difficult, especially as they get more flexible and sophisticated. This guide is intended to provide short examples for common actions you'll take when working with Shield. It is not intended to be the exhaustive documentation for each section. That's better handled through the area-specific doc files. + +!!! note + + The examples assume that you have run the setup script and that you have copies of the `Auth` and `AuthGroups` config files in your application's **app/Config** folder. + +## Configuration + +### Configure Redirect URLs + +If you need everyone to redirect to a single URL after login/logout/register actions, you can modify the `Config\Auth::$redirects` array in **app/Config/Auth.php** to specify the url to redirect to. + +By default, a successful login or register attempt will all redirect to `/`, while a logout action +will redirect to a [named route](https://codeigniter.com/user_guide/incoming/routing.html#using-named-routes "See routing docs") `login` or a *URI path* `/login`. You can change the default URLs used within the **app/Config/Auth.php** config file: + +```php +public array $redirects = [ + 'register' => '/', + 'login' => '/', + 'logout' => 'login', +]; +``` + +!!! note + + This redirect happens after the specified action is complete. In the case of register or login, it might not happen immediately. For example, if you have any Auth Actions specified, they will be redirected when those actions are completed successfully. If no Auth Actions are specified, they will be redirected immediately after registration or login. + +### Configure Remember-me Functionality + +Remember-me functionality is enabled by default. While this is handled in a secure manner, some sites may want it disabled. You might also want to change how long it remembers a user and doesn't require additional login. + +```php +public array $sessionConfig = [ + 'field' => 'user', + 'allowRemembering' => true, + 'rememberCookieName' => 'remember', + 'rememberLength' => 30 * DAY, +]; +``` + +### Enable Account Activation via Email + +!!! note + + You need to configure **app/Config/Email.php** to allow Shield to send emails. See [Installation](../getting_started/install.md#initial-setup). + +By default, once a user registers they have an active account that can be used. You can enable Shield's built-in, email-based activation flow within the `Auth` config file. + +```php +public array $actions = [ + 'register' => \CodeIgniter\Shield\Authentication\Actions\EmailActivator::class, + 'login' => null, +]; +``` + +### Enable Two-Factor Authentication + +!!! note + + You need to configure **app/Config/Email.php** to allow Shield to send emails. See [Installation](../getting_started/install.md#initial-setup). + +Turned off by default, Shield's Email-based 2FA can be enabled by specifying the class to use in the `Auth` config file. + +```php +public array $actions = [ + 'register' => null, + 'login' => \CodeIgniter\Shield\Authentication\Actions\Email2FA::class, +]; +``` + +## Customizing Routes + +If you need to customize how any of the auth features are handled, you can still +use the `service('auth')->routes()` helper, but you will need to pass the `except` +option with a list of routes to customize: + +```php +service('auth')->routes($routes, ['except' => ['login', 'register']]); +``` + +Then add the routes to your customized controllers: + +```php +$routes->get('login', '\App\Controllers\Auth\LoginController::loginView'); +$routes->get('register', '\App\Controllers\Auth\RegisterController::registerView'); +``` + +Check your routes with the [spark routes](https://codeigniter.com/user_guide/incoming/routing.html#spark-routes) +command. + +## Protecting Pages + +If you want to limit all routes (e.g. `localhost:8080/admin`, `localhost:8080/panel` and ...), you need to add the following code in the **app/Config/Filters.php** file. + +```php +public $globals = [ + 'before' => [ + // ... + 'session' => ['except' => ['login*', 'register', 'auth/a/*']], + ], + // ... +]; +``` + +Check your filters with the [spark routes](https://codeigniter.com/user_guide/incoming/routing.html#spark-routes) +command. diff --git a/docs/quickstart.md b/docs/quickstart.md deleted file mode 100644 index 49e25a38f..000000000 --- a/docs/quickstart.md +++ /dev/null @@ -1,319 +0,0 @@ -# Quick Start Guide - -Learning any new authentication system can be difficult, especially as they get more flexible and sophisticated. This guide is intended to provide short examples for common actions you'll take when working with Shield. It is not intended to be the exhaustive documentation for each section. That's better handled through the area-specific doc files. - -> **Note** The examples assume that you have run the setup script and that you have copies of the `Auth` and `AuthGroups` config files in your application's **app/Config** folder. - -- [Quick Start Guide](#quick-start-guide) - - [Authentication Flow](#authentication-flow) - - [Configure Config\\Auth](#configure-configauth) - - [Configure Redirect URLs](#configure-redirect-urls) - - [Configure Remember-me Functionality](#configure-remember-me-functionality) - - [Change Access Token Lifetime](#change-access-token-lifetime) - - [Enable Account Activation via Email](#enable-account-activation-via-email) - - [Enable Two-Factor Authentication](#enable-two-factor-authentication) - - [Responding to Magic Link Logins](#responding-to-magic-link-logins) - - [Session Notification](#session-notification) - - [Event](#event) - - [Authorization Flow](#authorization-flow) - - [Configure Config\\AuthGroups](#configure-configauthgroups) - - [Change Available Groups](#change-available-groups) - - [Set the Default Group](#set-the-default-group) - - [Change Available Permissions](#change-available-permissions) - - [Assign Permissions to a Group](#assign-permissions-to-a-group) - - [Assign Permissions to a User](#assign-permissions-to-a-user) - - [Check If a User Has Permission](#check-if-a-user-has-permission) - - [Adding a Group To a User](#adding-a-group-to-a-user) - - [Removing a Group From a User](#removing-a-group-from-a-user) - - [Checking If User Belongs To a Group](#checking-if-user-belongs-to-a-group) - - [Managing Users](#managing-users) - - [Creating Users](#creating-users) - - [Deleting Users](#deleting-users) - - [Editing a User](#editing-a-user) - -## Authentication Flow - -### Configure Config\Auth - -#### Configure Redirect URLs - -If you need everyone to redirect to a single URL after login/logout/register actions, you can modify the `Config\Auth::$redirects` array in **app/Config/Auth.php**`** to specify the url to redirect to. - -By default, a successful login or register attempt will all redirect to `/`, while a logout action -will redirect to a [named route](https://codeigniter.com/user_guide/incoming/routing.html#using-named-routes "See routing docs") `login` or a *URI path* `/login`. You can change the default URLs used within the **`**app/Config/Auth.php** config file: - -```php -public array $redirects = [ - 'register' => '/', - 'login' => '/', - 'logout' => 'login', -]; -``` - -> **Note** This redirect happens after the specified action is complete. In the case of register or login, it might not happen immediately. For example, if you have any Auth Actions specified, they will be redirected when those actions are completed successfully. If no Auth Actions are specified, they will be redirected immediately after registration or login. - -#### Configure Remember-me Functionality - -Remember-me functionality is enabled by default for the `Session` handler. While this is handled in a secure manner, some sites may want it disabled. You might also want to change how long it remembers a user and doesn't require additional login. - -```php -public array $sessionConfig = [ - 'field' => 'user', - 'allowRemembering' => true, - 'rememberCookieName' => 'remember', - 'rememberLength' => 30 * DAY, -]; -``` - -#### Change Access Token Lifetime - -By default, Access Tokens can be used for 1 year since the last use. This can be easily modified in the `Auth` config file. - -```php -public int $unusedTokenLifetime = YEAR; -``` - -#### Enable Account Activation via Email - -> **Note** You need to configure **app/Config/Email.php** to allow Shield to send emails. See [Installation](install.md#initial-setup). - -By default, once a user registers they have an active account that can be used. You can enable Shield's built-in, email-based activation flow within the `Auth` config file. - -```php -public array $actions = [ - 'register' => \CodeIgniter\Shield\Authentication\Actions\EmailActivator::class, - 'login' => null, -]; -``` - -#### Enable Two-Factor Authentication - -> **Note** You need to configure **app/Config/Email.php** to allow Shield to send emails. See [Installation](install.md#initial-setup). - -Turned off by default, Shield's Email-based 2FA can be enabled by specifying the class to use in the `Auth` config file. - -```php -public array $actions = [ - 'register' => null, - 'login' => \CodeIgniter\Shield\Authentication\Actions\Email2FA::class, -]; -``` - -### Responding to Magic Link Logins - -> **Note** You need to configure **app/Config/Email.php** to allow Shield to send emails. See [Installation](install.md#initial-setup). - -Magic Link logins allow a user that has forgotten their password to have an email sent with a unique, one-time login link. Once they've logged in you can decide how to respond. In some cases, you might want to redirect them to a special page where they must choose a new password. In other cases, you might simply want to display a one-time message prompting them to go to their account page and choose a new password. - -#### Session Notification - -You can detect if a user has finished the magic link login by checking for a session value, `magicLogin`. If they have recently completed the flow, it will exist and have a value of `true`. - -```php -if (session('magicLogin')) { - return redirect()->route('set_password'); -} -``` - -This value sticks around in the session for 5 minutes. Once you no longer need to take any actions, you might want to delete the value from the session. - -```php -session()->removeTempdata('magicLogin'); -``` - -#### Event - -At the same time the above session variable is set, a `magicLogin` [event](https://codeigniter.com/user_guide/extending/events.html) is fired off that you may subscribe to. Note that no data is passed to the event as you can easily grab the current user from the `user()` helper or the `auth()->user()` method. - -```php -Events::on('magicLogin', static function () { - // ... -}); -``` - - -## Authorization Flow - -### Configure Config\AuthGroups - -#### Change Available Groups - -The available groups are defined in the **app/Config/AuthGroups.php** config file, under the `$groups` property. Add new entries to the array, or remove existing ones to make them available throughout your application. - -```php -public array $groups = [ - 'superadmin' => [ - 'title' => 'Super Admin', - 'description' => 'Complete control of the site.', - ], - // -]; -``` - -#### Set the Default Group - -When a user registers on your site, they are assigned the group specified at `Config\AuthGroups::$defaultGroup`. Change this to one of the keys in the `$groups` array to update this. - -#### Change Available Permissions - -The permissions on the site are stored in the `AuthGroups` config file also. Each one is defined by a string that represents a context and a permission, joined with a decimal point. - -```php -public array $permissions = [ - 'admin.access' => 'Can access the sites admin area', - 'admin.settings' => 'Can access the main site settings', - 'users.manage-admins' => 'Can manage other admins', - 'users.create' => 'Can create new non-admin users', - 'users.edit' => 'Can edit existing non-admin users', - 'users.delete' => 'Can delete existing non-admin users', - 'beta.access' => 'Can access beta-level features', -]; -``` - -#### Assign Permissions to a Group - -Each group can have its own specific set of permissions. These are defined in `Config\AuthGroups::$matrix`. You can specify each permission by it's full name, or using the context and an asterisk (*) to specify all permissions within that context. - -```php -public array $matrix = [ - 'superadmin' => [ - 'admin.*', - 'users.*', - 'beta.access', - ], - // -]; -``` - -#### Assign Permissions to a User - -Permissions can also be assigned directly to a user, regardless of what groups they belong to. This is done programatically on the `User` Entity. - -```php -$user = auth()->user(); - -$user->addPermission('users.create', 'beta.access'); -``` - -This will add all new permissions. You can also sync permissions so that the user ONLY has the given permissions directly assigned to them. Any not in the provided list are removed from the user. - -```php -$user = auth()->user(); - -$user->syncPermissions('users.create', 'beta.access'); -``` - -## Check If a User Has Permission - -When you need to check if a user has a specific permission use the `can()` method on the `User` entity. This method checks permissions within the groups they belong to and permissions directly assigned to the user. - -```php -if (! auth()->user()->can('users.create')) { - return redirect()->back()->with('error', 'You do not have permissions to access that page.'); -} -``` - -> **Note** The example above can also be done through a [controller filter](https://codeigniter.com/user_guide/incoming/filters.html) if you want to apply it to multiple pages of your site. - -### Adding a Group To a User - -Groups are assigned to a user via the `addGroup()` method. You can pass multiple groups in and they will all be assigned to the user. - -```php -$user = auth()->user(); -$user->addGroup('admin', 'beta'); -``` - -This will add all new groups. You can also sync groups so that the user ONLY belongs to the groups directly assigned to them. Any not in the provided list are removed from the user. - -```php -$user = auth()->user(); -$user->syncGroups('admin', 'beta'); -``` - -### Removing a Group From a User - -Groups are removed from a user via the `removeGroup()` method. Multiple groups may be removed at once by passing all of their names into the method. - -```php -$user = auth()->user(); -$user->removeGroup('admin', 'beta'); -``` - -### Checking If User Belongs To a Group - -You can check if a user belongs to a group with the `inGroup()` method. - -```php -$user = auth()->user(); -if ($user->inGroup('admin')) { - // do something -} -``` - -You can pass more than one group to the method and it will return `true` if the user belongs to any of the specified groups. - -```php -$user = auth()->user(); -if ($user->inGroup('admin', 'beta')) { - // do something -} -``` - -## Managing Users - -Since Shield uses a more complex user setup than many other systems, separating [User Identities](concepts.md#user-identities) from the user accounts themselves. This quick overview should help you feel more confident when working with users on a day-to-day basis. - -### Creating Users - -By default, the only values stored in the users table is the username. The first step is to create the user record with the username. If you don't have a username, be sure to set the value to `null` anyway, so that it passes CodeIgniter's empty data check. - -```php -use CodeIgniter\Shield\Entities\User; - -// Get the User Provider (UserModel by default) -$users = auth()->getProvider(); - -$user = new User([ - 'username' => 'foo-bar', - 'email' => 'foo.bar@example.com', - 'password' => 'secret plain text password', -]); -$users->save($user); - -// To get the complete user object with ID, we need to get from the database -$user = $users->findById($users->getInsertID()); - -// Add to default group -$users->addToDefaultGroup($user); -``` - -### Deleting Users - -A user's data can be spread over a few different tables so you might be concerned about how to delete all of the user's data from the system. This is handled automatically at the database level for all information that Shield knows about, through the `onCascade` settings of the table's foreign keys. You can delete a user like any other entity. - -```php -// Get the User Provider (UserModel by default) -$users = auth()->getProvider(); - -$users->delete($user->id, true); -``` - -> **Note** The User rows use [soft deletes](https://codeigniter.com/user_guide/models/model.html#usesoftdeletes) so they are not actually deleted from the database unless the second parameter is `true`, like above. - -### Editing a User - -The `UserModel::save()`, `update()` and `insert()` methods have been modified to ensure that an email or password previously set on the `User` entity will be automatically updated in the correct `UserIdentity` record. - -```php -// Get the User Provider (UserModel by default) -$users = auth()->getProvider(); - -$user = $users->findById(123); -$user->fill([ - 'username' => 'JoeSmith111', - 'email' => 'joe.smith@example.com', - 'password' => 'secret123' -]); -$users->save($user); -``` diff --git a/docs/auth_actions.md b/docs/references/authentication/auth_actions.md similarity index 83% rename from docs/auth_actions.md rename to docs/references/authentication/auth_actions.md index 4fb2a5866..9f6379c17 100644 --- a/docs/auth_actions.md +++ b/docs/references/authentication/auth_actions.md @@ -1,9 +1,5 @@ # Authentication Actions -- [Authentication Actions](#authentication-actions) - - [Configuring Actions](#configuring-actions) - - [Defining New Actions](#defining-new-actions) - Authentication Actions are a way to group actions that can happen after login or registration. Shield ships with two actions you can use, and makes it simple for you to define your own. @@ -49,13 +45,13 @@ $routes->post('auth/a/verify', 'ActionController::verify'); Views for all of these pages are defined in the `Auth` config file, with the `$views` array. ```php - public $views = [ - 'action_email_2fa' => '\CodeIgniter\Shield\Views\email_2fa_show', - 'action_email_2fa_verify' => '\CodeIgniter\Shield\Views\email_2fa_verify', - 'action_email_2fa_email' => '\CodeIgniter\Shield\Views\Email\email_2fa_email', - 'action_email_activate_show' => '\CodeIgniter\Shield\Views\email_activate_show', - 'action_email_activate_email' => '\CodeIgniter\Shield\Views\Email\email_activate_email', - ]; +public $views = [ + 'action_email_2fa' => '\CodeIgniter\Shield\Views\email_2fa_show', + 'action_email_2fa_verify' => '\CodeIgniter\Shield\Views\email_2fa_verify', + 'action_email_2fa_email' => '\CodeIgniter\Shield\Views\Email\email_2fa_email', + 'action_email_activate_show' => '\CodeIgniter\Shield\Views\email_activate_show', + 'action_email_activate_email' => '\CodeIgniter\Shield\Views\Email\email_activate_email', +]; ``` ## Defining New Actions diff --git a/docs/references/authentication/authentication.md b/docs/references/authentication/authentication.md new file mode 100644 index 000000000..1ade0ffed --- /dev/null +++ b/docs/references/authentication/authentication.md @@ -0,0 +1,70 @@ +# Authentication + +Authentication is the process of determining that a visitor actually belongs to your website, +and identifying them. Shield provides a flexible and secure authentication system for your +web apps and APIs. + +## Available Authenticators + +Shield ships with 4 authenticators that will serve several typical situations within web app development. +You can see the [Authenticator List](../../getting_started/authenticators.md). + +The available authenticators are defined in `Config\Auth`: + +```php +public $authenticators = [ + // alias => classname + 'session' => Session::class, + 'tokens' => AccessTokens::class, + 'hmac' => HmacSha256::class, + // 'jwt' => JWT::class, +]; +``` + +The default authenticator is also defined in the configuration file, and uses the alias given above: + +```php +public $defaultAuthenticator = 'session'; +``` + +## Auth Helper + +The auth functionality is designed to be used with the `auth_helper` that comes with Shield. This +helper method provides the `auth()` function which returns a convenient interface to the most frequently +used functionality within the auth libraries. + +```php +// get the current user +auth()->user(); + +// get the current user's id +auth()->id(); +// or +user_id(); + +// get the User Provider (UserModel by default) +auth()->getProvider(); +``` + +!!! note + + The `auth_helper` is autoloaded by Composer. If you want to *override* the functions, + you need to define them in **app/Common.php**. + +## Authenticator Responses + +Many of the authenticator methods will return a `CodeIgniter\Shield\Result` class. This provides a consistent +way of checking the results and can have additional information returned along with it. The class +has the following methods: + +### isOK() + +Returns a boolean value stating whether the check was successful or not. + +### reason() + +Returns a message that can be displayed to the user when the check fails. + +### extraInfo() + +Can return a custom bit of information. These will be detailed in the method descriptions below. diff --git a/docs/references/authentication/hmac.md b/docs/references/authentication/hmac.md new file mode 100644 index 000000000..c8c504453 --- /dev/null +++ b/docs/references/authentication/hmac.md @@ -0,0 +1,158 @@ +# HMAC SHA256 Token Authenticator + +The HMAC-SHA256 authenticator supports the use of revocable API keys without using OAuth. This provides +an alternative to a token that is passed in every request and instead uses a shared secret that is used to sign +the request in a secure manner. Like authorization tokens, these are commonly used to provide third-party developers +access to your API. These keys typically have a very long expiration time, often years. + +These are also suitable for use with mobile applications. In this case, the user would register/sign-in +with their email/password. The application would create a new access token for them, with a recognizable +name, like John's iPhone 12, and return it to the mobile application, where it is stored and used +in all future requests. + +!!! note + + For the purpose of this documentation, and to maintain a level of consistency with the Authorization Tokens, + the term "Token" will be used to represent a set of API Keys (key and secretKey). + +## Usage + +In order to use HMAC Keys/Token the `Authorization` header will be set to the following in the request: + +``` +Authorization: HMAC-SHA256 : +``` + +The code to do this will look something like this: + +```php +header("Authorization: HMAC-SHA256 {$key}:" . hash_hmac('sha256', $requestBody, $secretKey)); +``` + +Using the CodeIgniter CURLRequest class: + +```php +setHeader('Authorization', "HMAC-SHA256 {$key}:{$hashValue}") + ->setBody($requestBody) + ->request('POST', 'https://example.com/api'); +``` + +## HMAC Keys/API Authentication + +Using HMAC keys requires that you either use/extend `CodeIgniter\Shield\Models\UserModel` or +use the `CodeIgniter\Shield\Authentication\Traits\HasHmacTokens` on your own user model. This trait +provides all the custom methods needed to implement HMAC keys in your application. The necessary +database table, `auth_identities`, is created in Shield's only migration class, which must be run +before first using any of the features of Shield. + +## Generating HMAC Access Keys + +Access keys/tokens are created through the `generateHmacToken()` method on the user. This takes a name to +give to the token as the first argument. The name is used to display it to the user, so they can +differentiate between multiple tokens. + +```php +$token = $user->generateHmacToken('Work Laptop'); +``` + +This creates the keys/tokens using a cryptographically secure random string. The keys operate as shared keys. +This means they are stored as-is in the database. The method returns an instance of +`CodeIgniters\Shield\Authentication\Entities\AccessToken`. The field `secret` is the 'key' the field `secret2` is +the shared 'secretKey'. Both are required to when using this authentication method. + +**The plain text version of these keys should be displayed to the user immediately, so they can copy it for +their use.** It is recommended that after that only the 'key' field is displayed to a user. If a user loses the +'secretKey', they should be required to generate a new set of keys to use. + +```php +$token = $user->generateHmacToken('Work Laptop'); + +echo 'Key: ' . $token->secret; +echo 'SecretKey: ' . $token->secret2; +``` + +## Revoking HMAC Keys + +HMAC keys can be revoked through the `revokeHmacToken()` method. This takes the key as the only +argument. Revoking simply deletes the record from the database. + +```php +$user->revokeHmacToken($key); +``` + +You can revoke all HMAC Keys with the `revokeAllHmacTokens()` method. + +```php +$user->revokeAllHmacTokens(); +``` + +## Retrieving HMAC Keys + +The following methods are available to help you retrieve a user's HMAC keys: + +```php +// Retrieve a set of HMAC Token/Keys by key +$token = $user->getHmacToken($key); + +// Retrieve an HMAC token/keys by its database ID +$token = $user->getHmacTokenById($id); + +// Retrieve all HMAC tokens as an array of AccessToken instances. +$tokens = $user->hmacTokens(); +``` + +## HMAC Keys Lifetime + +HMAC Keys/Tokens will expire after a specified amount of time has passed since they have been used. +This uses the same configuration value as AccessTokens. + +By default, this is set to 1 year. You can change this value by setting the `$unusedTokenLifetime` +value in the **app/Config/AuthToken.php** config file. This is in seconds so that you can use the +[time constants](https://codeigniter.com/user_guide/general/common_functions.html#time-constants) +that CodeIgniter provides. + +```php +public $unusedTokenLifetime = YEAR; +``` + +## HMAC Keys Scopes + +Each token (set of keys) can be given one or more scopes they can be used within. These can be thought of as +permissions the token grants to the user. Scopes are provided when the token is generated and +cannot be modified afterword. + +```php +$token = $user->gererateHmacToken('Work Laptop', ['posts.manage', 'forums.manage']); +``` + +By default, a user is granted a wildcard scope which provides access to all scopes. This is the +same as: + +```php +$token = $user->gererateHmacToken('Work Laptop', ['*']); +``` + +During authentication, the HMAC Keys the user used is stored on the user. Once authenticated, you +can use the `hmacTokenCan()` and `hmacTokenCant()` methods on the user to determine if they have access +to the specified scope. + +```php +if ($user->hmacTokenCan('posts.manage')) { + // do something.... +} + +if ($user->hmacTokenCant('forums.manage')) { + // do something.... +} +``` diff --git a/docs/references/authentication/session.md b/docs/references/authentication/session.md new file mode 100644 index 000000000..9245ebf7e --- /dev/null +++ b/docs/references/authentication/session.md @@ -0,0 +1,135 @@ +# Session Authenticator + +The Session authenticator stores the user's authentication within the user's session, and on a secure cookie +on their device. This is the standard password-based login used in most web sites. It supports a +secure remember-me feature, and more. This can also be used to handle authentication for +single page applications (SPAs). + +## Method References + +### attempt() + +When a user attempts to login with their email and password, you would call the `attempt()` method +on the auth class, passing in their credentials. + +```php +$credentials = [ + 'email' => $this->request->getPost('email'), + 'password' => $this->request->getPost('password') +]; + +$loginAttempt = auth()->attempt($credentials); + +if (! $loginAttempt->isOK()) { + return redirect()->back()->with('error', $loginAttempt->reason()); +} +``` + +Upon a successful `attempt()`, the user is logged in. The Response object returned will provide +the user that was logged in as `extraInfo()`. + +```php +$result = auth()->attempt($credentials); + +if ($result->isOK()) { + $user = $result->extraInfo(); +} +``` + +If the attempt fails a `failedLogin` event is triggered with the credentials array as +the only parameter. Whether or not they pass, a login attempt is recorded in the `auth_logins` table. + +If `allowRemembering` is `true` in the `Auth` config file, you can tell the Session authenticator +to set a secure remember-me cookie. + +```php +$loginAttempt = auth()->remember()->attempt($credentials); +``` + +### check() + +If you would like to check a user's credentials without logging them in, you can use the `check()` +method. + +```php +$credentials = [ + 'email' => $this->request->getPost('email'), + 'password' => $this->request->getPost('password') +]; + +$validCreds = auth()->check($credentials); + +if (! $validCreds->isOK()) { + return redirect()->back()->with('error', $validCreds->reason()); +} +``` + +The Result instance returned contains the valid user as `extraInfo()`. + +### loggedIn() + +You can determine if a user is currently logged in with the aptly titled method, `loggedIn()`. + +```php +if (auth()->loggedIn()) { + // Do something. +} +``` + +### logout() + +You can call the `logout()` method to log the user out of the current session. This will destroy and +regenerate the current session, purge any remember-me tokens current for this user, and trigger a +`logout` event. + +```php +auth()->logout(); +``` + +### forget() + +The `forget` method will purge all remember-me tokens for the current user, making it so they +will not be remembered on the next visit to the site. + +## Events and Logging + +The following is a list of Events and Logging for Session Authenticator. + +### Register + +- Default Register + - Post email/username/password + - OK → event `register` and `login` + - NG → no event +- Register with Email Activation + 1. Post email/username/password + - OK → event `register` + - NG → no event + 2. Post token + - OK → event `login` + - NG → no event + +### Login + +- Default Login + - Post email/password + - OK → event `login` / table `auth_logins` + - NG → event `failedLogin` / table `auth_logins` +- Email2FA Login + 1. Post email/password + - OK → no event / table `auth_logins` + - NG → event `failedLogin` / table `auth_logins` + 2. Post token + - OK → event `login` + - NG → no event +- Remember-me + - Send remember-me cookie w/o session cookie + - OK → no event + - NG → no event +- Magic-link + 1. Post email + - OK → no event + - NG → no event + 2. Send request with token + - OK → event `login` and `magicLogin` / table `auth_logins` + - NG → event `failedLogin` / table `auth_logins` diff --git a/docs/references/authentication/tokens.md b/docs/references/authentication/tokens.md new file mode 100644 index 000000000..b97cb6769 --- /dev/null +++ b/docs/references/authentication/tokens.md @@ -0,0 +1,127 @@ +# Access Token Authenticator + +The Access Token authenticator supports the use of revoke-able API tokens without using OAuth. These are commonly +used to provide third-party developers access to your API. These tokens typically have a very long +expiration time, often years. + +These are also suitable for use with mobile applications. In this case, the user would register/sign-in +with their email/password. The application would create a new access token for them, with a recognizable +name, like John's iPhone 12, and return it to the mobile application, where it is stored and used +in all future requests. + +## Access Token/API Authentication + +Using access tokens requires that you either use/extend `CodeIgniter\Shield\Models\UserModel` or +use the `CodeIgniter\Shield\Authentication\Traits\HasAccessTokens` on your own user model. This trait +provides all of the custom methods needed to implement access tokens in your application. The necessary +database table, `auth_identities`, is created in Shield's only migration class, which must be run +before first using any of the features of Shield. + +## Generating Access Tokens + +Access tokens are created through the `generateAccessToken()` method on the user. This takes a name to +give to the token as the first argument. The name is used to display it to the user so they can +differentiate between multiple tokens. + +```php +$token = $user->generateAccessToken('Work Laptop'); +``` + +This creates the token using a cryptographically secure random string. The token +is hashed (sha256) before saving it to the database. The method returns an instance of +`CodeIgniters\Shield\Authentication\Entities\AccessToken`. The only time a plain text +version of the token is available is in the `AccessToken` returned immediately after creation. + +**The plain text version should be displayed to the user immediately so they can copy it for +their use.** If a user loses it, they cannot see the raw version anymore, but they can generate +a new token to use. + +```php +$token = $user->generateAccessToken('Work Laptop'); + +// Only available immediately after creation. +echo $token->raw_token; +``` + +## Revoking Access Tokens + +Access tokens can be revoked through the `revokeAccessToken()` method. This takes the plain-text +access token as the only argument. Revoking simply deletes the record from the database. + +```php +$user->revokeAccessToken($token); +``` + +Typically, the plain text token is retrieved from the request's headers as part of the authentication +process. If you need to revoke the token for another user as an admin, and don't have access to the +token, you would need to get the user's access tokens and delete them manually. + +If you don't have the raw token usable to remove the token there is the possibility to remove it using the tokens secret thats stored in the database. It's possible to get a list of all tokens with there secret using the `accessTokens()` function. + +```php +$user->revokeAccessTokenBySecret($secret); +``` + +You can revoke all access tokens with the `revokeAllAccessTokens()` method. + +```php +$user->revokeAllAccessTokens(); +``` + +## Retrieving Access Tokens + +The following methods are available to help you retrieve a user's access tokens: + +```php +// Retrieve a single token by plain text token +$token = $user->getAccessToken($rawToken); + +// Retrieve a single token by it's database ID +$token = $user->getAccessTokenById($id); + +// Retrieve all access tokens as an array of AccessToken instances. +$tokens = $user->accessTokens(); +``` + +## Access Token Lifetime + +Tokens will expire after a specified amount of time has passed since they have been used. +By default, this is set to 1 year. You can change this value by setting the `$unusedTokenLifetime` +value in the **app/Config/AuthToken.php** config file. This is in seconds so that you can use the +[time constants](https://codeigniter.com/user_guide/general/common_functions.html#time-constants) +that CodeIgniter provides. + +```php +public $unusedTokenLifetime = YEAR; +``` + +## Access Token Scopes + +Each token can be given one or more scopes they can be used within. These can be thought of as +permissions the token grants to the user. Scopes are provided when the token is generated and +cannot be modified afterword. + +```php +$token = $user->gererateAccessToken('Work Laptop', ['posts.manage', 'forums.manage']); +``` + +By default a user is granted a wildcard scope which provides access to all scopes. This is the +same as: + +```php +$token = $user->gererateAccessToken('Work Laptop', ['*']); +``` + +During authentication, the token the user used is stored on the user. Once authenticated, you +can use the `tokenCan()` and `tokenCant()` methods on the user to determine if they have access +to the specified scope. + +```php +if ($user->tokenCan('posts.manage')) { + // do something.... +} + +if ($user->tokenCant('forums.manage')) { + // do something.... +} +``` diff --git a/docs/authorization.md b/docs/references/authorization.md similarity index 78% rename from docs/authorization.md rename to docs/references/authorization.md index 16ac5a797..81c1e6a14 100644 --- a/docs/authorization.md +++ b/docs/references/authorization.md @@ -1,30 +1,5 @@ # Authorization -- [Authorization](#authorization) - - [Defining Available Groups](#defining-available-groups) - - [Default User Group](#default-user-group) - - [Defining Available Permissions](#defining-available-permissions) - - [Assigning Permissions to Groups](#assigning-permissions-to-groups) - - [Authorizing Users](#authorizing-users) - - [can()](#can) - - [inGroup()](#ingroup) - - [hasPermission()](#haspermission) - - [Authorizing via Routes](#authorizing-via-routes) - - [Managing User Permissions](#managing-user-permissions) - - [addPermission()](#addpermission) - - [removePermission()](#removepermission) - - [syncPermissions()](#syncpermissions) - - [getPermissions()](#getpermissions) - - [Managing User Groups](#managing-user-groups) - - [addGroup()](#addgroup) - - [removeGroup()](#removegroup) - - [syncGroups()](#syncgroups) - - [getGroups()](#getgroups) - - [User Activation](#user-activation) - - [Checking Activation Status](#checking-activation-status) - - [Activating a User](#activating-a-user) - - [Deactivating a User](#deactivating-a-user) - Authorization happens once a user has been identified through authentication. It is the process of determining what actions a user is allowed to do within your site. @@ -37,8 +12,7 @@ around features, like Beta feature access, or used to provide discrete groups of Groups are defined within the `Shield\Config\AuthGroups` config class. ```php - -public $groups = [ +public array $groups = [ 'superadmin' => [ 'title' => 'Super Admin', 'description' => 'Optional description of the group.', @@ -56,7 +30,7 @@ When a user is first registered on the site, they are assigned to a default user `Config\AuthGroups::$defaultGroup`, and must match the name of one of the defined groups. ```php -public $defaultGroup = 'users'; +public string $defaultGroup = 'user'; ``` ## Defining Available Permissions @@ -66,7 +40,7 @@ a scope and action, like `users.create`. The scope would be `users` and the acti can have a description for display within UIs if needed. ```php -public $permissions = [ +public array $permissions = [ 'admin.access' => 'Can access the sites admin area', 'admin.settings' => 'Can access the main site settings', 'users.manage-admins' => 'Can manage other admins', @@ -82,13 +56,15 @@ public $permissions = [ In order to grant any permissions to a group, they must have the permission assigned to the group, within the `AuthGroups` config file, under the `$matrix` property. -> **Note** This defines **group-level permissons**. +!!! note + + This defines **group-level permissons**. The matrix is an associative array with the group name as the key, and an array of permissions that should be applied to that group. ```php -public $matrix = [ +public array $matrix = [ 'admin' => [ 'admin.access', 'users.create', 'users.edit', 'users.delete', @@ -100,7 +76,7 @@ public $matrix = [ You can use a wildcard within a scope to allow all actions within that scope, by using a `*` in place of the action. ```php -public $matrix = [ +public array $matrix = [ 'superadmin' => ['admin.*', 'users.*', 'beta.*'], ]; ``` @@ -111,7 +87,7 @@ The `Authorizable` trait on the `User` entity provides the following methods to #### can() -Allows you to check if a user is permitted to do a specific action. The only argument is the permission string. Returns +Allows you to check if a user is permitted to do a specific action or group or actions. The permission string(s) should be passed as the argument(s). Returns boolean `true`/`false`. Will check the user's direct permissions (**user-level permissions**) first, and then check against all of the user's groups permissions (**group-level permissions**) to determine if they are allowed. @@ -119,6 +95,11 @@ permissions (**group-level permissions**) to determine if they are allowed. if ($user->can('users.create')) { // } + +// If multiple permissions are specified, true is returned if the user has any of them. +if ($user->can('users.create', 'users.edit')) { + // +} ``` #### inGroup() @@ -141,9 +122,11 @@ if (! $user->hasPermission('users.create')) { } ``` -> **Note** This method checks only **user-level permissions**, and does not check -> group-level permissions. If you want to check if the user can do something, -> use the `$user->can()` method instead. +!!! note + + This method checks only **user-level permissions**, and does not check + group-level permissions. If you want to check if the user can do something, + use the `$user->can()` method instead. #### Authorizing via Routes @@ -170,8 +153,10 @@ $routes->group('admin', ['filter' => 'group:admin,superadmin'], static function Note that the options (`filter`) passed to the outer `group()` are not merged with the inner `group()` options. -> **Note** If you set more than one filter to a route, you need to enable -> [Multiple Filters](https://codeigniter.com/user_guide/incoming/routing.html#multiple-filters). +!!! note + + If you set more than one filter to a route, you need to enable + [Multiple Filters](https://codeigniter.com/user_guide/incoming/routing.html#multiple-filters). ## Managing User Permissions @@ -213,7 +198,9 @@ Returns all **user-level** permissions this user has assigned directly to them. $user->getPermissions(); ``` -> **Note** This method does not return **group-level permissions**. +!!! note + + This method does not return **group-level permissions**. ## Managing User Groups @@ -254,7 +241,7 @@ $user->getGroups(); ## User Activation -All users have an `active` flag. This is only used when the [`EmailActivation` action](./auth_actions.md), or a custom action used to activate a user, is enabled. +All users have an `active` flag. This is only used when the [`EmailActivation` action](./authentication/auth_actions.md), or a custom action used to activate a user, is enabled. ### Checking Activation Status @@ -266,7 +253,9 @@ if ($user->isActivated()) { } ``` -> **Note** If no activator is specified in the `Auth` config file, `actions['register']` property, then this will always return `true`. +!!! note + + If no activator is specified in the `Auth` config file, `actions['register']` property, then this will always return `true`. You can check if a user has not been activated yet via the `isNotActivated()` method. diff --git a/docs/references/controller_filters.md b/docs/references/controller_filters.md new file mode 100644 index 000000000..88dfe4c1a --- /dev/null +++ b/docs/references/controller_filters.md @@ -0,0 +1,110 @@ +# Controller Filters + +## Provided Filters + +!!! note + + These filters are already loaded for you by the [registrar](https://codeigniter.com/user_guide/general/configuration.html#registrars) class located at **src/Config/Registrar.php**. + +The [Controller Filters](https://codeigniter.com/user_guide/incoming/filters.html) you can use to protect your routes Shield provides are: + +```php +public $aliases = [ + // ... + 'session' => \CodeIgniter\Shield\Filters\SessionAuth::class, + 'tokens' => \CodeIgniter\Shield\Filters\TokenAuth::class, + 'hmac' => \CodeIgniter\Shield\Filters\HmacAuth::class, + 'chain' => \CodeIgniter\Shield\Filters\ChainAuth::class, + 'auth-rates' => \CodeIgniter\Shield\Filters\AuthRates::class, + 'group' => \CodeIgniter\Shield\Filters\GroupFilter::class, + 'permission' => \CodeIgniter\Shield\Filters\PermissionFilter::class, + 'force-reset' => \CodeIgniter\Shield\Filters\ForcePasswordResetFilter::class, + 'jwt' => \CodeIgniter\Shield\Filters\JWTAuth::class, +]; +``` + +| Filters | Description | +|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| session | The `Session` authenticator. | +| tokens | The `AccessTokens` authenticator. | +| chained | The filter will check authenticators in sequence to see if the user is logged in through either of authenticators, allowing a single API endpoint to work for both an SPA using session auth, and a mobile app using access tokens. | +| jwt | The `JWT` authenticator. See [JWT Authentication](../addons/jwt.md). | +| hmac | The `HMAC` authenticator. See [HMAC Authentication](../guides/api_hmac_keys.md). | +| auth-rates | Provides a good basis for rate limiting of auth-related routes. | +| group | Checks if the user is in one of the groups passed in. | +| permission | Checks if the user has the passed permissions. | +| force-reset | Checks if the user requires a password reset. | + +These can be used in any of the [normal filter config settings](https://codeigniter.com/user_guide/incoming/filters.html#globals), or [within the routes file](https://codeigniter.com/user_guide/incoming/routing.html#applying-filters). + +## Configure Controller Filters + +### Protect All Pages + +If you want to limit all routes (e.g. `localhost:8080/admin`, `localhost:8080/panel` and ...), you need to add the following code in the **app/Config/Filters.php** file. + +```php +public $globals = [ + 'before' => [ + // ... + 'session' => ['except' => ['login*', 'register', 'auth/a/*']], + ], + // ... +]; +``` + +### Rate Limiting + +To help protect your authentication forms from being spammed by bots, it is recommended that you use +the `auth-rates` filter on all of your authentication routes. This can be done with the following +filter setup: + +```php +public $filters = [ + 'auth-rates' => [ + 'before' => [ + 'login*', 'register', 'auth/*' + ] + ] +]; +``` + +### Forcing Password Reset + +If your application requires a force password reset functionality, ensure that you exclude the auth pages and the actual password reset page from the `before` global. This will ensure that your users do not run into a *too many redirects* error. See: + +```php +public $globals = [ + 'before' => [ + //... + //... + 'force-reset' => ['except' => ['login*', 'register', 'auth/a/*', 'change-password', 'logout']] + ] +]; +``` +In the example above, it is assumed that the page you have created for users to change their password after successful login is **change-password**. + +!!! note + + If you have grouped or changed the default format of the routes, ensure that your code matches the new format(s) in the **app/Config/Filter.php** file. + +For example, if you configured your routes like so: + +```php +$routes->group('accounts', static function($routes) { + service('auth')->routes($routes); +}); +``` + +Then the global `before` filter for `session` should look like so: + +```php +public $globals = [ + 'before' => [ + // ... + 'session' => ['except' => ['accounts/login*', 'accounts/register', 'accounts/auth/a/*']] + ] +] +``` + +The same should apply for the Rate Limiting and Forcing Password Reset. diff --git a/docs/events.md b/docs/references/events.md similarity index 79% rename from docs/events.md rename to docs/references/events.md index 0fd544153..20344fd96 100644 --- a/docs/events.md +++ b/docs/references/events.md @@ -2,16 +2,6 @@ Shield fires off several events during the lifecycle of the application that your code can tap into. -- [Events](#events) - - [Responding to Events](#responding-to-events) - - [Event List](#event-list) - - [register](#register) - - [login](#login) - - [failedLogin](#failedlogin) - - [logout](#logout) - - [magicLogin](#magiclogin) - - [Event Timing](#event-timing) - ## Responding to Events When you want to respond to an event that Shield publishes, you will need to add it to your **app/Config/Events.php** @@ -23,7 +13,7 @@ for more information. #### register -Triggered when a new user has registered in the system. It's only argument is the `User` entity itself. +Triggered when a new user has registered in the system. The only argument is the `User` entity itself. ```php Events::trigger('register', $user); @@ -84,4 +74,4 @@ Events::on('magicLogin', function() { To learn more about Event timing, please see the list below. -- [Session Authenticator Event and Logging](./session_auth_event_and_logging.md). +- [Session Authenticator](./authentication/session.md#events-and-logging). diff --git a/docs/references/magic_link_login.md b/docs/references/magic_link_login.md new file mode 100644 index 000000000..942f08aa8 --- /dev/null +++ b/docs/references/magic_link_login.md @@ -0,0 +1,58 @@ +# Magic Link Login + +Magic Link Login is a feature that allows users to log in if they forget their +password. + +## Configuration + +### Configure Magic Link Login Functionality + +Magic Link Login functionality is enabled by default. +You can change it within the **app/Config/Auth.php** file. + +```php +public bool $allowMagicLinkLogins = true; +``` + +### Magic Link Lifetime + +By default, Magic Link can be used for 1 hour. This can be easily modified +in the **app/Config/Auth.php** file. + +```php +public int $magicLinkLifetime = HOUR; +``` + +## Responding to Magic Link Logins + +!!! note + + You need to configure **app/Config/Email.php** to allow Shield to send emails. See [Installation](../getting_started/install.md#initial-setup). + +Magic Link logins allow a user that has forgotten their password to have an email sent with a unique, one-time login link. Once they've logged in you can decide how to respond. In some cases, you might want to redirect them to a special page where they must choose a new password. In other cases, you might simply want to display a one-time message prompting them to go to their account page and choose a new password. + +### Session Notification + +You can detect if a user has finished the magic link login by checking for a session value, `magicLogin`. If they have recently completed the flow, it will exist and have a value of `true`. + +```php +if (session('magicLogin')) { + return redirect()->route('set_password'); +} +``` + +This value sticks around in the session for 5 minutes. Once you no longer need to take any actions, you might want to delete the value from the session. + +```php +session()->removeTempdata('magicLogin'); +``` + +### Event + +At the same time the above session variable is set, a `magicLogin` [event](https://codeigniter.com/user_guide/extending/events.html) is fired off that you may subscribe to. Note that no data is passed to the event as you can easily grab the current user from the `user()` helper or the `auth()->user()` method. + +```php +Events::on('magicLogin', static function () { + // ... +}); +``` diff --git a/docs/testing.md b/docs/references/testing.md similarity index 86% rename from docs/testing.md rename to docs/references/testing.md index 27cd64dbe..30766858a 100644 --- a/docs/testing.md +++ b/docs/references/testing.md @@ -1,6 +1,8 @@ # Testing -When performing [HTTP testing](https://codeigniter.com/user_guide/testing/feature.html) in your applications, you +## HTTP Feature Testing + +When performing [HTTP Feature Testing](https://codeigniter.com/user_guide/testing/feature.html) in your applications, you will often need to ensure you are logged in to check security, or simply to access protected locations. Shield provides the `AuthenticationTesting` trait to help you out. Use it within the test class and then you can use the `actingAs()` method that takes a User instance. This user will be logged in during the test. @@ -24,7 +26,7 @@ class ActionsTest extends TestCase ->withSession([ 'auth_action' => Email2FA::class, ])->get('/auth/a/show'); - + $result->assertStatus(200); // Should auto-populate in the form $result->assertSee($this->user->email); diff --git a/docs/session_auth_event_and_logging.md b/docs/session_auth_event_and_logging.md deleted file mode 100644 index a1232ccfa..000000000 --- a/docs/session_auth_event_and_logging.md +++ /dev/null @@ -1,42 +0,0 @@ -# Session Authenticator Event and Logging - -The following is a list of Events and Logging for Session Authenticator. - -## Register - -- Default Register - - Post email/username/password - - OK → event `register` and `login` - - NG → no event -- Register with Email Activation - 1. Post email/username/password - - OK → event `register` - - NG → no event - 2. Post token - - OK → event `login` - - NG → no event - -## Login - -- Default Login - - Post email/password - - OK → event `login` / table `auth_logins` - - NG → event `failedLogin` / table `auth_logins` -- Email2FA Login - 1. Post email/password - - OK → no event / table `auth_logins` - - NG → event `failedLogin` / table `auth_logins` - 2. Post token - - OK → event `login` - - NG → no event -- Remember-me - - Send remember-me cookie w/o session cookie - - OK → no event - - NG → no event -- Magic-link - 1. Post email - - OK → no event - - NG → no event - 2. Send request with token - - OK → event `login` and `magicLogin` / table `auth_logins` - - NG → event `failedLogin` / table `auth_logins` diff --git a/docs/banning_users.md b/docs/user_management/banning_users.md similarity index 77% rename from docs/banning_users.md rename to docs/user_management/banning_users.md index b0a81c4da..54e1196fd 100644 --- a/docs/banning_users.md +++ b/docs/user_management/banning_users.md @@ -2,20 +2,15 @@ Shield provides a way to ban users from your application. This is useful if you need to prevent a user from logging in, or logging them out in the event that they breach your terms of service. -- [Checking if the User is Banned](#check-if-a-user-is-banned) -- [Banning a User](#banning-a-user) -- [Unbanning a User](#unbanning-a-user) -- [Getting the Reason for Ban ](#getting-the-reason-for-ban) +### Check if a User is Banned -### Check if a User is Banned - -You can check if a user is banned using `isBanned()` method on the `User` entity. The method returns a boolean `true`/`false`. +You can check if a user is banned using `isBanned()` method on the `User` entity. The method returns a boolean `true`/`false`. ```php if ($user->isBanned()) { //... } -``` +``` ### Banning a User @@ -30,7 +25,7 @@ $user->ban('Your reason for banning the user here'); ### Unbanning a User -Unbanning a user can be done using the `unBan()` method on the `User` entity. This method will also reset the `status_message` property. +Unbanning a user can be done using the `unBan()` method on the `User` entity. This method will also reset the `status_message` property. ```php $user->unBan(); @@ -42,4 +37,4 @@ The reason for the ban can be obtained user the `getBanMessage()` method on the ```php $user->getBanMessage(); -``` \ No newline at end of file +``` diff --git a/docs/forcing_password_reset.md b/docs/user_management/forcing_password_reset.md similarity index 80% rename from docs/forcing_password_reset.md rename to docs/user_management/forcing_password_reset.md index 927b3aec8..241af0988 100644 --- a/docs/forcing_password_reset.md +++ b/docs/user_management/forcing_password_reset.md @@ -2,14 +2,6 @@ Depending on the scope of your application, there may be times when you'll decide that it is absolutely necessary to force user(s) to reset their password. This practice is common when you find out that users of your application do not use strong passwords OR there is a reasonable suspicion that their passwords have been compromised. This guide provides you with ways to achieve this. -- [Forcing Password Reset](#forcing-password-reset) - - [Available Methods](#available-methods) - - [Check if a User Requires Password Reset](#check-if-a-user-requires-password-reset) - - [Force Password Reset On a User](#force-password-reset-on-a-user) - - [Remove Force Password Reset Flag On a User](#remove-force-password-reset-flag-on-a-user) - - [Force Password Reset On Multiple Users](#force-password-reset-on-multiple-users) - - [Force Password Reset On All Users](#force-password-reset-on-all-users) - ## Available Methods Shield provides a way to enforce password resets throughout your application. The `Resettable` trait on the `User` entity and the `UserIdentityModel` provides the following methods to do so. diff --git a/docs/user_management/managing_users.md b/docs/user_management/managing_users.md new file mode 100644 index 000000000..834670ac4 --- /dev/null +++ b/docs/user_management/managing_users.md @@ -0,0 +1,84 @@ +# Managing Users + +Since Shield uses a more complex user setup than many other systems, separating [User Identities](../getting_started/concepts.md#user-identities) from the user accounts themselves. This quick overview should help you feel more confident when working with users on a day-to-day basis. + +## Managing Users by Code + +### Creating Users + +By default, the only values stored in the users table is the username. The first step is to create the user record with the username. If you don't have a username, be sure to set the value to `null` anyway, so that it passes CodeIgniter's empty data check. + +```php +use CodeIgniter\Shield\Entities\User; + +// Get the User Provider (UserModel by default) +$users = auth()->getProvider(); + +$user = new User([ + 'username' => 'foo-bar', + 'email' => 'foo.bar@example.com', + 'password' => 'secret plain text password', +]); +$users->save($user); + +// To get the complete user object with ID, we need to get from the database +$user = $users->findById($users->getInsertID()); + +// Add to default group +$users->addToDefaultGroup($user); +``` + +### Deleting Users + +A user's data can be spread over a few different tables so you might be concerned about how to delete all of the user's data from the system. This is handled automatically at the database level for all information that Shield knows about, through the `onCascade` settings of the table's foreign keys. You can delete a user like any other entity. + +```php +// Get the User Provider (UserModel by default) +$users = auth()->getProvider(); + +$users->delete($user->id, true); +``` + +!!! note + + The User rows use [soft deletes](https://codeigniter.com/user_guide/models/model.html#usesoftdeletes) so they are not actually deleted from the database unless the second parameter is `true`, like above. + +### Editing a User + +The `UserModel::save()`, `update()` and `insert()` methods have been modified to ensure that an email or password previously set on the `User` entity will be automatically updated in the correct `UserIdentity` record. + +```php +// Get the User Provider (UserModel by default) +$users = auth()->getProvider(); + +$user = $users->findById(123); +$user->fill([ + 'username' => 'JoeSmith111', + 'email' => 'joe.smith@example.com', + 'password' => 'secret123' +]); +$users->save($user); +``` + +## Managing Users via CLI + +Shield has a CLI command to manage users. You can do the following actions: + +```text +create: Create a new user +activate: Activate a user +deactivate: Deactivate a user +changename: Change user name +changeemail: Change user email +delete: Delete a user +password: Change a user password +list: List users +addgroup: Add a user to a group +removegroup: Remove a user from a group +``` + +You can get help on how to use it by running the following command in a terminal: + +```console +php spark shield:user --help +``` diff --git a/mkdocs.yml b/mkdocs.yml index 1b1752483..64917aeef 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,49 +10,129 @@ theme: font: text: Raleway palette: - primary: deep orange + # Palette toggle for light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: deep orange + accent: orange + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Palette toggle for dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: deep orange + accent: orange + toggle: + icon: material/brightness-4 + name: Switch to light mode features: - - navigation.sections + - navigation.instant + - navigation.instant.prefetch + - content.code.copy + - navigation.footer + - content.action.edit + - navigation.top + - search.suggest + - search.highlight + - search.share extra: homepage: https://codeigniter.com + generator: false social: - - icon: fontawesome/brands/github + - icon: material/github link: https://github.com/codeigniter4/shield name: GitHub + - icon: material/twitter + link: https://twitter.com/CodeIgniterPhp + name: X + - icon: material/forum + link: https://forum.codeigniter.com + name: Forum Codeigniter + - icon: material/slack + link: https://join.slack.com/t/codeigniterchat/shared_invite/zt-244xrrslc-l_I69AJSi5y2a2RVN~xIdQ + name: Slack + repo_url: https://github.com/codeigniter4/shield +edit_uri: edit/develop/docs/ +copyright: Copyright © 2023 CodeIgniter Foundation. markdown_extensions: - pymdownx.superfences - pymdownx.highlight: use_pygments: false + - admonition + - pymdownx.details extra_css: - - https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.6.0/build/styles/github.min.css + - https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.8.0/build/styles/default.min.css + - assets/css/dark_mode.css extra_javascript: - - https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.6.0/build/highlight.min.js - - assets/hljs.js + - https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.8.0/build/highlight.min.js + - assets/js/hljs.js + - assets/js/curl.min.js +plugins: + - search + - git-revision-date-localized: + enable_creation_date: true + - redirects: + redirect_maps: + 'auth_actions.md': 'references/authentication/auth_actions.md' + 'authentication.md': 'references/authentication/authentication.md' + 'authorization.md': 'references/authorization.md' + 'banning_users.md': 'user_management/banning_users.md' + 'concepts.md': 'getting_started/concepts.md' + 'customization.md': 'customization/table_names.md' + 'events.md': 'references/events.md' + 'forcing_password_reset.md': 'user_management/forcing_password_reset.md' + 'install.md': 'getting_started/install.md' + 'quickstart.md': 'quick_start_guide/using_session_auth.md' + 'session_auth_event_and_logging.md': 'references/authentication/session.md' + 'testing.md': 'references/testing.md' nav: - Home: index.md - - Installation: install.md - - Concepts: concepts.md - - Quick Start Guide: quickstart.md - - Authentication: authentication.md - - Authorization: authorization.md - - Auth Actions: auth_actions.md - - Events: events.md - - Testing: testing.md - - Customization: customization.md - - Forcing Password Reset: forcing_password_reset.md - - Banning Users: banning_users.md - - session_auth_event_and_logging.md + - Getting Started: + - Concepts: getting_started/concepts.md + - getting_started/install.md + - getting_started/authenticators.md + - getting_started/configuration.md + - Quick Start Guide: + - quick_start_guide/using_session_auth.md + - quick_start_guide/using_authorization.md + - Customization: + - customization/table_names.md + - customization/route_config.md + - customization/redirect_urls.md + - customization/validation_rules.md + - customization/user_provider.md + - customization/extending_controllers.md + - customization/integrating_custom_view_libs.md + - customization/login_identifier.md + - User Management: + - user_management/managing_users.md + - user_management/forcing_password_reset.md + - user_management/banning_users.md - Guides: - guides/api_tokens.md + - guides/api_hmac_keys.md - guides/mobile_apps.md - guides/strengthen_password.md + - References: + - references/controller_filters.md + - Authentication: + - references/authentication/authentication.md + - references/authentication/session.md + - Token Authenticator: references/authentication/tokens.md + - HMAC Authenticator: references/authentication/hmac.md + - Auth Actions: references/authentication/auth_actions.md + - references/magic_link_login.md + - references/authorization.md + - references/events.md + - references/testing.md - Addons: - JWT Authentication: addons/jwt.md diff --git a/phpstan-baseline.php b/phpstan-baseline.php new file mode 100644 index 000000000..2e880e9ff --- /dev/null +++ b/phpstan-baseline.php @@ -0,0 +1,370 @@ + '#^Call to function property_exists\\(\\) with CodeIgniter\\\\Shield\\\\Config\\\\Auth and \'userProvider\' will always evaluate to true\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Auth.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Auth.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Only booleans are allowed in a ternary operator condition, CodeIgniter\\\\Shield\\\\Entities\\\\User\\|null given\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Auth.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to deprecated function random_string\\(\\)\\: +The type \'basic\', \'md5\', and \'sha1\' are deprecated\\. They are not cryptographically secure\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Actions/Email2FA.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 2, + 'path' => __DIR__ . '/src/Authentication/Actions/Email2FA.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to deprecated function random_string\\(\\)\\: +The type \'basic\', \'md5\', and \'sha1\' are deprecated\\. They are not cryptographically secure\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Actions/EmailActivator.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Authentication.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 4, + 'path' => __DIR__ . '/src/Authentication/Authenticators/AccessTokens.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Only booleans are allowed in &&, CodeIgniter\\\\I18n\\\\Time\\|null given on the left side\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Authenticators/AccessTokens.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Parameter \\#1 \\$credentials \\(array\\{token\\?\\: string\\}\\) of method CodeIgniter\\\\Shield\\\\Authentication\\\\Authenticators\\\\JWT\\:\\:attempt\\(\\) should be contravariant with parameter \\$credentials \\(array\\) of method CodeIgniter\\\\Shield\\\\Authentication\\\\AuthenticatorInterface\\:\\:attempt\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Authenticators/JWT.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Parameter \\#1 \\$credentials \\(array\\{token\\?\\: string\\}\\) of method CodeIgniter\\\\Shield\\\\Authentication\\\\Authenticators\\\\JWT\\:\\:check\\(\\) should be contravariant with parameter \\$credentials \\(array\\) of method CodeIgniter\\\\Shield\\\\Authentication\\\\AuthenticatorInterface\\:\\:check\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Authenticators/JWT.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 3, + 'path' => __DIR__ . '/src/Authentication/Authenticators/Session.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Only booleans are allowed in an elseif condition, string\\|null given\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Authenticators/Session.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Only booleans are allowed in an if condition, CodeIgniter\\\\Shield\\\\Entities\\\\UserIdentity\\|null given\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Authenticators/Session.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Only booleans are allowed in an if condition, int\\|string\\|null given\\.$#', + 'count' => 3, + 'path' => __DIR__ . '/src/Authentication/Authenticators/Session.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Parameter \\#1 \\$credentials \\(array\\{email\\?\\: string, username\\?\\: string, password\\?\\: string\\}\\) of method CodeIgniter\\\\Shield\\\\Authentication\\\\Authenticators\\\\Session\\:\\:attempt\\(\\) should be contravariant with parameter \\$credentials \\(array\\) of method CodeIgniter\\\\Shield\\\\Authentication\\\\AuthenticatorInterface\\:\\:attempt\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Authenticators/Session.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Parameter \\#1 \\$credentials \\(array\\{email\\?\\: string, username\\?\\: string, password\\?\\: string\\}\\) of method CodeIgniter\\\\Shield\\\\Authentication\\\\Authenticators\\\\Session\\:\\:check\\(\\) should be contravariant with parameter \\$credentials \\(array\\) of method CodeIgniter\\\\Shield\\\\Authentication\\\\AuthenticatorInterface\\:\\:check\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Authenticators/Session.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to method CodeIgniter\\\\Shield\\\\Result\\:\\:isOK\\(\\) with incorrect case\\: isOk$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Passwords.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Passwords.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Passwords/CompositionValidator.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 5, + 'path' => __DIR__ . '/src/Authentication/Passwords/NothingPersonalValidator.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to method CodeIgniter\\\\Shield\\\\Result\\:\\:isOK\\(\\) with incorrect case\\: isOk$#', + 'count' => 2, + 'path' => __DIR__ . '/src/Authentication/Passwords/ValidationRules.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Passwords/ValidationRules.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Only booleans are allowed in &&, CodeIgniter\\\\Shield\\\\Entities\\\\User\\|null given on the right side\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Authentication/Passwords/ValidationRules.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 3, + 'path' => __DIR__ . '/src/Authorization/Groups.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Return type \\(int\\|string\\|null\\) of method CodeIgniter\\\\Shield\\\\Collectors\\\\Auth\\:\\:getBadgeValue\\(\\) should be covariant with return type \\(int\\|null\\) of method CodeIgniter\\\\Debug\\\\Toolbar\\\\Collectors\\\\BaseCollector\\:\\:getBadgeValue\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Collectors/Auth.php', +]; +$ignoreErrors[] = [ + 'message' => '#^PHPDoc type array\\ of property CodeIgniter\\\\Shield\\\\Commands\\\\Generators\\\\UserModelGenerator\\:\\:\\$arguments is not the same as PHPDoc type array of overridden property CodeIgniter\\\\CLI\\\\BaseCommand\\:\\:\\$arguments\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Commands/Generators/UserModelGenerator.php', +]; +$ignoreErrors[] = [ + 'message' => '#^PHPDoc type array\\ of property CodeIgniter\\\\Shield\\\\Commands\\\\Generators\\\\UserModelGenerator\\:\\:\\$options is not the same as PHPDoc type array of overridden property CodeIgniter\\\\CLI\\\\BaseCommand\\:\\:\\$options\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Commands/Generators/UserModelGenerator.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Controllers/ActionController.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Instanceof between CodeIgniter\\\\Shield\\\\Authentication\\\\Actions\\\\ActionInterface and CodeIgniter\\\\Shield\\\\Authentication\\\\Actions\\\\ActionInterface will always evaluate to true\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Controllers/ActionController.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to deprecated function random_string\\(\\)\\: +The type \'basic\', \'md5\', and \'sha1\' are deprecated\\. They are not cryptographically secure\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Controllers/MagicLinkController.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to function assert\\(\\) with false and \'Config Auth…\' will always evaluate to false\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Controllers/RegisterController.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Instanceof between null and CodeIgniter\\\\Shield\\\\Models\\\\UserModel will always evaluate to false\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Controllers/RegisterController.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Parameter \\#1 \\$name of function model expects a valid class string, array\\|bool\\|float\\|int\\|object\\|string\\|null given\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Controllers/RegisterController.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to an undefined method CodeIgniter\\\\Database\\\\ConnectionInterface\\:\\:disableForeignKeyChecks\\(\\)\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Database/Migrations/2020-12-28-223112_create_auth_tables.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to an undefined method CodeIgniter\\\\Database\\\\ConnectionInterface\\:\\:enableForeignKeyChecks\\(\\)\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Database/Migrations/2020-12-28-223112_create_auth_tables.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Parameter \\#1 \\$value \\(bool\\|int\\|string\\) of method CodeIgniter\\\\Shield\\\\Entities\\\\Cast\\\\IntBoolCast\\:\\:set\\(\\) should be contravariant with parameter \\$value \\(array\\|bool\\|float\\|int\\|object\\|string\\|null\\) of method CodeIgniter\\\\Entity\\\\Cast\\\\BaseCast\\:\\:set\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Entities/Cast/IntBoolCast.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Parameter \\#1 \\$value \\(bool\\|int\\|string\\) of method CodeIgniter\\\\Shield\\\\Entities\\\\Cast\\\\IntBoolCast\\:\\:set\\(\\) should be contravariant with parameter \\$value \\(array\\|bool\\|float\\|int\\|object\\|string\\|null\\) of method CodeIgniter\\\\Entity\\\\Cast\\\\CastInterface\\:\\:set\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Entities/Cast/IntBoolCast.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Parameter \\#1 \\$value \\(int\\) of method CodeIgniter\\\\Shield\\\\Entities\\\\Cast\\\\IntBoolCast\\:\\:get\\(\\) should be contravariant with parameter \\$value \\(array\\|bool\\|float\\|int\\|object\\|string\\|null\\) of method CodeIgniter\\\\Entity\\\\Cast\\\\BaseCast\\:\\:get\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Entities/Cast/IntBoolCast.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Parameter \\#1 \\$value \\(int\\) of method CodeIgniter\\\\Shield\\\\Entities\\\\Cast\\\\IntBoolCast\\:\\:get\\(\\) should be contravariant with parameter \\$value \\(array\\|bool\\|float\\|int\\|object\\|string\\|null\\) of method CodeIgniter\\\\Entity\\\\Cast\\\\CastInterface\\:\\:get\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Entities/Cast/IntBoolCast.php', +]; +$ignoreErrors[] = [ + 'message' => '#^PHPDoc type array\\ of property CodeIgniter\\\\Shield\\\\Entities\\\\Entity\\:\\:\\$castHandlers is not the same as PHPDoc type array\\ of overridden property CodeIgniter\\\\Entity\\\\Entity\\:\\:\\$castHandlers\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Entities/Entity.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 2, + 'path' => __DIR__ . '/src/Entities/Group.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 8, + 'path' => __DIR__ . '/src/Entities/User.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Only booleans are allowed in a ternary operator condition, int\\<0, max\\> given\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Entities/User.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Filters/AbstractAuthFilter.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method CodeIgniter\\\\Shield\\\\Filters\\\\AuthRates\\:\\:before\\(\\) should return CodeIgniter\\\\HTTP\\\\RedirectResponse\\|void but returns CodeIgniter\\\\HTTP\\\\ResponseInterface\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Filters/AuthRates.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to method CodeIgniter\\\\HTTP\\\\ResponseInterface\\:\\:setJSON\\(\\) with incorrect case\\: setJson$#', + 'count' => 2, + 'path' => __DIR__ . '/src/Filters/TokenAuth.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Filters/TokenAuth.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Method CodeIgniter\\\\Shield\\\\Filters\\\\TokenAuth\\:\\:before\\(\\) should return CodeIgniter\\\\HTTP\\\\RedirectResponse\\|void but returns CodeIgniter\\\\HTTP\\\\ResponseInterface\\.$#', + 'count' => 2, + 'path' => __DIR__ . '/src/Filters/TokenAuth.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to deprecated function random_string\\(\\)\\: +The type \'basic\', \'md5\', and \'sha1\' are deprecated\\. They are not cryptographically secure\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Models/TokenLoginModel.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to deprecated function random_string\\(\\)\\: +The type \'basic\', \'md5\', and \'sha1\' are deprecated\\. They are not cryptographically secure\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Models/UserIdentityModel.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Cannot unset offset \'email\' on array\\{username\\: string, status\\: string, status_message\\: string, active\\: bool, last_active\\: string, deleted_at\\: string\\}\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Models/UserModel.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Cannot unset offset \'password_hash\' on array\\{username\\: string, status\\: string, status_message\\: string, active\\: bool, last_active\\: string, deleted_at\\: string\\}\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Models/UserModel.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 2, + 'path' => __DIR__ . '/src/Models/UserModel.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Offset \'email\' does not exist on array\\{username\\: string, status\\: string, status_message\\: string, active\\: bool, last_active\\: string, deleted_at\\: string\\}\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Models/UserModel.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Offset \'password_hash\' does not exist on array\\{username\\: string, status\\: string, status_message\\: string, active\\: bool, last_active\\: string, deleted_at\\: string\\}\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Models/UserModel.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Parameter \\#1 \\$data \\(array\\|CodeIgniter\\\\Shield\\\\Entities\\\\User\\) of method CodeIgniter\\\\Shield\\\\Models\\\\UserModel\\:\\:insert\\(\\) should be contravariant with parameter \\$data \\(array\\|object\\|null\\) of method CodeIgniter\\\\Model\\:\\:insert\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Models/UserModel.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Parameter \\#1 \\$data \\(array\\|CodeIgniter\\\\Shield\\\\Entities\\\\User\\) of method CodeIgniter\\\\Shield\\\\Models\\\\UserModel\\:\\:save\\(\\) should be contravariant with parameter \\$data \\(array\\|object\\) of method CodeIgniter\\\\BaseModel\\:\\:save\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Models/UserModel.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Parameter \\#2 \\$data \\(array\\|CodeIgniter\\\\Shield\\\\Entities\\\\User\\) of method CodeIgniter\\\\Shield\\\\Models\\\\UserModel\\:\\:update\\(\\) should be contravariant with parameter \\$data \\(array\\|object\\|null\\) of method CodeIgniter\\\\Model\\:\\:update\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Models/UserModel.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Return type \\(int\\|string\\|true\\) of method CodeIgniter\\\\Shield\\\\Models\\\\UserModel\\:\\:insert\\(\\) should be covariant with return type \\(int\\|object\\|string\\|false\\) of method CodeIgniter\\\\Model\\:\\:insert\\(\\)$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Models/UserModel.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with \'CodeIgniter\\\\\\\\Shield\\\\\\\\Result\' and CodeIgniter\\\\Shield\\\\Result will always evaluate to true\\.$#', + 'count' => 2, + 'path' => __DIR__ . '/tests/Authentication/Authenticators/AccessTokenAuthenticatorTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with \'CodeIgniter\\\\\\\\Shield\\\\\\\\Result\' and CodeIgniter\\\\Shield\\\\Result will always evaluate to true\\.$#', + 'count' => 3, + 'path' => __DIR__ . '/tests/Authentication/Authenticators/JWTAuthenticatorTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with \'CodeIgniter\\\\\\\\Shield\\\\\\\\Result\' and CodeIgniter\\\\Shield\\\\Result will always evaluate to true\\.$#', + 'count' => 9, + 'path' => __DIR__ . '/tests/Authentication/Authenticators/SessionAuthenticatorTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/tests/Authentication/Filters/AbstractFilterTestCase.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Implicit array creation is not allowed \\- variable \\$users might not exist\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/tests/Authentication/ForcePasswordResetTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Variable \\$users might not be defined\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/tests/Authentication/ForcePasswordResetTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with \'CodeIgniter\\\\\\\\Shield\\\\\\\\Entities\\\\\\\\AccessToken\' and CodeIgniter\\\\Shield\\\\Entities\\\\AccessToken will always evaluate to true\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/tests/Authentication/HasAccessTokensTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Only booleans are allowed in a ternary operator condition, string\\|null given\\.$#', + 'count' => 2, + 'path' => __DIR__ . '/tests/Language/AbstractTranslationTestCase.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to method PHPUnit\\\\Framework\\\\Assert\\:\\:assertIsString\\(\\) with string will always evaluate to true\\.$#', + 'count' => 6, + 'path' => __DIR__ . '/tests/Unit/Authentication/JWT/JWTManagerTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to an undefined method CodeIgniter\\\\Shield\\\\Models\\\\UserModel\\:\\:getLastQuery\\(\\)\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/tests/Unit/UserTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Call to method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with \'CodeIgniter\\\\\\\\Shield\\\\\\\\Entities\\\\\\\\UserIdentity\' and CodeIgniter\\\\Shield\\\\Entities\\\\UserIdentity will always evaluate to true\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/tests/Unit/UserTest.php', +]; +$ignoreErrors[] = [ + 'message' => '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/tests/_support/Config/Registrar.php', +]; + +return ['parameters' => ['ignoreErrors' => $ignoreErrors]]; diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 5a406f846..eabfd9204 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,3 +1,7 @@ +includes: + - phpstan-baseline.php + - phar://vendor/phpstan/phpstan/phpstan.phar/conf/bleedingEdge.neon + parameters: tmpDir: build/phpstan level: 5 @@ -7,20 +11,23 @@ parameters: bootstrapFiles: - vendor/codeigniter4/framework/system/Test/bootstrap.php excludePaths: - - src/Config/Routes.php - src/Views/* - ignoreErrors: - - '#Call to an undefined method CodeIgniter\\Database\\ConnectionInterface::[A-Za-z].+\(\)#' - - '#Cannot access property [\$a-z_]+ on (array|object)#' universalObjectCratesClasses: - - CodeIgniter\Entity - - CodeIgniter\Entity\Entity - Faker\Generator scanDirectories: - vendor/codeigniter4/framework/system/Helpers - vendor/codeigniter4/settings/src/Helpers dynamicConstantNames: - - APP_NAMESPACE - - CI_DEBUG - - ENVIRONMENT - CodeIgniter\CodeIgniter::CI_VERSION + codeigniter: + additionalConfigNamespaces: + - CodeIgniter\Settings\Config + - CodeIgniter\Shield\Config + additionalServices: + - CodeIgniter\Shield\Config\Services + strictRules: + allRules: false + disallowedLooseComparison: true + booleansInConditions: true + disallowedConstructs: true + matchingInheritedMethodNames: true diff --git a/rector.php b/rector.php index bbc490708..12a24007d 100644 --- a/rector.php +++ b/rector.php @@ -5,9 +5,7 @@ use Rector\CodeQuality\Rector\BooleanAnd\SimplifyEmptyArrayCheckRector; use Rector\CodeQuality\Rector\Class_\CompleteDynamicPropertiesRector; use Rector\CodeQuality\Rector\Expression\InlineIfToExplicitIfRector; -use Rector\CodeQuality\Rector\For_\ForToForeachRector; use Rector\CodeQuality\Rector\Foreach_\UnusedForeachValueToArrayKeysRector; -use Rector\CodeQuality\Rector\FuncCall\AddPregQuoteDelimiterRector; use Rector\CodeQuality\Rector\FuncCall\ChangeArrayPushToArrayAssignRector; use Rector\CodeQuality\Rector\FuncCall\SimplifyRegexPatternRector; use Rector\CodeQuality\Rector\FuncCall\SimplifyStrposLowerRector; @@ -22,9 +20,9 @@ use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector; use Rector\Config\RectorConfig; use Rector\Core\ValueObject\PhpVersion; +use Rector\DeadCode\Rector\Cast\RecastingRemovalRector; use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPromotedPropertyRector; use Rector\DeadCode\Rector\If_\UnwrapFutureCompatibleIfPhpVersionRector; -use Rector\DeadCode\Rector\MethodCall\RemoveEmptyMethodCallRector; use Rector\DeadCode\Rector\Property\RemoveUnusedPrivatePropertyRector; use Rector\DeadCode\Rector\StmtsAwareInterface\RemoveJustPropertyFetchForAssignRector; use Rector\EarlyReturn\Rector\Foreach_\ChangeNestedForeachIfsToEarlyContinueRector; @@ -32,13 +30,12 @@ use Rector\EarlyReturn\Rector\If_\RemoveAlwaysElseRector; use Rector\EarlyReturn\Rector\Return_\PreparedValueToEarlyReturnRector; use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; -use Rector\Php56\Rector\FunctionLike\AddDefaultValueForUndefinedVariableRector; use Rector\Php73\Rector\FuncCall\JsonThrowOnErrorRector; use Rector\Php73\Rector\FuncCall\StringifyStrNeedlesRector; -use Rector\PHPUnit\Rector\Class_\AnnotationWithValueToAttributeRector; +use Rector\PHPUnit\AnnotationsToAttributes\Rector\Class_\AnnotationWithValueToAttributeRector; +use Rector\PHPUnit\CodeQuality\Rector\Class_\YieldDataProviderRector; use Rector\PHPUnit\Set\PHPUnitSetList; use Rector\Privatization\Rector\Property\PrivatizeFinalClassPropertyRector; -use Rector\PSR4\Rector\FileWithoutNamespace\NormalizeNamespaceByPSR4ComposerAutoloadRector; use Rector\Set\ValueObject\LevelSetList; use Rector\Set\ValueObject\SetList; @@ -46,7 +43,7 @@ $rectorConfig->sets([ SetList::DEAD_CODE, LevelSetList::UP_TO_PHP_74, - PHPUnitSetList::PHPUNIT_SPECIFIC_METHOD, + PHPUnitSetList::PHPUNIT_CODE_QUALITY, PHPUnitSetList::PHPUNIT_100, ]); @@ -68,9 +65,11 @@ realpath(getcwd()) . '/vendor/codeigniter4/framework/system/Test/bootstrap.php', ]); - if (is_file(__DIR__ . '/phpstan.neon.dist')) { - $rectorConfig->phpstanConfig(__DIR__ . '/phpstan.neon.dist'); - } + $rectorConfig->phpstanConfigs([ + __DIR__ . '/phpstan.neon.dist', + __DIR__ . '/vendor/codeigniter/phpstan-codeigniter/extension.neon', + __DIR__ . '/vendor/phpstan/phpstan-strict-rules/rules.neon', + ]); // Set the target version for refactoring $rectorConfig->phpVersion(PhpVersion::PHP_74); @@ -84,29 +83,15 @@ JsonThrowOnErrorRector::class, StringifyStrNeedlesRector::class, + YieldDataProviderRector::class, // Note: requires php 8 RemoveUnusedPromotedPropertyRector::class, AnnotationWithValueToAttributeRector::class, - // Ignore tests that might make calls without a result - RemoveEmptyMethodCallRector::class => [ - __DIR__ . '/tests', - ], - - // Ignore files that should not be namespaced to their folder - NormalizeNamespaceByPSR4ComposerAutoloadRector::class => [ - __DIR__ . '/src/Helpers', - __DIR__ . '/src/Language', - __DIR__ . '/tests/_support', - ], - // May load view files directly when detecting classes StringClassNameToClassConstantRector::class, - // May be uninitialized on purpose - AddDefaultValueForUndefinedVariableRector::class, - // See https://github.com/codeigniter4/shield/issues/228 RemoveJustPropertyFetchForAssignRector::class => [ __DIR__ . '/src/Models/UserModel.php', @@ -114,23 +99,30 @@ // Ignore tests that use CodeIgniter::CI_VERSION UnwrapFutureCompatibleIfPhpVersionRector::class => [ + __DIR__ . '/src/Test/MockInputOutput.php', + __DIR__ . '/tests/Commands/SetupTest.php', __DIR__ . '/tests/Commands/UserModelGeneratorTest.php', __DIR__ . '/tests/Controllers/LoginTest.php', - __DIR__ . '/tests/Commands/SetupTest.php', ], RemoveUnusedPrivatePropertyRector::class => [ + __DIR__ . '/src/Test/MockInputOutput.php', + __DIR__ . '/tests/Commands/SetupTest.php', __DIR__ . '/tests/Commands/UserModelGeneratorTest.php', __DIR__ . '/tests/Controllers/LoginTest.php', - __DIR__ . '/tests/Commands/SetupTest.php', + ], + + RecastingRemovalRector::class => [ + // To check old Email Config file + __DIR__ . '/src/Commands/Setup.php', ], ]); + // auto import fully qualified class names $rectorConfig->importNames(); $rectorConfig->rule(SimplifyUselessVariableRector::class); $rectorConfig->rule(RemoveAlwaysElseRector::class); $rectorConfig->rule(CountArrayToEmptyArrayComparisonRector::class); - $rectorConfig->rule(ForToForeachRector::class); $rectorConfig->rule(ChangeNestedForeachIfsToEarlyContinueRector::class); $rectorConfig->rule(ChangeIfElseValueAssignToEarlyReturnRector::class); $rectorConfig->rule(SimplifyStrposLowerRector::class); @@ -143,12 +135,10 @@ $rectorConfig->rule(UnusedForeachValueToArrayKeysRector::class); $rectorConfig->rule(ChangeArrayPushToArrayAssignRector::class); $rectorConfig->rule(UnnecessaryTernaryExpressionRector::class); - $rectorConfig->rule(AddPregQuoteDelimiterRector::class); $rectorConfig->rule(SimplifyRegexPatternRector::class); $rectorConfig->rule(FuncGetArgsToVariadicParamRector::class); $rectorConfig->rule(MakeInheritedMethodVisibilitySameAsParentRector::class); $rectorConfig->rule(SimplifyEmptyArrayCheckRector::class); - $rectorConfig->rule(NormalizeNamespaceByPSR4ComposerAutoloadRector::class); $rectorConfig->rule(StringClassNameToClassConstantRector::class); $rectorConfig->rule(PrivatizeFinalClassPropertyRector::class); $rectorConfig->rule(CompleteDynamicPropertiesRector::class); diff --git a/src/Auth.php b/src/Auth.php index 41163479a..32b163db0 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -28,7 +28,7 @@ class Auth /** * The current version of CodeIgniter Shield */ - public const SHIELD_VERSION = '1.0.0-beta.6'; + public const SHIELD_VERSION = '1.0.0-beta.7'; protected Authentication $authenticate; diff --git a/src/Authentication/Actions/Email2FA.php b/src/Authentication/Actions/Email2FA.php index 52827c99a..54883b7be 100644 --- a/src/Authentication/Actions/Email2FA.php +++ b/src/Authentication/Actions/Email2FA.php @@ -78,6 +78,7 @@ public function handle(IncomingRequest $request) $date = Time::now()->toDateTimeString(); // Send the user an email with the code + helper('email'); $email = emailer()->setFrom(setting('Email.fromEmail'), setting('Email.fromName') ?? ''); $email->setTo($user->email); $email->setSubject(lang('Auth.email2FASubject')); diff --git a/src/Authentication/Actions/EmailActivator.php b/src/Authentication/Actions/EmailActivator.php index 8e5216074..5017798f0 100644 --- a/src/Authentication/Actions/EmailActivator.php +++ b/src/Authentication/Actions/EmailActivator.php @@ -55,6 +55,7 @@ public function show(): string $date = Time::now()->toDateTimeString(); // Send the email + helper('email'); $email = emailer()->setFrom(setting('Email.fromEmail'), setting('Email.fromName') ?? ''); $email->setTo($userEmail); $email->setSubject(lang('Auth.emailActivateSubject')); diff --git a/src/Authentication/Authenticators/AccessTokens.php b/src/Authentication/Authenticators/AccessTokens.php index a09000703..f8e2e9254 100644 --- a/src/Authentication/Authenticators/AccessTokens.php +++ b/src/Authentication/Authenticators/AccessTokens.php @@ -8,6 +8,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\AuthenticationException; use CodeIgniter\Shield\Authentication\AuthenticatorInterface; +use CodeIgniter\Shield\Config\Auth; use CodeIgniter\Shield\Entities\User; use CodeIgniter\Shield\Exceptions\InvalidArgumentException; use CodeIgniter\Shield\Models\TokenLoginModel; @@ -42,6 +43,8 @@ public function __construct(UserModel $provider) */ public function attempt(array $credentials): Result { + $config = config('AuthToken'); + /** @var IncomingRequest $request */ $request = service('request'); @@ -51,14 +54,16 @@ public function attempt(array $credentials): Result $result = $this->check($credentials); if (! $result->isOK()) { - // Always record a login attempt, whether success or not. - $this->loginModel->recordLoginAttempt( - self::ID_TYPE_ACCESS_TOKEN, - $credentials['token'] ?? '', - false, - $ipAddress, - $userAgent - ); + if ($config->recordLoginAttempt >= Auth::RECORD_LOGIN_ATTEMPT_FAILURE) { + // Record all failed login attempts. + $this->loginModel->recordLoginAttempt( + self::ID_TYPE_ACCESS_TOKEN, + $credentials['token'] ?? '', + false, + $ipAddress, + $userAgent + ); + } return $result; } @@ -66,6 +71,18 @@ public function attempt(array $credentials): Result $user = $result->extraInfo(); if ($user->isBanned()) { + if ($config->recordLoginAttempt >= Auth::RECORD_LOGIN_ATTEMPT_FAILURE) { + // Record a banned login attempt. + $this->loginModel->recordLoginAttempt( + self::ID_TYPE_ACCESS_TOKEN, + $credentials['token'] ?? '', + false, + $ipAddress, + $userAgent, + $user->id + ); + } + $this->user = null; return new Result([ @@ -80,14 +97,17 @@ public function attempt(array $credentials): Result $this->login($user); - $this->loginModel->recordLoginAttempt( - self::ID_TYPE_ACCESS_TOKEN, - $credentials['token'] ?? '', - true, - $ipAddress, - $userAgent, - $this->user->id - ); + if ($config->recordLoginAttempt === Auth::RECORD_LOGIN_ATTEMPT_ALL) { + // Record a successful login attempt. + $this->loginModel->recordLoginAttempt( + self::ID_TYPE_ACCESS_TOKEN, + $credentials['token'] ?? '', + true, + $ipAddress, + $userAgent, + $this->user->id + ); + } return $result; } @@ -104,7 +124,10 @@ public function check(array $credentials): Result if (! array_key_exists('token', $credentials) || empty($credentials['token'])) { return new Result([ 'success' => false, - 'reason' => lang('Auth.noToken', [config('Auth')->authenticatorHeader['tokens']]), + 'reason' => lang( + 'Auth.noToken', + [config('AuthToken')->authenticatorHeader['tokens']] + ), ]); } @@ -129,7 +152,9 @@ public function check(array $credentials): Result // Hasn't been used in a long time if ( $token->last_used_at - && $token->last_used_at->isBefore(Time::now()->subSeconds(config('Auth')->unusedTokenLifetime)) + && $token->last_used_at->isBefore( + Time::now()->subSeconds(config('AuthToken')->unusedTokenLifetime) + ) ) { return new Result([ 'success' => false, @@ -168,7 +193,9 @@ public function loggedIn(): bool $request = service('request'); return $this->attempt([ - 'token' => $request->getHeaderLine(config('Auth')->authenticatorHeader['tokens']), + 'token' => $request->getHeaderLine( + config('AuthToken')->authenticatorHeader['tokens'] + ), ])->isOK(); } @@ -226,7 +253,7 @@ public function getBearerToken(): ?string /** @var IncomingRequest $request */ $request = service('request'); - $header = $request->getHeaderLine(config('Auth')->authenticatorHeader['tokens']); + $header = $request->getHeaderLine(config('AuthToken')->authenticatorHeader['tokens']); if (empty($header)) { return null; diff --git a/src/Authentication/Authenticators/HmacSha256.php b/src/Authentication/Authenticators/HmacSha256.php new file mode 100644 index 000000000..697c7a447 --- /dev/null +++ b/src/Authentication/Authenticators/HmacSha256.php @@ -0,0 +1,338 @@ +provider = $provider; + + $this->loginModel = model(TokenLoginModel::class); + } + + /** + * Attempts to authenticate a user with the given $credentials. + * Logs the user in with a successful check. + * + * @throws AuthenticationException + */ + public function attempt(array $credentials): Result + { + $config = config('AuthToken'); + + /** @var IncomingRequest $request */ + $request = service('request'); + + $ipAddress = $request->getIPAddress(); + $userAgent = (string) $request->getUserAgent(); + + $result = $this->check($credentials); + + if (! $result->isOK()) { + if ($config->recordLoginAttempt >= Auth::RECORD_LOGIN_ATTEMPT_FAILURE) { + // Record all failed login attempts. + $this->loginModel->recordLoginAttempt( + self::ID_TYPE_HMAC_TOKEN, + $credentials['token'] ?? '', + false, + $ipAddress, + $userAgent + ); + } + + return $result; + } + + $user = $result->extraInfo(); + + if ($user->isBanned()) { + if ($config->recordLoginAttempt >= Auth::RECORD_LOGIN_ATTEMPT_FAILURE) { + // Record a banned login attempt. + $this->loginModel->recordLoginAttempt( + self::ID_TYPE_HMAC_TOKEN, + $credentials['token'] ?? '', + false, + $ipAddress, + $userAgent, + $user->id + ); + } + + $this->user = null; + + return new Result([ + 'success' => false, + 'reason' => $user->getBanMessage() ?? lang('Auth.bannedUser'), + ]); + } + + $user = $user->setHmacToken( + $user->getHmacToken($this->getHmacKeyFromToken()) + ); + + $this->login($user); + + if ($config->recordLoginAttempt === Auth::RECORD_LOGIN_ATTEMPT_ALL) { + // Record a successful login attempt. + $this->loginModel->recordLoginAttempt( + self::ID_TYPE_HMAC_TOKEN, + $credentials['token'] ?? '', + true, + $ipAddress, + $userAgent, + $this->user->id + ); + } + + return $result; + } + + /** + * Checks a user's $credentials to see if they match an + * existing user. + * + * In this case, $credentials has only a single valid value: token, + * which is the plain text token to return. + */ + public function check(array $credentials): Result + { + if (! array_key_exists('token', $credentials) || $credentials['token'] === '') { + return new Result([ + 'success' => false, + 'reason' => lang( + 'Auth.noToken', + [config('AuthToken')->authenticatorHeader['hmac']] + ), + ]); + } + + if (strpos($credentials['token'], 'HMAC-SHA256') === 0) { + $credentials['token'] = trim(substr($credentials['token'], 11)); // HMAC-SHA256 + } + + // Extract UserToken and HMACSHA256 Signature from Authorization token + [$userToken, $signature] = $this->getHmacAuthTokens($credentials['token']); + + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + $token = $identityModel->getHmacTokenByKey($userToken); + + if ($token === null) { + return new Result([ + 'success' => false, + 'reason' => lang('Auth.badToken'), + ]); + } + + // Check signature... + $hash = hash_hmac('sha256', $credentials['body'], $token->secret2); + if ($hash !== $signature) { + return new Result([ + 'success' => false, + 'reason' => lang('Auth.badToken'), + ]); + } + + assert($token->last_used_at instanceof Time || $token->last_used_at === null); + + // Hasn't been used in a long time + if ( + isset($token->last_used_at) + && $token->last_used_at->isBefore( + Time::now()->subSeconds(config('AuthToken')->unusedTokenLifetime) + ) + ) { + return new Result([ + 'success' => false, + 'reason' => lang('Auth.oldToken'), + ]); + } + + $token->last_used_at = Time::now()->format('Y-m-d H:i:s'); + + if ($token->hasChanged()) { + $identityModel->save($token); + } + + // Ensure the token is set as the current token + $user = $token->user(); + $user->setHmacToken($token); + + return new Result([ + 'success' => true, + 'extraInfo' => $user, + ]); + } + + /** + * Checks if the user is currently logged in. + * Since AccessToken usage is inherently stateless, + * it runs $this->attempt on each usage. + */ + public function loggedIn(): bool + { + if (isset($this->user)) { + return true; + } + + /** @var IncomingRequest $request */ + $request = service('request'); + + return $this->attempt([ + 'token' => $request->getHeaderLine( + config('AuthToken')->authenticatorHeader['hmac'] + ), + ])->isOK(); + } + + /** + * Logs the given user in by saving them to the class. + */ + public function login(User $user): void + { + $this->user = $user; + } + + /** + * Logs a user in based on their ID. + * + * @param int|string $userId User ID + * + * @throws AuthenticationException + */ + public function loginById($userId): void + { + $user = $this->provider->findById($userId); + + if ($user === null) { + throw AuthenticationException::forInvalidUser(); + } + + $user->setHmacToken( + $user->getHmacToken($this->getHmacKeyFromToken()) + ); + + $this->login($user); + } + + /** + * Logs the current user out. + */ + public function logout(): void + { + $this->user = null; + } + + /** + * Returns the currently logged-in user. + */ + public function getUser(): ?User + { + return $this->user; + } + + /** + * Returns the Full HMAC Authorization token from the Authorization header + * + * @return ?string Trimmed Authorization Token from Header + */ + public function getFullHmacToken(): ?string + { + /** @var IncomingRequest $request */ + $request = service('request'); + + $header = $request->getHeaderLine(config('AuthToken')->authenticatorHeader['hmac']); + + if ($header === '') { + return null; + } + + return trim(substr($header, 11)); // 'HMAC-SHA256' + } + + /** + * Get Key and HMAC hash from Auth token + * + * @param ?string $fullToken Full Token + * + * @return ?array [key, hmacHash] + */ + public function getHmacAuthTokens(?string $fullToken = null): ?array + { + if (! isset($fullToken)) { + $fullToken = $this->getFullHmacToken(); + } + + if (isset($fullToken)) { + return preg_split('/:/', $fullToken, -1, PREG_SPLIT_NO_EMPTY); + } + + return null; + } + + /** + * Retrieve the key from the Auth token + * + * @return ?string HMAC token key + */ + public function getHmacKeyFromToken(): ?string + { + [$key, $secretKey] = $this->getHmacAuthTokens(); + + return $key; + } + + /** + * Retrieve the HMAC Hash from the Auth token + * + * @return ?string HMAC Hash + */ + public function getHmacHashFromToken(): ?string + { + [$key, $hash] = $this->getHmacAuthTokens(); + + return $hash; + } + + /** + * Updates the user's last active date. + */ + public function recordActiveDate(): void + { + if (! $this->user instanceof User) { + throw new InvalidArgumentException( + __METHOD__ . '() requires logged in user before calling.' + ); + } + + $this->user->last_active = Time::now(); + + $this->provider->updateActiveDate($this->user); + } +} diff --git a/src/Authentication/Authenticators/JWT.php b/src/Authentication/Authenticators/JWT.php index c223bdbc7..09590275c 100644 --- a/src/Authentication/Authenticators/JWT.php +++ b/src/Authentication/Authenticators/JWT.php @@ -61,7 +61,8 @@ public function __construct(UserModel $provider) */ public function attempt(array $credentials): Result { - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); /** @var IncomingRequest $request */ $request = service('request'); @@ -142,7 +143,7 @@ public function check(array $credentials): Result 'success' => false, 'reason' => lang( 'Auth.noToken', - [config(AuthJWT::class)->authenticatorHeader] + [config('AuthJWT')->authenticatorHeader] ), ]); } @@ -196,7 +197,8 @@ public function loggedIn(): bool /** @var IncomingRequest $request */ $request = service('request'); - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); return $this->attempt([ 'token' => $request->getHeaderLine($config->authenticatorHeader), diff --git a/src/Authentication/JWT/Adapters/FirebaseAdapter.php b/src/Authentication/JWT/Adapters/FirebaseAdapter.php index 3a246ed3f..70141393d 100644 --- a/src/Authentication/JWT/Adapters/FirebaseAdapter.php +++ b/src/Authentication/JWT/Adapters/FirebaseAdapter.php @@ -79,7 +79,8 @@ public function decode(string $encodedToken, $keyset): stdClass */ private function createKeysForDecode($keyset) { - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $configKeys = $config->keys[$keyset]; @@ -127,7 +128,8 @@ public function encode(array $payload, $keyset, ?array $headers = null): string */ private function createKeysForEncode($keyset): array { - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); if (isset($config->keys[$keyset][0]['secret'])) { $key = $config->keys[$keyset][0]['secret']; diff --git a/src/Authentication/JWT/JWSEncoder.php b/src/Authentication/JWT/JWSEncoder.php index 327d5ea03..e426e7eda 100644 --- a/src/Authentication/JWT/JWSEncoder.php +++ b/src/Authentication/JWT/JWSEncoder.php @@ -39,7 +39,8 @@ public function encode( 'Cannot pass $claims[\'exp\'] and $ttl at the same time.' ); - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $payload = array_merge( $config->defaultClaims, diff --git a/src/Authentication/Passwords.php b/src/Authentication/Passwords.php index 3eca6c71f..3a5b7b041 100644 --- a/src/Authentication/Passwords.php +++ b/src/Authentication/Passwords.php @@ -143,7 +143,7 @@ public function check(string $password, ?User $user = null): Result /** * Returns the validation rule for max length. */ - public static function getMaxLenghtRule(): string + public static function getMaxLengthRule(): string { if (config('Auth')->hashAlgorithm === PASSWORD_BCRYPT) { return 'max_byte[72]'; diff --git a/src/Authentication/Passwords/NothingPersonalValidator.php b/src/Authentication/Passwords/NothingPersonalValidator.php index 6a4102e6b..92151e8b0 100644 --- a/src/Authentication/Passwords/NothingPersonalValidator.php +++ b/src/Authentication/Passwords/NothingPersonalValidator.php @@ -72,10 +72,8 @@ protected function isNotPersonal(string $password, ?User $user): bool $needles = $this->strip_explode($userName); // extract local-part and domain parts from email as separate needles - [ - $localPart, - $domain, - ] = explode('@', $email); + [$localPart, $domain] = explode('@', $email) + [1 => null]; + // might be john.doe@example.com and we want all the needles we can get $emailParts = $this->strip_explode($localPart); if (! empty($domain)) { diff --git a/src/Authentication/Passwords/ValidationRules.php b/src/Authentication/Passwords/ValidationRules.php index 53f078b8e..2ad59c87d 100644 --- a/src/Authentication/Passwords/ValidationRules.php +++ b/src/Authentication/Passwords/ValidationRules.php @@ -39,6 +39,7 @@ public function strong_password(string $value, ?string &$error1 = null, array $d if (function_exists('auth') && auth()->user()) { $user = auth()->user(); } else { + /** @phpstan-ignore-next-line */ $user = empty($data) ? $this->buildUserFromRequest() : $this->buildUserFromData($data); } @@ -65,6 +66,10 @@ public function max_byte(?string $str, string $val): bool /** * Builds a new user instance from the global request. + * + * @deprecated This will be removed soon. + * + * @see https://github.com/codeigniter4/shield/pull/747#discussion_r1198778666 */ protected function buildUserFromRequest(): User { @@ -97,10 +102,9 @@ protected function buildUserFromData(array $data = []): User */ protected function prepareValidFields(): array { - $config = config('Auth'); - $fields = array_merge($config->validFields, $config->personalFields); - $fields[] = 'password'; + $config = config('Auth'); + $fields = array_merge($config->validFields, $config->personalFields, ['email', 'password']); - return $fields; + return array_unique($fields); } } diff --git a/src/Authentication/Traits/HasAccessTokens.php b/src/Authentication/Traits/HasAccessTokens.php index 389fe9576..e19aecaa2 100644 --- a/src/Authentication/Traits/HasAccessTokens.php +++ b/src/Authentication/Traits/HasAccessTokens.php @@ -47,6 +47,17 @@ public function revokeAccessToken(string $rawToken): void $identityModel->revokeAccessToken($this, $rawToken); } + /** + * Delete any access tokens for the given secret token. + */ + public function revokeAccessTokenBySecret(string $secretToken): void + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + $identityModel->revokeAccessTokenBySecret($this, $secretToken); + } + /** * Revokes all access tokens for this user. */ diff --git a/src/Authentication/Traits/HasHmacTokens.php b/src/Authentication/Traits/HasHmacTokens.php new file mode 100644 index 000000000..435a19bd2 --- /dev/null +++ b/src/Authentication/Traits/HasHmacTokens.php @@ -0,0 +1,150 @@ +generateHmacToken($this, $name, $scopes); + } + + /** + * Delete any HMAC tokens for the given key. + */ + public function revokeHmacToken(string $key): void + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + $identityModel->revokeHmacToken($this, $key); + } + + /** + * Revokes all HMAC tokens for this user. + */ + public function revokeAllHmacTokens(): void + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + $identityModel->revokeAllHmacTokens($this); + } + + /** + * Retrieves all personal HMAC tokens for this user. + * + * @return AccessToken[] + */ + public function hmacTokens(): array + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + return $identityModel->getAllHmacTokens($this); + } + + /** + * Given an HMAC Key, it will locate it within the system. + */ + public function getHmacToken(?string $key): ?AccessToken + { + if (! isset($key) || $key === '') { + return null; + } + + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + return $identityModel->getHmacToken($this, $key); + } + + /** + * Given the ID, returns the given access token. + */ + public function getHmacTokenById(int $id): ?AccessToken + { + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + return $identityModel->getHmacTokenById($id, $this); + } + + /** + * Determines whether the user's token grants permissions to $scope. + * First checks against $this->activeToken, which is set during + * authentication. If it hasn't been set, returns false. + */ + public function hmacTokenCan(string $scope): bool + { + if (! $this->currentHmacToken() instanceof AccessToken) { + return false; + } + + return $this->currentHmacToken()->can($scope); + } + + /** + * Determines whether the user's token does NOT grant permissions to $scope. + * First checks against $this->activeToken, which is set during + * authentication. If it hasn't been set, returns true. + */ + public function hmacTokenCant(string $scope): bool + { + if (! $this->currentHmacToken() instanceof AccessToken) { + return true; + } + + return $this->currentHmacToken()->cant($scope); + } + + /** + * Returns the current HMAC token for the user. + */ + public function currentHmacToken(): ?AccessToken + { + return $this->currentHmacToken; + } + + /** + * Sets the current active token for this user. + * + * @return $this + */ + public function setHmacToken(?AccessToken $accessToken): self + { + $this->currentHmacToken = $accessToken; + + return $this; + } +} diff --git a/src/Authorization/Traits/Authorizable.php b/src/Authorization/Traits/Authorizable.php index 9e3f32ff1..6cee326ce 100644 --- a/src/Authorization/Traits/Authorizable.php +++ b/src/Authorization/Traits/Authorizable.php @@ -226,49 +226,54 @@ public function hasPermission(string $permission): bool /** * Checks user permissions and their group permissions - * to see if the user has a specific permission. + * to see if the user has a specific permission or group + * of permissions. * - * @param string $permission string consisting of a scope and action, like `users.create` + * @param string $permissions string(s) consisting of a scope and action, like `users.create` */ - public function can(string $permission): bool + public function can(string ...$permissions): bool { - if (strpos($permission, '.') === false) { - throw new LogicException( - 'A permission must be a string consisting of a scope and action, like `users.create`.' - . ' Invalid permission: ' . $permission - ); - } - + // Get user's permissions and store in cache $this->populatePermissions(); - $permission = strtolower($permission); - - // Check user's permissions - if (in_array($permission, $this->permissionsCache, true)) { - return true; - } - // Check the groups the user belongs to $this->populateGroups(); - if (! count($this->groupCache)) { - return false; - } + foreach ($permissions as $permission) { + // Permission must contain a scope and action + if (strpos($permission, '.') === false) { + throw new LogicException( + 'A permission must be a string consisting of a scope and action, like `users.create`.' + . ' Invalid permission: ' . $permission + ); + } - $matrix = function_exists('setting') - ? setting('AuthGroups.matrix') - : config('AuthGroups')->matrix; + $permission = strtolower($permission); - foreach ($this->groupCache as $group) { - // Check exact match - if (isset($matrix[$group]) && in_array($permission, $matrix[$group], true)) { + // Check user's permissions + if (in_array($permission, $this->permissionsCache, true)) { return true; } - // Check wildcard match - $check = substr($permission, 0, strpos($permission, '.')) . '.*'; - if (isset($matrix[$group]) && in_array($check, $matrix[$group], true)) { - return true; + if (! count($this->groupCache)) { + return false; + } + + $matrix = function_exists('setting') + ? setting('AuthGroups.matrix') + : config('AuthGroups')->matrix; + + foreach ($this->groupCache as $group) { + // Check exact match + if (isset($matrix[$group]) && in_array($permission, $matrix[$group], true)) { + return true; + } + + // Check wildcard match + $check = substr($permission, 0, strpos($permission, '.')) . '.*'; + if (isset($matrix[$group]) && in_array($check, $matrix[$group], true)) { + return true; + } } } diff --git a/src/Commands/BaseCommand.php b/src/Commands/BaseCommand.php new file mode 100644 index 000000000..2b30856f0 --- /dev/null +++ b/src/Commands/BaseCommand.php @@ -0,0 +1,89 @@ +ensureInputOutput(); + } + + /** + * Asks the user for input. + * + * @param string $field Output "field" question + * @param array|string $options String to a default value, array to a list of options (the first option will be the default value) + * @param array|string $validation Validation rules + * + * @return string The user input + */ + protected function prompt(string $field, $options = null, $validation = null): string + { + return self::$io->prompt($field, $options, $validation); + } + + /** + * Outputs a string to the cli on its own line. + */ + protected function write( + string $text = '', + ?string $foreground = null, + ?string $background = null + ): void { + self::$io->write($text, $foreground, $background); + } + + /** + * Outputs an error to the CLI using STDERR instead of STDOUT + */ + protected function error( + string $text, + string $foreground = 'light_red', + ?string $background = null + ): void { + self::$io->error($text, $foreground, $background); + } + + protected function ensureInputOutput(): void + { + if (self::$io === null) { + self::$io = new InputOutput(); + } + } + + /** + * @internal Testing purpose only + */ + public static function setInputOutput(InputOutput $io): void + { + self::$io = $io; + } + + /** + * @internal Testing purpose only + */ + public static function resetInputOutput(): void + { + self::$io = null; + } +} diff --git a/src/Commands/Exceptions/BadInputException.php b/src/Commands/Exceptions/BadInputException.php new file mode 100644 index 000000000..0316394e1 --- /dev/null +++ b/src/Commands/Exceptions/BadInputException.php @@ -0,0 +1,11 @@ +execute($params); // @phpstan-ignore-line suppress deprecated error. + $this->generateClass($params); return 0; } diff --git a/src/Commands/Setup.php b/src/Commands/Setup.php index c9ced3efd..a39e7de10 100644 --- a/src/Commands/Setup.php +++ b/src/Commands/Setup.php @@ -4,22 +4,15 @@ namespace CodeIgniter\Shield\Commands; -use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; use CodeIgniter\Commands\Database\Migrate; use CodeIgniter\Shield\Commands\Setup\ContentReplacer; +use CodeIgniter\Test\Filters\CITestStreamFilter; +use Config\Email as EmailConfig; use Config\Services; class Setup extends BaseCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Shield'; - /** * The Command's name * @@ -83,11 +76,13 @@ private function publishConfig(): void { $this->publishConfigAuth(); $this->publishConfigAuthGroups(); + $this->publishConfigAuthToken(); $this->setupHelper(); $this->setupRoutes(); $this->setSecurityCSRF(); + $this->setupEmail(); $this->runMigrations(); } @@ -131,6 +126,18 @@ private function publishConfigAuthGroups(): void $this->copyAndReplace($file, $replaces); } + private function publishConfigAuthToken(): void + { + $file = 'Config/AuthToken.php'; + $replaces = [ + 'namespace CodeIgniter\Shield\Config' => 'namespace Config', + 'use CodeIgniter\\Config\\BaseConfig;' => 'use CodeIgniter\\Shield\\Config\\AuthToken as ShieldAuthToken;', + 'extends BaseConfig' => 'extends ShieldAuthToken', + ]; + + $this->copyAndReplace($file, $replaces); + } + /** * Write a file, catching any exceptions and showing a * nicely formatted error. @@ -153,18 +160,18 @@ protected function writeFile(string $file, string $content): void if ( ! $overwrite - && CLI::prompt(" File '{$cleanPath}' already exists in destination. Overwrite?", ['n', 'y']) === 'n' + && $this->prompt(" File '{$cleanPath}' already exists in destination. Overwrite?", ['n', 'y']) === 'n' ) { - CLI::error(" Skipped {$cleanPath}. If you wish to overwrite, please use the '-f' option or reply 'y' to the prompt."); + $this->error(" Skipped {$cleanPath}. If you wish to overwrite, please use the '-f' option or reply 'y' to the prompt."); return; } } if (write_file($path, $content)) { - CLI::write(CLI::color(' Created: ', 'green') . $cleanPath); + $this->write(CLI::color(' Created: ', 'green') . $cleanPath); } else { - CLI::error(" Error creating {$cleanPath}."); + $this->error(" Error creating {$cleanPath}."); } } @@ -182,20 +189,20 @@ protected function add(string $file, string $code, string $pattern, string $repl $output = $this->replacer->add($content, $code, $pattern, $replace); if ($output === true) { - CLI::error(" Skipped {$cleanPath}. It has already been updated."); + $this->error(" Skipped {$cleanPath}. It has already been updated."); return; } if ($output === false) { - CLI::error(" Error checking {$cleanPath}."); + $this->error(" Error checking {$cleanPath}."); return; } if (write_file($path, $output)) { - CLI::write(CLI::color(' Updated: ', 'green') . $cleanPath); + $this->write(CLI::color(' Updated: ', 'green') . $cleanPath); } else { - CLI::error(" Error updating {$cleanPath}."); + $this->error(" Error updating {$cleanPath}."); } } @@ -219,12 +226,12 @@ private function replace(string $file, array $replaces): bool } if (write_file($path, $output)) { - CLI::write(CLI::color(' Updated: ', 'green') . $cleanPath); + $this->write(CLI::color(' Updated: ', 'green') . $cleanPath); return true; } - CLI::error(" Error updating {$cleanPath}."); + $this->error(" Error updating {$cleanPath}."); return false; } @@ -274,7 +281,7 @@ private function setSecurityCSRF(): void $cleanPath = clean_path($path); if (! is_file($path)) { - CLI::error(" Not found file '{$cleanPath}'."); + $this->error(" Not found file '{$cleanPath}'."); return; } @@ -284,35 +291,102 @@ private function setSecurityCSRF(): void // check $csrfProtection = 'session' if ($output === $content) { - CLI::write(CLI::color(' Security Setup: ', 'green') . 'Everything is fine.'); + $this->write(CLI::color(' Security Setup: ', 'green') . 'Everything is fine.'); return; } if (write_file($path, $output)) { - CLI::write(CLI::color(' Updated: ', 'green') . "We have updated file '{$cleanPath}' for security reasons."); + $this->write(CLI::color(' Updated: ', 'green') . "We have updated file '{$cleanPath}' for security reasons."); } else { - CLI::error(" Error updating file '{$cleanPath}'."); + $this->error(" Error updating file '{$cleanPath}'."); + } + } + + private function setupEmail(): void + { + $file = 'Config/Email.php'; + + $path = $this->distPath . $file; + $cleanPath = clean_path($path); + + if (! is_file($path)) { + $this->error(" Not found file '{$cleanPath}'."); + + return; + } + + $config = config(EmailConfig::class); + $fromEmail = (string) $config->fromEmail; // Old Config may return null. + $fromName = (string) $config->fromName; + + if ($fromEmail !== '' && $fromName !== '') { + $this->write(CLI::color(' Email Setup: ', 'green') . 'Everything is fine.'); + + return; + } + + $content = file_get_contents($path); + $output = $content; + + if ($fromEmail === '') { + $set = $this->prompt(' The required Config\Email::$fromEmail is not set. Do you set now?', ['y', 'n']); + + if ($set === 'y') { + // Input from email + $fromEmail = $this->prompt(' What is your email?', null, 'required|valid_email'); + + $pattern = '/^ public .*\$fromEmail\s+= \'\';/mu'; + $replace = ' public string $fromEmail = \'' . $fromEmail . '\';'; + $output = preg_replace($pattern, $replace, $content); + } + } + + if ($fromName === '') { + $set = $this->prompt(' The required Config\Email::$fromName is not set. Do you set now?', ['y', 'n']); + + if ($set === 'y') { + $fromName = $this->prompt(' What is your name?', null, 'required'); + + $pattern = '/^ public .*\$fromName\s+= \'\';/mu'; + $replace = ' public string $fromName = \'' . $fromName . '\';'; + $output = preg_replace($pattern, $replace, $output); + } + } + + if (write_file($path, $output)) { + $this->write(CLI::color(' Updated: ', 'green') . $cleanPath); + } else { + $this->error(" Error updating file '{$cleanPath}'."); } } private function runMigrations(): void { if ( - $this->cliPrompt(' Run `spark migrate --all` now?', ['y', 'n']) === 'n' + $this->prompt(' Run `spark migrate --all` now?', ['y', 'n']) === 'n' ) { return; } $command = new Migrate(Services::logger(), Services::commands()); + + // This is a hack for testing. + // @TODO Remove CITestStreamFilter and refactor when CI 4.5.0 or later is supported. + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + CITestStreamFilter::addErrorFilter(); + $command->run(['all' => null]); - } - /** - * This method is for testing. - */ - protected function cliPrompt(string $field, array $options): string - { - return CLI::prompt($field, $options); + CITestStreamFilter::removeOutputFilter(); + CITestStreamFilter::removeErrorFilter(); + + // Capture the output, and write for testing. + // @TODO Remove CITestStreamFilter and refactor when CI 4.5.0 or later is supported. + $output = CITestStreamFilter::$buffer; + $this->write($output); + + CITestStreamFilter::$buffer = ''; } } diff --git a/src/Commands/User.php b/src/Commands/User.php new file mode 100644 index 000000000..e5d42daa9 --- /dev/null +++ b/src/Commands/User.php @@ -0,0 +1,658 @@ + options + + shield:user create -n newusername -e newuser@example.com + + shield:user activate -n username + shield:user activate -e user@example.com + + shield:user deactivate -n username + shield:user deactivate -e user@example.com + + shield:user changename -n username --new-name newusername + shield:user changename -e user@example.com --new-name newusername + + shield:user changeemail -n username --new-email newuseremail@example.com + shield:user changeemail -e user@example.com --new-email newuseremail@example.com + + shield:user delete -i 123 + shield:user delete -n username + shield:user delete -e user@example.com + + shield:user password -n username + shield:user password -e user@example.com + + shield:user list + shield:user list -n username -e user@example.com + + shield:user addgroup -n username -g mygroup + shield:user addgroup -e user@example.com -g mygroup + + shield:user removegroup -n username -g mygroup + shield:user removegroup -e user@example.com -g mygroup + EOL; + + /** + * Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'action' => <<<'EOL' + + create: Create a new user + activate: Activate a user + deactivate: Deactivate a user + changename: Change user name + changeemail: Change user email + delete: Delete a user + password: Change a user password + list: List users + addgroup: Add a user to a group + removegroup: Remove a user from a group + EOL, + ]; + + /** + * Command's Options + * + * @var array + */ + protected $options = [ + '-i' => 'User id', + '-n' => 'User name', + '-e' => 'User email', + '--new-name' => 'New username', + '--new-email' => 'New email', + '-g' => 'Group name', + ]; + + /** + * Validation rules for user fields + */ + private array $validationRules = []; + + /** + * Auth Table names + * + * @var array + */ + private array $tables = []; + + /** + * Displays the help for the spark cli script itself. + */ + public function run(array $params): int + { + $this->setTables(); + $this->setValidationRules(); + + $action = $params[0] ?? null; + + if ($action === null || ! in_array($action, $this->validActions, true)) { + $this->write( + 'Specify a valid action: ' . implode(',', $this->validActions), + 'red' + ); + + return EXIT_ERROR; + } + + $userid = (int) ($params['i'] ?? 0); + $username = $params['n'] ?? null; + $email = $params['e'] ?? null; + $newUsername = $params['new-name'] ?? null; + $newEmail = $params['new-email'] ?? null; + $group = $params['g'] ?? null; + + try { + switch ($action) { + case 'create': + $this->create($username, $email); + break; + + case 'activate': + $this->activate($username, $email); + break; + + case 'deactivate': + $this->deactivate($username, $email); + break; + + case 'changename': + $this->changename($username, $email, $newUsername); + break; + + case 'changeemail': + $this->changeemail($username, $email, $newEmail); + break; + + case 'delete': + $this->delete($userid, $username, $email); + break; + + case 'password': + $this->password($username, $email); + break; + + case 'list': + $this->list($username, $email); + break; + + case 'addgroup': + $this->addgroup($group, $username, $email); + break; + + case 'removegroup': + $this->removegroup($group, $username, $email); + break; + } + } catch (BadInputException|CancelException|UserNotFoundException $e) { + $this->write($e->getMessage(), 'red'); + + return EXIT_ERROR; + } + + return EXIT_SUCCESS; + } + + private function setTables(): void + { + /** @var Auth $config */ + $config = config('Auth'); + $this->tables = $config->tables; + } + + private function setValidationRules(): void + { + $validationRules = new ValidationRules(); + + $rules = $validationRules->getRegistrationRules(); + + // Remove `strong_password` because it only supports use cases + // to check the user's own password. + $passwordRules = $rules['password']['rules']; + if (is_string($passwordRules)) { + $passwordRules = explode('|', $passwordRules); + } + if (($key = array_search('strong_password[]', $passwordRules, true)) !== false) { + unset($passwordRules[$key]); + } + if (($key = array_search('strong_password', $passwordRules, true)) !== false) { + unset($passwordRules[$key]); + } + + /** @var Auth $config */ + $config = config('Auth'); + + // Add `min_length` + $passwordRules[] = 'min_length[' . $config->minimumPasswordLength . ']'; + + $rules['password']['rules'] = $passwordRules; + + $this->validationRules = [ + 'username' => $rules['username'], + 'email' => $rules['email'], + 'password' => $rules['password'], + ]; + } + + /** + * Create a new user + * + * @param string|null $username User name to create (optional) + * @param string|null $email User email to create (optional) + */ + private function create(?string $username = null, ?string $email = null): void + { + $data = []; + + if ($username === null) { + $username = $this->prompt('Username', null, $this->validationRules['username']['rules']); + } + $data['username'] = $username; + + if ($email === null) { + $email = $this->prompt('Email', null, $this->validationRules['email']['rules']); + } + $data['email'] = $email; + + $password = $this->prompt( + 'Password', + null, + $this->validationRules['password']['rules'] + ); + $passwordConfirm = $this->prompt( + 'Password confirmation', + null, + $this->validationRules['password']['rules'] + ); + + if ($password !== $passwordConfirm) { + throw new BadInputException("The passwords don't match"); + } + $data['password'] = $password; + + // Run validation if the user has passed username and/or email via command line + $validation = Services::validation(); + $validation->setRules($this->validationRules); + + if (! $validation->run($data)) { + foreach ($validation->getErrors() as $message) { + $this->write($message, 'red'); + } + + throw new CancelException('User creation aborted'); + } + + $userModel = model(UserModel::class); + + $user = new UserEntity($data); + $userModel->save($user); + + $this->write('User "' . $username . '" created', 'green'); + } + + /** + * Activate an existing user by username or email + * + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function activate(?string $username = null, ?string $email = null): void + { + $user = $this->findUser('Activate user', $username, $email); + + $confirm = $this->prompt('Activate the user ' . $user->username . ' ?', ['y', 'n']); + + if ($confirm === 'y') { + $userModel = model(UserModel::class); + + $user->active = 1; + $userModel->save($user); + + $this->write('User "' . $user->username . '" activated', 'green'); + } else { + $this->write('User "' . $user->username . '" activation cancelled', 'yellow'); + } + } + + /** + * Deactivate an existing user by username or email + * + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function deactivate(?string $username = null, ?string $email = null): void + { + $user = $this->findUser('Deactivate user', $username, $email); + + $confirm = $this->prompt('Deactivate the user "' . $username . '" ?', ['y', 'n']); + + if ($confirm === 'y') { + $userModel = model(UserModel::class); + + $user->active = 0; + $userModel->save($user); + + $this->write('User "' . $user->username . '" deactivated', 'green'); + } else { + $this->write('User "' . $user->username . '" deactivation cancelled', 'yellow'); + } + } + + /** + * Change the name of an existing user by username or email + * + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + * @param string|null $newUsername User new name (optional) + */ + private function changename( + ?string $username = null, + ?string $email = null, + ?string $newUsername = null + ): void { + $user = $this->findUser('Change username', $username, $email); + + if ($newUsername === null) { + $newUsername = $this->prompt('New username', null, $this->validationRules['username']['rules']); + } else { + // Run validation if the user has passed username and/or email via command line + $validation = Services::validation(); + $validation->setRules([ + 'username' => $this->validationRules['username'], + ]); + + if (! $validation->run(['username' => $newUsername])) { + foreach ($validation->getErrors() as $message) { + $this->write($message, 'red'); + } + + throw new CancelException('User name change aborted'); + } + } + + $userModel = model(UserModel::class); + + $oldUsername = $user->username; + $user->username = $newUsername; + $userModel->save($user); + + $this->write('Username "' . $oldUsername . '" changed to "' . $newUsername . '"', 'green'); + } + + /** + * Change the email of an existing user by username or email + * + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + * @param string|null $newEmail User new email (optional) + */ + private function changeemail( + ?string $username = null, + ?string $email = null, + ?string $newEmail = null + ): void { + $user = $this->findUser('Change email', $username, $email); + + if ($newEmail === null) { + $newEmail = $this->prompt('New email', null, $this->validationRules['email']['rules']); + } else { + // Run validation if the user has passed username and/or email via command line + $validation = Services::validation(); + $validation->setRules([ + 'email' => $this->validationRules['email'], + ]); + + if (! $validation->run(['email' => $newEmail])) { + foreach ($validation->getErrors() as $message) { + $this->write($message, 'red'); + } + + throw new CancelException('User email change aborted'); + } + } + + $userModel = model(UserModel::class); + + $user->email = $newEmail; + $userModel->save($user); + + $this->write('Email for "' . $user->username . '" changed to ' . $newEmail, 'green'); + } + + /** + * Delete an existing user by username or email + * + * @param int $userid User id to delete (optional) + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function delete(int $userid = 0, ?string $username = null, ?string $email = null): void + { + $userModel = model(UserModel::class); + + if ($userid !== 0) { + $user = $userModel->findById($userid); + + $this->checkUserExists($user); + } else { + $user = $this->findUser('Delete user', $username, $email); + } + + $confirm = $this->prompt( + 'Delete the user "' . $user->username . '" (' . $user->email . ') ?', + ['y', 'n'] + ); + + if ($confirm === 'y') { + $userModel->delete($user->id, true); + + $this->write('User "' . $user->username . '" deleted', 'green'); + } else { + $this->write('User "' . $user->username . '" deletion cancelled', 'yellow'); + } + } + + /** + * @param UserEntity|null $user + */ + private function checkUserExists($user): void + { + if ($user === null) { + throw new UserNotFoundException("User doesn't exist"); + } + } + + /** + * Change the password of an existing user by username or email + * + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function password($username = null, $email = null): void + { + $user = $this->findUser('Change user password', $username, $email); + + $confirm = $this->prompt('Set the password for "' . $user->username . '" ?', ['y', 'n']); + + if ($confirm === 'y') { + $password = $this->prompt( + 'Password', + null, + $this->validationRules['password']['rules'] + ); + $passwordConfirm = $this->prompt( + 'Password confirmation', + null, + $this->validationRules['password']['rules'] + ); + + if ($password !== $passwordConfirm) { + throw new BadInputException("The passwords don't match"); + } + + $userModel = model(UserModel::class); + + $user->password = $password; + $userModel->save($user); + + $this->write('Password for "' . $user->username . '" set', 'green'); + } else { + $this->write('Password setting for "' . $user->username . '" cancelled', 'yellow'); + } + } + + /** + * List users searching by username or email + * + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function list(?string $username = null, ?string $email = null): void + { + $userModel = model(UserModel::class); + $userModel + ->select($this->tables['users'] . '.id as id, username, secret as email') + ->join( + $this->tables['identities'], + $this->tables['users'] . '.id = ' . $this->tables['identities'] . '.user_id', + 'LEFT' + ) + ->groupStart() + ->where($this->tables['identities'] . '.type', Session::ID_TYPE_EMAIL_PASSWORD) + ->orGroupStart() + ->where($this->tables['identities'] . '.type', null) + ->groupEnd() + ->groupEnd() + ->asArray(); + + if ($username !== null) { + $userModel->like('username', $username); + } + if ($email !== null) { + $userModel->like('secret', $email); + } + + $this->write("Id\tUser"); + + foreach ($userModel->findAll() as $user) { + $this->write($user['id'] . "\t" . $user['username'] . ' (' . $user['email'] . ')'); + } + } + + /** + * Add a user by username or email to a group + * + * @param string|null $group Group to add user to + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function addgroup($group = null, $username = null, $email = null): void + { + if ($group === null) { + $group = $this->prompt('Group', null, 'required'); + } + + $user = $this->findUser('Add user to group', $username, $email); + + $confirm = $this->prompt( + 'Add the user "' . $user->username . '" to the group "' . $group . '" ?', + ['y', 'n'] + ); + + if ($confirm === 'y') { + $user->addGroup($group); + + $this->write('User "' . $user->username . '" added to group "' . $group . '"', 'green'); + } else { + $this->write( + 'Addition of the user "' . $user->username . '" to the group "' . $group . '" cancelled', + 'yellow' + ); + } + } + + /** + * Remove a user by username or email from a group + * + * @param string|null $group Group to remove user from + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function removegroup($group = null, $username = null, $email = null): void + { + if ($group === null) { + $group = $this->prompt('Group', null, 'required'); + } + + $user = $this->findUser('Remove user from group', $username, $email); + + $confirm = $this->prompt( + 'Remove the user "' . $user->username . '" from the group "' . $group . '" ?', + ['y', 'n'] + ); + + if ($confirm === 'y') { + $user->removeGroup($group); + + $this->write('User "' . $user->username . '" removed from group "' . $group . '"', 'green'); + } else { + $this->write('Removal of the user "' . $user->username . '" from the group "' . $group . '" cancelled', 'yellow'); + } + } + + /** + * Find an existing user by username or email. + * + * @param string $question Initial question at user prompt + * @param string|null $username User name to search for (optional) + * @param string|null $email User email to search for (optional) + */ + private function findUser($question = '', $username = null, $email = null): UserEntity + { + if ($username === null && $email === null) { + $choice = $this->prompt($question . ' by username or email ?', ['u', 'e']); + + if ($choice === 'u') { + $username = $this->prompt('Username', null, 'required'); + } elseif ($choice === 'e') { + $email = $this->prompt( + 'Email', + null, + 'required' + ); + } + } + + $userModel = model(UserModel::class); + $userModel + ->select($this->tables['users'] . '.id as id, username, secret') + ->join( + $this->tables['identities'], + $this->tables['users'] . '.id = ' . $this->tables['identities'] . '.user_id', + 'LEFT' + ) + ->groupStart() + ->where($this->tables['identities'] . '.type', Session::ID_TYPE_EMAIL_PASSWORD) + ->orGroupStart() + ->where($this->tables['identities'] . '.type', null) + ->groupEnd() + ->groupEnd() + ->asArray(); + + $user = null; + if ($username !== null) { + $user = $userModel->where('username', $username)->first(); + } elseif ($email !== null) { + $user = $userModel->where('secret', $email)->first(); + } + + $this->checkUserExists($user); + + return $userModel->findById($user['id']); + } +} diff --git a/src/Commands/Utils/InputOutput.php b/src/Commands/Utils/InputOutput.php new file mode 100644 index 000000000..5bbfb4474 --- /dev/null +++ b/src/Commands/Utils/InputOutput.php @@ -0,0 +1,43 @@ + '\CodeIgniter\Shield\Views\login', @@ -42,42 +50,12 @@ class Auth extends BaseConfig 'magic-link-email' => '\CodeIgniter\Shield\Views\Email\magic_link_email', ]; - /** - * -------------------------------------------------------------------- - * Customize Name of Shield Tables - * -------------------------------------------------------------------- - * Only change if you want to rename the default Shield table names - * - * It may be necessary to change the names of the tables for - * security reasons, to prevent the conflict of table names, - * the internal policy of the companies or any other reason. - * - * - users Auth Users Table, the users info is stored. - * - auth_identities Auth Identities Table, Used for storage of passwords, access tokens, social login identities, etc. - * - auth_logins Auth Login Attempts, Table records login attempts. - * - auth_token_logins Auth Token Login Attempts Table, Records Bearer Token type login attempts. - * - auth_remember_tokens Auth Remember Tokens (remember-me) Table. - * - auth_groups_users Groups Users Table. - * - auth_permissions_users Users Permissions Table. - * - * @var array - */ - public array $tables = [ - 'users' => 'users', - 'identities' => 'auth_identities', - 'logins' => 'auth_logins', - 'token_logins' => 'auth_token_logins', - 'remember_tokens' => 'auth_remember_tokens', - 'groups_users' => 'auth_groups_users', - 'permissions_users' => 'auth_permissions_users', - ]; - /** * -------------------------------------------------------------------- * Redirect URLs * -------------------------------------------------------------------- * The default URL that a user will be redirected to after various auth - * auth actions. This can be either of the following: + * actions. This can be either of the following: * * 1. An absolute URL. E.g. http://example.com OR https://example.com * 2. A named route that can be accessed using `route_to()` or `url_to()` @@ -127,30 +105,10 @@ class Auth extends BaseConfig public array $authenticators = [ 'tokens' => AccessTokens::class, 'session' => Session::class, + 'hmac' => HmacSha256::class, // 'jwt' => JWT::class, ]; - /** - * -------------------------------------------------------------------- - * Name of Authenticator Header - * -------------------------------------------------------------------- - * The name of Header that the Authorization token should be found. - * According to the specs, this should be `Authorization`, but rare - * circumstances might need a different header. - */ - public array $authenticatorHeader = [ - 'tokens' => 'Authorization', - ]; - - /** - * -------------------------------------------------------------------- - * Unused Token Lifetime - * -------------------------------------------------------------------- - * Determines the amount of time, in seconds, that an unused - * access token can be used. - */ - public int $unusedTokenLifetime = YEAR; - /** * -------------------------------------------------------------------- * Default Authenticator @@ -174,6 +132,7 @@ class Auth extends BaseConfig public array $authenticationChain = [ 'session', 'tokens', + 'hmac', // 'jwt', ]; @@ -190,7 +149,7 @@ class Auth extends BaseConfig * Record Last Active Date * -------------------------------------------------------------------- * If true, will always update the `last_active` datetime for the - * logged in user on every page request. + * logged-in user on every page request. * This feature only works when session/tokens filter is active. * * @see https://codeigniter4.github.io/shield/install/#protect-all-pages for set filters. @@ -239,6 +198,43 @@ class Auth extends BaseConfig 'rememberLength' => 30 * DAY, ]; + /** + * -------------------------------------------------------------------- + * The validation rules for username + * -------------------------------------------------------------------- + * + * Do not use string rules like `required|valid_email`. + * + * @var array|string> + */ + public array $usernameValidationRules = [ + 'label' => 'Auth.username', + 'rules' => [ + 'required', + 'max_length[30]', + 'min_length[3]', + 'regex_match[/\A[a-zA-Z0-9\.]+\z/]', + ], + ]; + + /** + * -------------------------------------------------------------------- + * The validation rules for email + * -------------------------------------------------------------------- + * + * Do not use string rules like `required|valid_email`. + * + * @var array|string> + */ + public array $emailValidationRules = [ + 'label' => 'Auth.email', + 'rules' => [ + 'required', + 'max_length[254]', + 'valid_email', + ], + ]; + /** * -------------------------------------------------------------------- * Minimum Password Length @@ -382,6 +378,44 @@ class Auth extends BaseConfig * OTHER SETTINGS * //////////////////////////////////////////////////////////////////// */ + + /** + * -------------------------------------------------------------------- + * Customize the DB group used for each model + * -------------------------------------------------------------------- + */ + public ?string $DBGroup = null; + + /** + * -------------------------------------------------------------------- + * Customize Name of Shield Tables + * -------------------------------------------------------------------- + * Only change if you want to rename the default Shield table names + * + * It may be necessary to change the names of the tables for + * security reasons, to prevent the conflict of table names, + * the internal policy of the companies or any other reason. + * + * - users Auth Users Table, the users info is stored. + * - auth_identities Auth Identities Table, Used for storage of passwords, access tokens, social login identities, etc. + * - auth_logins Auth Login Attempts, Table records login attempts. + * - auth_token_logins Auth Token Login Attempts Table, Records Bearer Token type login attempts. + * - auth_remember_tokens Auth Remember Tokens (remember-me) Table. + * - auth_groups_users Groups Users Table. + * - auth_permissions_users Users Permissions Table. + * + * @var array + */ + public array $tables = [ + 'users' => 'users', + 'identities' => 'auth_identities', + 'logins' => 'auth_logins', + 'token_logins' => 'auth_token_logins', + 'remember_tokens' => 'auth_remember_tokens', + 'groups_users' => 'auth_groups_users', + 'permissions_users' => 'auth_permissions_users', + ]; + /** * -------------------------------------------------------------------- * User Provider @@ -402,7 +436,8 @@ class Auth extends BaseConfig */ public function loginRedirect(): string { - $url = setting('Auth.redirects')['login']; + $session = session(); + $url = $session->getTempdata('beforeLoginUrl') ?? setting('Auth.redirects')['login']; return $this->getUrl($url); } diff --git a/src/Config/AuthSession.php b/src/Config/AuthSession.php deleted file mode 100644 index f9c4c28b3..000000000 --- a/src/Config/AuthSession.php +++ /dev/null @@ -1,36 +0,0 @@ - 'Authorization', + 'hmac' => 'Authorization', + ]; + + /** + * -------------------------------------------------------------------- + * Unused Token Lifetime + * -------------------------------------------------------------------- + * Determines the amount of time, in seconds, that an unused token can + * be used. + */ + public int $unusedTokenLifetime = YEAR; + + /** + * -------------------------------------------------------------------- + * HMAC secret key byte size + * -------------------------------------------------------------------- + * Specify in integer the desired byte size of the + * HMAC SHA256 byte size + */ + public int $hmacSecretKeyByteSize = 32; +} diff --git a/src/Config/Registrar.php b/src/Config/Registrar.php index 290b036d5..8c366e8cd 100644 --- a/src/Config/Registrar.php +++ b/src/Config/Registrar.php @@ -10,6 +10,7 @@ use CodeIgniter\Shield\Filters\ChainAuth; use CodeIgniter\Shield\Filters\ForcePasswordResetFilter; use CodeIgniter\Shield\Filters\GroupFilter; +use CodeIgniter\Shield\Filters\HmacAuth; use CodeIgniter\Shield\Filters\JWTAuth; use CodeIgniter\Shield\Filters\PermissionFilter; use CodeIgniter\Shield\Filters\SessionAuth; @@ -26,6 +27,7 @@ public static function Filters(): array 'aliases' => [ 'session' => SessionAuth::class, 'tokens' => TokenAuth::class, + 'hmac' => HmacAuth::class, 'chain' => ChainAuth::class, 'auth-rates' => AuthRates::class, 'group' => GroupFilter::class, diff --git a/src/Config/Services.php b/src/Config/Services.php index 1e002b6a0..42960ff7d 100644 --- a/src/Config/Services.php +++ b/src/Config/Services.php @@ -4,11 +4,11 @@ namespace CodeIgniter\Shield\Config; +use CodeIgniter\Config\BaseService; use CodeIgniter\Shield\Auth; use CodeIgniter\Shield\Authentication\Authentication; use CodeIgniter\Shield\Authentication\JWTManager; use CodeIgniter\Shield\Authentication\Passwords; -use Config\Services as BaseService; class Services extends BaseService { diff --git a/src/Controllers/LoginController.php b/src/Controllers/LoginController.php index 8c5bc445d..e38fa3817 100644 --- a/src/Controllers/LoginController.php +++ b/src/Controllers/LoginController.php @@ -7,8 +7,8 @@ use App\Controllers\BaseController; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\Shield\Authentication\Authenticators\Session; -use CodeIgniter\Shield\Authentication\Passwords; use CodeIgniter\Shield\Traits\Viewable; +use CodeIgniter\Shield\Validation\ValidationRules; class LoginController extends BaseController { @@ -47,11 +47,12 @@ public function loginAction(): RedirectResponse // like the password, can only be validated properly here. $rules = $this->getValidationRules(); - if (! $this->validateData($this->request->getPost(), $rules)) { + if (! $this->validateData($this->request->getPost(), $rules, [], config('Auth')->DBGroup)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } - $credentials = $this->request->getPost(setting('Auth.validFields')); + /** @var array $credentials */ + $credentials = $this->request->getPost(setting('Auth.validFields')) ?? []; $credentials = array_filter($credentials); $credentials['password'] = $this->request->getPost('password'); $remember = (bool) $this->request->getPost('remember'); @@ -81,23 +82,9 @@ public function loginAction(): RedirectResponse */ protected function getValidationRules(): array { - return setting('Validation.login') ?? [ - // 'username' => [ - // 'label' => 'Auth.username', - // 'rules' => config('AuthSession')->usernameValidationRules, - // ], - 'email' => [ - 'label' => 'Auth.email', - 'rules' => config('AuthSession')->emailValidationRules, - ], - 'password' => [ - 'label' => 'Auth.password', - 'rules' => 'required|' . Passwords::getMaxLenghtRule(), - 'errors' => [ - 'max_byte' => 'Auth.errorPasswordTooLongBytes', - ], - ], - ]; + $rules = new ValidationRules(); + + return $rules->getLoginRules(); } /** diff --git a/src/Controllers/MagicLinkController.php b/src/Controllers/MagicLinkController.php index 2f081a82c..25ab09d99 100644 --- a/src/Controllers/MagicLinkController.php +++ b/src/Controllers/MagicLinkController.php @@ -35,7 +35,10 @@ class MagicLinkController extends BaseController public function __construct() { helper('setting'); - $providerClass = setting('Auth.userProvider'); + + /** @var class-string $providerClass */ + $providerClass = setting('Auth.userProvider'); + $this->provider = new $providerClass(); } @@ -47,6 +50,10 @@ public function __construct() */ public function loginView() { + if (! setting('Auth.allowMagicLinkLogins')) { + return redirect()->route('login')->with('error', lang('Auth.magicLinkDisabled')); + } + if (auth()->loggedIn()) { return redirect()->to(config('Auth')->loginRedirect()); } @@ -63,9 +70,13 @@ public function loginView() */ public function loginAction() { + if (! setting('Auth.allowMagicLinkLogins')) { + return redirect()->route('login')->with('error', lang('Auth.magicLinkDisabled')); + } + // Validate email format $rules = $this->getValidationRules(); - if (! $this->validateData($this->request->getPost(), $rules)) { + if (! $this->validateData($this->request->getPost(), $rules, [], config('Auth')->DBGroup)) { return redirect()->route('magic-link')->with('errors', $this->validator->getErrors()); } @@ -102,6 +113,7 @@ public function loginAction() $date = Time::now()->toDateTimeString(); // Send the user an email with the code + helper('email'); $email = emailer()->setFrom(setting('Email.fromEmail'), setting('Email.fromName') ?? ''); $email->setTo($user->email); $email->setSubject(lang('Auth.magicLinkSubject')); @@ -132,6 +144,10 @@ protected function displayMessage(): string */ public function verify(): RedirectResponse { + if (! setting('Auth.allowMagicLinkLogins')) { + return redirect()->route('login')->with('error', lang('Auth.magicLinkDisabled')); + } + $token = $this->request->getGet('token'); /** @var UserIdentityModel $identityModel */ @@ -219,10 +235,7 @@ private function recordLoginAttempt( protected function getValidationRules(): array { return [ - 'email' => [ - 'label' => 'Auth.email', - 'rules' => config('AuthSession')->emailValidationRules, - ], + 'email' => config('Auth')->emailValidationRules, ]; } } diff --git a/src/Controllers/RegisterController.php b/src/Controllers/RegisterController.php index 6f394faa0..20daf5f73 100644 --- a/src/Controllers/RegisterController.php +++ b/src/Controllers/RegisterController.php @@ -10,12 +10,11 @@ use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Shield\Authentication\Authenticators\Session; -use CodeIgniter\Shield\Authentication\Passwords; -use CodeIgniter\Shield\Config\Auth; use CodeIgniter\Shield\Entities\User; use CodeIgniter\Shield\Exceptions\ValidationException; use CodeIgniter\Shield\Models\UserModel; use CodeIgniter\Shield\Traits\Viewable; +use CodeIgniter\Shield\Validation\ValidationRules; use Psr\Log\LoggerInterface; /** @@ -30,11 +29,6 @@ class RegisterController extends BaseController protected $helpers = ['setting']; - /** - * Auth Table names - */ - private array $tables; - public function initController( RequestInterface $request, ResponseInterface $response, @@ -45,10 +39,6 @@ public function initController( $response, $logger ); - - /** @var Auth $authConfig */ - $authConfig = config('Auth'); - $this->tables = $authConfig->tables; } /** @@ -100,7 +90,7 @@ public function registerAction(): RedirectResponse // like the password, can only be validated properly here. $rules = $this->getValidationRules(); - if (! $this->validateData($this->request->getPost(), $rules)) { + if (! $this->validateData($this->request->getPost(), $rules, [], config('Auth')->DBGroup)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } @@ -136,7 +126,7 @@ public function registerAction(): RedirectResponse // If an action has been defined for register, start it up. $hasAction = $authenticator->startUpAction('register', $user); if ($hasAction) { - return redirect()->to('auth/a/show'); + return redirect()->route('auth-action-show'); } // Set the user active @@ -177,35 +167,8 @@ protected function getUserEntity(): User */ protected function getValidationRules(): array { - $registrationUsernameRules = array_merge( - config('AuthSession')->usernameValidationRules, - [sprintf('is_unique[%s.username]', $this->tables['users'])] - ); - $registrationEmailRules = array_merge( - config('AuthSession')->emailValidationRules, - [sprintf('is_unique[%s.secret]', $this->tables['identities'])] - ); + $rules = new ValidationRules(); - return setting('Validation.registration') ?? [ - 'username' => [ - 'label' => 'Auth.username', - 'rules' => $registrationUsernameRules, - ], - 'email' => [ - 'label' => 'Auth.email', - 'rules' => $registrationEmailRules, - ], - 'password' => [ - 'label' => 'Auth.password', - 'rules' => 'required|' . Passwords::getMaxLenghtRule() . '|strong_password', - 'errors' => [ - 'max_byte' => 'Auth.errorPasswordTooLongBytes', - ], - ], - 'password_confirm' => [ - 'label' => 'Auth.passwordConfirm', - 'rules' => 'required|matches[password]', - ], - ]; + return $rules->getRegistrationRules(); } } diff --git a/src/Database/Migrations/2020-12-28-223112_create_auth_tables.php b/src/Database/Migrations/2020-12-28-223112_create_auth_tables.php index 80691cd9b..792cdad3a 100644 --- a/src/Database/Migrations/2020-12-28-223112_create_auth_tables.php +++ b/src/Database/Migrations/2020-12-28-223112_create_auth_tables.php @@ -15,13 +15,21 @@ class CreateAuthTables extends Migration */ private array $tables; + private array $attributes; + public function __construct(?Forge $forge = null) { + /** @var Auth $authConfig */ + $authConfig = config('Auth'); + + if ($authConfig->DBGroup !== null) { + $this->DBGroup = $authConfig->DBGroup; + } + parent::__construct($forge); - /** @var Auth $authConfig */ - $authConfig = config('Auth'); - $this->tables = $authConfig->tables; + $this->tables = $authConfig->tables; + $this->attributes = ($this->db->getPlatform() === 'MySQLi') ? ['ENGINE' => 'InnoDB'] : []; } public function up(): void @@ -40,7 +48,7 @@ public function up(): void ]); $this->forge->addPrimaryKey('id'); $this->forge->addUniqueKey('username'); - $this->forge->createTable($this->tables['users']); + $this->createTable($this->tables['users']); /* * Auth Identities Table @@ -64,7 +72,7 @@ public function up(): void $this->forge->addUniqueKey(['type', 'secret']); $this->forge->addKey('user_id'); $this->forge->addForeignKey('user_id', $this->tables['users'], 'id', '', 'CASCADE'); - $this->forge->createTable($this->tables['identities']); + $this->createTable($this->tables['identities']); /** * Auth Login Attempts Table @@ -85,7 +93,7 @@ public function up(): void $this->forge->addKey(['id_type', 'identifier']); $this->forge->addKey('user_id'); // NOTE: Do NOT delete the user_id or identifier when the user is deleted for security audits - $this->forge->createTable($this->tables['logins']); + $this->createTable($this->tables['logins']); /* * Auth Token Login Attempts Table @@ -105,7 +113,7 @@ public function up(): void $this->forge->addKey(['id_type', 'identifier']); $this->forge->addKey('user_id'); // NOTE: Do NOT delete the user_id or identifier when the user is deleted for security audits - $this->forge->createTable($this->tables['token_logins']); + $this->createTable($this->tables['token_logins']); /* * Auth Remember Tokens (remember-me) Table @@ -123,7 +131,7 @@ public function up(): void $this->forge->addPrimaryKey('id'); $this->forge->addUniqueKey('selector'); $this->forge->addForeignKey('user_id', $this->tables['users'], 'id', '', 'CASCADE'); - $this->forge->createTable($this->tables['remember_tokens']); + $this->createTable($this->tables['remember_tokens']); // Groups Users Table $this->forge->addField([ @@ -134,7 +142,7 @@ public function up(): void ]); $this->forge->addPrimaryKey('id'); $this->forge->addForeignKey('user_id', $this->tables['users'], 'id', '', 'CASCADE'); - $this->forge->createTable($this->tables['groups_users']); + $this->createTable($this->tables['groups_users']); // Users Permissions Table $this->forge->addField([ @@ -145,7 +153,7 @@ public function up(): void ]); $this->forge->addPrimaryKey('id'); $this->forge->addForeignKey('user_id', $this->tables['users'], 'id', '', 'CASCADE'); - $this->forge->createTable($this->tables['permissions_users']); + $this->createTable($this->tables['permissions_users']); } // -------------------------------------------------------------------- @@ -164,4 +172,9 @@ public function down(): void $this->db->enableForeignKeyChecks(); } + + private function createTable(string $tableName): void + { + $this->forge->createTable($tableName, false, $this->attributes); + } } diff --git a/src/Entities/User.php b/src/Entities/User.php index 00a420fe7..1aff561ab 100644 --- a/src/Entities/User.php +++ b/src/Entities/User.php @@ -8,6 +8,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\Authenticators\Session; use CodeIgniter\Shield\Authentication\Traits\HasAccessTokens; +use CodeIgniter\Shield\Authentication\Traits\HasHmacTokens; use CodeIgniter\Shield\Authorization\Traits\Authorizable; use CodeIgniter\Shield\Models\LoginModel; use CodeIgniter\Shield\Models\UserIdentityModel; @@ -28,6 +29,7 @@ class User extends Entity { use Authorizable; use HasAccessTokens; + use HasHmacTokens; use Resettable; use Activatable; use Bannable; @@ -117,6 +119,11 @@ public function getIdentities(string $type = 'all'): array return $identities; } + public function setIdentities(array $identities): void + { + $this->identities = $identities; + } + /** * Creates a new identity for this user with an email/password * combination. diff --git a/src/Exceptions/UserNotFoundException.php b/src/Exceptions/UserNotFoundException.php new file mode 100644 index 000000000..485f5c04e --- /dev/null +++ b/src/Exceptions/UserNotFoundException.php @@ -0,0 +1,9 @@ +loggedIn()) { + // Set the entrance url to redirect a user after successful login + if (! url_is('login')) { + $session = session(); + $session->setTempdata('beforeLoginUrl', current_url(), 300); + } + return redirect()->route('login'); } diff --git a/src/Filters/HmacAuth.php b/src/Filters/HmacAuth.php new file mode 100644 index 000000000..f655d0264 --- /dev/null +++ b/src/Filters/HmacAuth.php @@ -0,0 +1,64 @@ +getAuthenticator(); + + helper('setting'); + + $requestParams = [ + 'token' => $request->getHeaderLine(setting('Auth.authenticatorHeader')['hmac'] ?? 'Authorization'), + 'body' => $request->getBody() ?? '', + ]; + + $result = $authenticator->attempt($requestParams); + + if (! $result->isOK() || ($arguments !== null && $arguments !== [] && $result->extraInfo()->hmacTokenCant($arguments[0]))) { + return service('response') + ->setStatusCode(Response::HTTP_UNAUTHORIZED) + ->setJSON(['message' => lang('Auth.badToken')]); + } + + if (setting('Auth.recordActiveDate')) { + $authenticator->recordActiveDate(); + } + + // Block inactive users when Email Activation is enabled + $user = $authenticator->getUser(); + if ($user !== null && ! $user->isActivated()) { + $authenticator->logout(); + + return service('response') + ->setStatusCode(Response::HTTP_FORBIDDEN) + ->setJSON(['message' => lang('Auth.activationBlocked')]); + } + + return $request; + } + + /** + * {@inheritDoc} + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void + { + } +} diff --git a/src/Filters/JWTAuth.php b/src/Filters/JWTAuth.php index a0a6c2a7d..84914511b 100644 --- a/src/Filters/JWTAuth.php +++ b/src/Filters/JWTAuth.php @@ -59,7 +59,8 @@ private function getTokenFromHeader(RequestInterface $request): string { assert($request instanceof IncomingRequest); - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $tokenHeader = $request->getHeaderLine( $config->authenticatorHeader ?? 'Authorization' diff --git a/src/Filters/SessionAuth.php b/src/Filters/SessionAuth.php index d95f89c07..b7d449666 100644 --- a/src/Filters/SessionAuth.php +++ b/src/Filters/SessionAuth.php @@ -61,10 +61,12 @@ public function before(RequestInterface $request, $arguments = null) } if ($user !== null && ! $user->isActivated()) { - $authenticator->logout(); - - return redirect()->route('login') - ->with('error', lang('Auth.activationBlocked')); + // If an action has been defined for register, start it up. + $hasAction = $authenticator->startUpAction('register', $user); + if ($hasAction) { + return redirect()->route('auth-action-show') + ->with('error', lang('Auth.activationBlocked')); + } } return; @@ -75,6 +77,11 @@ public function before(RequestInterface $request, $arguments = null) ->with('error', $authenticator->getPendingMessage()); } + if (! url_is('login')) { + $session = session(); + $session->setTempdata('beforeLoginUrl', current_url(), 300); + } + return redirect()->route('login'); } diff --git a/src/Filters/TokenAuth.php b/src/Filters/TokenAuth.php index ee504cbd5..4425c14ff 100644 --- a/src/Filters/TokenAuth.php +++ b/src/Filters/TokenAuth.php @@ -21,7 +21,7 @@ class TokenAuth implements FilterInterface { /** * Do whatever processing this filter needs to do. - * By default it should not return anything during + * By default, it should not return anything during * normal execution. However, when an abnormal state * is found, it should return an instance of * CodeIgniter\HTTP\Response. If it does, script diff --git a/src/Language/ar/Auth.php b/src/Language/ar/Auth.php new file mode 100644 index 000000000..3f92deec0 --- /dev/null +++ b/src/Language/ar/Auth.php @@ -0,0 +1,108 @@ + '{0} ليس توثيق صحيح.', + 'unknownUserProvider' => 'تعذر تحديد موفر المستخدم الذي يجب استخدامه.', + 'invalidUser' => 'تعذر تحديد المستخدم المدخل.', + 'bannedUser' => 'لا يمكن تسجيل الدخول حيث أن حسابك موقوف حالياً.', + 'logOutBannedUser' => 'لقد تم تسجيل خروجك وذلك لانه تم حظرك.', + 'badAttempt' => 'لا يمكن تسجيل دخولك. يُرجى التحقق من صحة البيانات الخاصة بك.', + 'noPassword' => 'لا يمكن التحقق من هوية المستخدم بدون كلمة مرور.', + 'invalidPassword' => 'تعذر تسجيل الدخول. يرجى التحقق من كلمة المرور الخاصة بك.', + 'noToken' => 'يجب أن يحتوي كل طلب على رمز حامل (token) في الهيدر {0}.', + 'badToken' => 'رمز الوصول (Token) غير صالح.', + 'oldToken' => 'انتهت صلاحية رمز الوصول.', + 'noUserEntity' => 'يجب توفير كيان المستخدم للتحقق من صحة كلمة المرور.', + 'invalidEmail' => 'تعذر التحقق من تطابق عنوان البريد الإلكتروني مع البريد الإلكتروني المسجل.', + 'unableSendEmailToUser' => 'عذرا ، كانت هناك مشكلة في إرسال البريد الإلكتروني. لم نتمكن من إرسال بريد إلكتروني إلى "{0}".', + 'throttled' => 'تم إجراء العديد من الطلبات من عنوان IP هذا. يمكنك المحاولة مرة أخرى في غضون {0} ثانية.', + 'notEnoughPrivilege' => 'ليس لديك الإذن اللازم لإجراء العملية المطلوبة.', + // JWT Exceptions + 'invalidJWT' => 'الرمز غير صالح.', + 'expiredJWT' => 'انتهت صلاحية الرمز.', + 'beforeValidJWT' => 'الرمز غير متوفر بعد.', + + 'email' => 'عنوان البريد الالكتروني', + 'username' => 'اسم المستخدم', + 'password' => 'كلمة المرور', + 'passwordConfirm' => 'كلمة المرور (مرة اخرى)', + 'haveAccount' => 'هل لديك حساب بالفعل؟', + 'token' => 'رمز الوصول', + + // Buttons + 'confirm' => 'تاكيد', + 'send' => 'ارسال', + + // Registration + 'register' => 'تسجيل حساب', + 'registerDisabled' => 'تسجيل حساب جديد غير مسموح الان.', + 'registerSuccess' => 'أهلا بك!', + + // Login + 'login' => 'تسجيل دخول', + 'needAccount' => 'هل تحتاج الى حساب؟', + 'rememberMe' => 'تذكر دخولي؟', + 'forgotPassword' => 'نسيت كلمة المرور؟', + 'useMagicLink' => 'تسجيل دخول بواسطة رابط دخول', + 'magicLinkSubject' => 'رابط الدخول الخاص بك', + 'magicTokenNotFound' => 'تعذر التحقق من صحة الرابط.', + 'magicLinkExpired' => 'عذرا ، لقد انتهت صلاحية الرابط.', + 'checkYourEmail' => 'تحقق من بريدك الالكتروني!', + 'magicLinkDetails' => 'لقد أرسلنا لك بريدًا إلكترونيًا يحتوي على رابط تسجيل الدخول بالداخل. الرابط صالح فقط لمدة {0} دقيقة.', + 'magicLinkDisabled' => 'استخدام الرابط للدخول MagicLink غير مسموح به حاليًا.', + 'successLogout' => 'لقد قمت بتسجيل الخروج بنجاح.', + 'backToLogin' => 'العودة إلى نموذج تسجيل الدخول', + + // Passwords + 'errorPasswordLength' => 'يجب أن تتكون كلمات المرور من {0, number} من الأحرف على الأقل.', + 'suggestPasswordLength' => 'عبارات المرور - التي يصل طولها إلى 255 حرفًا - تجعل كلمات المرور أكثر أمانًا ويسهل تذكرها.', + 'errorPasswordCommon' => 'يجب ألا تكون كلمة المرور كلمة مرور شائعة.', + 'suggestPasswordCommon' => 'تم فحص كلمة المرور مقابل أكثر من 65 ألف كلمة مرور أو كلمات مرور شائعة الاستخدام تم تسريبها من خلال الاختراقات.', + 'errorPasswordPersonal' => 'لا يمكن أن تحتوي كلمات المرور على معلومات شخصية تم إعادة تجزئتها (re-hashed).', + 'suggestPasswordPersonal' => 'لا يجب اجزاء من عنوان بريدك الإلكتروني أو اسم المستخدم ككلمات مرور.', + 'errorPasswordTooSimilar' => 'كلمة المرور مشابهة جدًا لاسم المستخدم.', + 'suggestPasswordTooSimilar' => 'لا تستخدم أجزاء من اسم المستخدم الخاص بك في كلمة المرور الخاصة بك.', + 'errorPasswordPwned' => 'تم الكشف عن كلمة المرور {0} بسبب اختراق البيانات وشوهدت {1, number} مرة في {2} في كلمات المرور المخترقة.', + 'suggestPasswordPwned' => 'يجب عدم استخدام {0} أبدًا ككلمة مرور. إذا كنت تستخدمها في أي مكان ، فقم بتغييرها على الفور.', + 'errorPasswordEmpty' => 'كلمة مرور مطلوبة', + 'errorPasswordTooLongBytes' => 'لا يمكن أن يتجاوز طول كلمة المرور {param} بايت.', + 'passwordChangeSuccess' => 'تم تغيير كلمة المرور بنجاح', + 'userDoesNotExist' => 'لم يتم تغيير كلمة المرور. المستخدم غير موجود', + 'resetTokenExpired' => 'آسف. انتهت صلاحية رمز إعادة التعيين الخاص بك.', + + // Email Globals + 'emailInfo' => 'بعض المعلومات عن الشخص:', + 'emailIpAddress' => 'عنوان IP:', + 'emailDevice' => 'الجهاز:', + 'emailDate' => 'التاريخ:', + + // 2FA + 'email2FATitle' => 'التحقق بخطوتين', + 'confirmEmailAddress' => 'أكد عنوان بريدك الألكتروني.', + 'emailEnterCode' => 'تأكيد بريدك الإلكتروني', + 'emailConfirmCode' => 'أدخل الرمز المكون من 6 أرقام الذي أرسلناه للتو إلى عنوان بريدك الإلكتروني.', + 'email2FASubject' => 'رمز المصادقة الخاص بك', + 'email2FAMailBody' => 'رمز المصادقة الخاص بك هو:', + 'invalid2FAToken' => 'رمز المصادقة غير صحيح.', + 'need2FA' => 'يجب عليك إكمال التحقق بخطوتين.', + 'needVerification' => 'تحقق من بريدك الإلكتروني لإكمال تنشيط الحساب.', + + // Activate + 'emailActivateTitle' => 'تفعيل البريد الإلكتروني', + 'emailActivateBody' => 'لقد أرسلنا إليك بريدًا إلكترونيًا يحتوي على رمز لتأكيد عنوان بريدك الإلكتروني. انسخ هذا الرمز والصقه أدناه.', + 'emailActivateSubject' => 'رمز التفعيل الخاص بك', + 'emailActivateMailBody' => 'يرجى استخدام الكود أدناه لتفعيل حسابك والبدء في استخدام الموقع.', + 'invalidActivateToken' => 'الرمز غير صحيح', + 'needActivate' => 'يجب عليك إكمال تسجيل حسابك عن طريق تأكيد الرمز المرسل إلى عنوان بريدك الإلكتروني.', + 'activationBlocked' => 'يجب عليك تفعيل حسابك قبل تسجيل الدخول.', + + // Groups + 'unknownGroup' => '{0} ليست مجموعة صالحة.', + 'missingTitle' => 'يجب أن يكون للمجموعات عنوان.', + + // Permissions + 'unknownPermission' => '{0} ليس صلاحية صحيحة.', +]; diff --git a/src/Language/bg/Auth.php b/src/Language/bg/Auth.php new file mode 100644 index 000000000..b4bee29dd --- /dev/null +++ b/src/Language/bg/Auth.php @@ -0,0 +1,108 @@ + '{0} не е валиден аутентикатор.', + 'unknownUserProvider' => 'Не може да се определи използваният потребителски доставчик.', + 'invalidUser' => 'Не може да се намери посоченият потребител.', + 'bannedUser' => 'Не може да влезете в профила си, тъй като сте баннати.', + 'logOutBannedUser' => 'Изведен сте от профила ви, защото сте баннати.', + 'badAttempt' => 'Не може да влезете в профила си. Моля, проверете вашите потребителски данни.', + 'noPassword' => 'Не може да се потвърди потребителски профил без парола.', + 'invalidPassword' => 'Не може да влезете в профила си. Моля, проверете вашата парола.', + 'noToken' => 'Всяка заявка трябва да съдържа носител на токен в {0} заглавната си част.', + 'badToken' => 'Токенът за достъп не е валиден.', + 'oldToken' => 'Токенът за достъп е изтекъл.', + 'noUserEntity' => 'Потребителското съдържание трябва да бъде предоставено за потвърждение на паролата.', + 'invalidEmail' => 'Не може да се потвърди, че имейл адресът съвпада с имейл адреса от записа.', + 'unableSendEmailToUser' => 'Съжаляваме, имаше проблем с изпращането на имейла. Не можем да изпратим имейл до "{0}".', + 'throttled' => 'Твърде много заявки са направени от този IP адрес. Може да опитате отново след {0} секунди.', + 'notEnoughPrivilege' => 'Нямате необходимите права за изпълнение на желаната операция.', + // JWT Изключения + 'invalidJWT' => 'Токенът е невалиден.', + 'expiredJWT' => 'Токенът е изтекъл.', + 'beforeValidJWT' => 'Токенът все още не е наличен.', + + 'email' => 'Адрес на електронна поща', + 'username' => 'Потребителско име', + 'password' => 'Парола', + 'passwordConfirm' => 'Парола (отново)', + 'haveAccount' => 'Вече имате акаунт?', + 'token' => '(To be translated) Token', + + // Бутони + 'confirm' => 'Потвърди', + 'send' => 'Изпрати', + + // Регистрация + 'register' => 'Регистрация', + 'registerDisabled' => 'Регистрацията в момента не е позволена.', + 'registerSuccess' => 'Добре дошли!', + + // Вход + 'login' => 'Вход', + 'needAccount' => 'Нуждаете се от акаунт?', + 'rememberMe' => 'Запомни ме?', + 'forgotPassword' => 'Забравена парола?', + 'useMagicLink' => 'Използвайте линк за вход', + 'magicLinkSubject' => 'Вашият линк за вход', + 'magicTokenNotFound' => 'Не може да се потвърди линка.', + 'magicLinkExpired' => 'Съжаляваме, линкът е изтекъл.', + 'checkYourEmail' => 'Проверете вашия имейл!', + 'magicLinkDetails' => 'Току що ви изпратихме имейл с линк за вход. Линкът ще бъде валиден само {0} минути.', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', + 'successLogout' => 'Успешно излязохте от системата.', + 'backToLogin' => 'Обратно към входа', + + // Пароли + 'errorPasswordLength' => 'Паролите трябва да са поне {0, number} символа дълги.', + 'suggestPasswordLength' => 'Паролите с дължина до 255 символа, наричани "паролни изречения", правят паролите по-сигурни и лесни за запомняне.', + 'errorPasswordCommon' => 'Паролата не трябва да е общоизвестна.', + 'suggestPasswordCommon' => 'Проверихме паролата срещу над 65 000 общоизвестни пароли или пароли, които са били изложени след хакерски атаки.', + 'errorPasswordPersonal' => 'Паролите не могат да съдържат лична информация.', + 'suggestPasswordPersonal' => 'Вариации на имейл адреса или потребителското име не трябва да се използват за пароли.', + 'errorPasswordTooSimilar' => 'Паролата е твърде подобна на потребителското име.', + 'suggestPasswordTooSimilar' => 'Не използвайте части от потребителското си име в паролата си.', + 'errorPasswordPwned' => 'Паролата {0} е била компрометирана в следствие на нарушения в сигурността на данните и е била видяна {1, number} пъти в {2} от компрометираните пароли.', + 'suggestPasswordPwned' => '{0} никога не трябва да се използва като парола. Ако я използвате някъде, трябва да я сменете веднага.', + 'errorPasswordEmpty' => 'Изисква се парола.', + 'errorPasswordTooLongBytes' => 'Паролата не може да бъде по-дълга от {param} байта.', + 'passwordChangeSuccess' => 'Паролата беше успешно променена.', + 'userDoesNotExist' => 'Паролата не беше променена. Потребителят не съществува.', + 'resetTokenExpired' => 'Съжаляваме. Вашият токен за нулиране на паролата е изтекъл.', + + // Глобални променливи за електронна поща + 'emailInfo' => 'Някаква информации за потребителя:', + 'emailIpAddress' => 'IP Адрес:', + 'emailDevice' => 'Устройство:', + 'emailDate' => 'Дата:', + + // Двуфакторна автентикация (2FA) + 'email2FATitle' => 'Двуфакторна автентикация', + 'confirmEmailAddress' => 'Потвърдете Вашата електронна поща.', + 'emailEnterCode' => 'Потвърдете Вашата електронна поща', + 'emailConfirmCode' => 'Въведете 6-цифрен код, който изпратихме на Вашата електронна поща.', + 'email2FASubject' => 'Вашият код за автентикация', + 'email2FAMailBody' => 'Вашият код за автентикация е:', + 'invalid2FAToken' => 'Грешен код.', + 'need2FA' => 'Трябва да завършите двуфакторна верификация.', + 'needVerification' => 'Проверете Вашата електронна поща, за да завършите активацията на профила.', + + // Активация + 'emailActivateTitle' => 'Активиране по имейл', + 'emailActivateBody' => 'Изпратихме ви имейл с код за потвърждение на вашия имейл адрес. Копирайте този код и го поставете по-долу.', + 'emailActivateSubject' => 'Вашият код за активация', + 'emailActivateMailBody' => 'Моля, използвайте по-долу посочения код за активиране на акаунта си и започнете да използвате сайта.', + 'invalidActivateToken' => 'Кода е невалиден.', + 'needActivate' => 'Трябва да завършите регистрацията си, като потвърдите кода, изпратен на вашия имейл адрес.', + 'activationBlocked' => 'Трябва да активирате акаунта си, преди да влезете.', + + // Групи + 'unknownGroup' => '{0} не е валидна група.', + 'missingTitle' => 'Групите трябва да имат заглавие.', + + // Разрешения + 'unknownPermission' => '{0} не е валидно разрешение.', +]; diff --git a/src/Language/de/Auth.php b/src/Language/de/Auth.php index a681516bd..53b67542a 100644 --- a/src/Language/de/Auth.php +++ b/src/Language/de/Auth.php @@ -30,6 +30,7 @@ 'password' => 'Passwort', 'passwordConfirm' => 'Passwort (erneut)', 'haveAccount' => 'Haben Sie bereits ein Konto?', + 'token' => '(To be translated) Token', // Buttons 'confirm' => 'Bestätigen', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'Sorry, der Link ist abgelaufen.', 'checkYourEmail' => 'Prüfen Sie Ihre E-Mail!', 'magicLinkDetails' => 'Wir haben Ihnen gerade eine E-Mail mit einem Login-Link geschickt. Er ist nur für {0} Minuten gültig.', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', 'successLogout' => 'Sie haben sich erfolgreich abgemeldet.', + 'backToLogin' => 'Zurück zur Anmeldung', // Passwords 'errorPasswordLength' => 'Passwörter müssen mindestens {0, number} Zeichen lang sein.', diff --git a/src/Language/en/Auth.php b/src/Language/en/Auth.php index 363fd4af7..1dfdfe7b1 100644 --- a/src/Language/en/Auth.php +++ b/src/Language/en/Auth.php @@ -30,6 +30,7 @@ 'password' => 'Password', 'passwordConfirm' => 'Password (again)', 'haveAccount' => 'Already have an account?', + 'token' => 'Token', // Buttons 'confirm' => 'Confirm', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'Sorry, link has expired.', 'checkYourEmail' => 'Check your email!', 'magicLinkDetails' => 'We just sent you an email with a Login link inside. It is only valid for {0} minutes.', + 'magicLinkDisabled' => 'Use of MagicLink is currently not allowed.', 'successLogout' => 'You have successfully logged out.', + 'backToLogin' => 'Back to Login', // Passwords 'errorPasswordLength' => 'Passwords must be at least {0, number} characters long.', diff --git a/src/Language/es/Auth.php b/src/Language/es/Auth.php index 2cf2c6211..5fdd2ae0c 100644 --- a/src/Language/es/Auth.php +++ b/src/Language/es/Auth.php @@ -30,6 +30,7 @@ 'password' => 'Contraseña', 'passwordConfirm' => 'Contraseña (otra vez)', 'haveAccount' => '¿Ya tienes una cuenta?', + 'token' => '(To be translated) Token', // Botones 'confirm' => 'Confirmar', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'Lo siento, el enlace ha caducado.', 'checkYourEmail' => '¡Revisa tu correo electrónico!', 'magicLinkDetails' => 'Acabamos de enviarte un correo electrónico con un enlace de inicio de sesión. Solo es válido durante {0} minutos.', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', 'successLogout' => 'Has cerrado sesión correctamente.', + 'backToLogin' => 'Volver al inicio de sesión', // Contraseñas 'errorPasswordLength' => 'Las contraseñas deben tener al menos {0, number} caracteres.', diff --git a/src/Language/fa/Auth.php b/src/Language/fa/Auth.php index 26d242525..c3bf118f0 100644 --- a/src/Language/fa/Auth.php +++ b/src/Language/fa/Auth.php @@ -30,6 +30,7 @@ 'password' => 'رمز عبور', 'passwordConfirm' => 'رمز عبور (تکرار)', 'haveAccount' => 'از قبل حساب کاربری دارید؟', + 'token' => 'توکن', // Buttons 'confirm' => 'تایید', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'متاسفانه, لینک منقضی شده است.', 'checkYourEmail' => 'ایمیلتان را بررسی کنید!', 'magicLinkDetails' => 'ما فقط یک لینک ورود به ایمیلتان ارسال کردیم. این لینک فقط برای {0} دقیقه معتبر خواهد بود.', + 'magicLinkDisabled' => 'امکان استفاده از لینک جادویی وجود ندارد.', 'successLogout' => 'با موفقیت خارج شدید.', + 'backToLogin' => 'بازگشت به ورود به سیستم', // Passwords 'errorPasswordLength' => 'طول رمز های عبور باید حداقل {0, number} کاراکتر باشد.', diff --git a/src/Language/fr/Auth.php b/src/Language/fr/Auth.php index b43a354b0..fb167b954 100644 --- a/src/Language/fr/Auth.php +++ b/src/Language/fr/Auth.php @@ -30,6 +30,7 @@ 'password' => 'Mot de passe', 'passwordConfirm' => 'Mot de passe (répéter)', 'haveAccount' => 'Vous avez déjà un compte ?', + 'token' => '(To be translated) Token', // Buttons 'confirm' => 'Confirmer', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'Désolé, le lien a expiré.', 'checkYourEmail' => 'Vérifier votre email !', 'magicLinkDetails' => 'Nous venons de vous envoyer un email contenant un lien de connexion. Il n\'est valable que {0} minutes.', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', 'successLogout' => 'Vous avez été déconnecté avec succès.', + 'backToLogin' => 'Retour à la connexion', // Passwords 'errorPasswordLength' => 'Le mot de passe doit contenir au moins {0, number} caractères.', diff --git a/src/Language/id/Auth.php b/src/Language/id/Auth.php index f2be28a35..0c5e1533c 100644 --- a/src/Language/id/Auth.php +++ b/src/Language/id/Auth.php @@ -7,8 +7,8 @@ 'unknownAuthenticator' => '{0} bukan otentikator yang sah.', 'unknownUserProvider' => 'Tidak dapat menentukan Penyedia Pengguna yang akan digunakan.', 'invalidUser' => 'Tidak dapat menemukan pengguna yang spesifik.', - 'bannedUser' => '(To be translated) Can not log you in as you are currently banned.', - 'logOutBannedUser' => '(To be translated) You have been logged out because you have been banned.', + 'bannedUser' => 'Anda tidak dapat masuk karena saat ini Anda diblokir.', + 'logOutBannedUser' => 'Anda telah keluar karena Anda telah diblokir.', 'badAttempt' => 'Anda tidak dapat masuk. Harap periksa kredensial Anda.', 'noPassword' => 'Tidak dapat memvalidasi pengguna tanpa kata sandi.', 'invalidPassword' => 'Anda tidak dapat masuk. Harap periksa kata sandi Anda.', @@ -21,15 +21,16 @@ 'throttled' => 'Terlalu banyak permintaan yang dibuat dari alamat IP ini. Anda dapat mencoba lagi dalam {0} detik.', 'notEnoughPrivilege' => 'Anda tidak memiliki izin yang diperlukan untuk melakukan operasi yang diinginkan.', // JWT Exceptions - 'invalidJWT' => '(To be translated) The token is invalid.', - 'expiredJWT' => '(To be translated) The token has expired.', - 'beforeValidJWT' => '(To be translated) The token is not yet available.', + 'invalidJWT' => 'Token tidak valid.', + 'expiredJWT' => 'Token telah kedaluwarsa.', + 'beforeValidJWT' => 'Token belum tersedia.', 'email' => 'Alamat Email', 'username' => 'Nama Pengguna', 'password' => 'Kata Sandi', 'passwordConfirm' => 'Kata Sandi (lagi)', 'haveAccount' => 'Sudah punya akun?', + 'token' => '(To be translated) Token', // Buttons 'confirm' => 'Konfirmasi', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'Maaf, tautan sudah tidak berlaku.', 'checkYourEmail' => 'Periksa email Anda!', 'magicLinkDetails' => 'Kami baru saja mengirimi Anda email dengan tautan Masuk di dalamnya. Ini hanya berlaku selama {0} menit.', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', 'successLogout' => 'Anda telah berhasil keluar.', + 'backToLogin' => 'Kembali ke masuk', // Passwords 'errorPasswordLength' => 'Kata sandi harus setidaknya terdiri dari {0, number} karakter.', @@ -65,7 +68,7 @@ 'errorPasswordPwned' => 'Kata sandi {0} telah bocor karena pelanggaran data dan telah dilihat {1, number} kali dalam {2} sandi yang disusupi.', 'suggestPasswordPwned' => '{0} tidak boleh digunakan sebagai kata sandi. Jika Anda menggunakannya di mana saja, segera ubah.', 'errorPasswordEmpty' => 'Kata sandi wajib diisi.', - 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', + 'errorPasswordTooLongBytes' => 'Panjang kata sandi tidak boleh lebih dari {param} byte.', 'passwordChangeSuccess' => 'Kata sandi berhasil diubah', 'userDoesNotExist' => 'Kata sandi tidak diubah. User tidak ditemukan', 'resetTokenExpired' => 'Maaf, token setel ulang Anda sudah habis waktu.', @@ -94,7 +97,7 @@ 'emailActivateMailBody' => 'Silahkan gunakan kode dibawah ini untuk mengaktivasi akun Anda.', 'invalidActivateToken' => 'Kode tidak sesuai.', 'needActivate' => 'Anda harus menyelesaikan registrasi Anda dengan mengonfirmasi kode yang dikirim ke alamat email Anda.', - 'activationBlocked' => '(to be translated) You must activate your account before logging in.', + 'activationBlocked' => 'Anda harus mengaktifkan akun Anda sebelum masuk.', // Groups 'unknownGroup' => '{0} bukan grup yang sah.', diff --git a/src/Language/it/Auth.php b/src/Language/it/Auth.php index af2b41e24..0b4e845ac 100644 --- a/src/Language/it/Auth.php +++ b/src/Language/it/Auth.php @@ -30,6 +30,7 @@ 'password' => 'Password', 'passwordConfirm' => 'Password (ancora)', 'haveAccount' => 'Hai già un account?', + 'token' => '(To be translated) Token', // Buttons 'confirm' => 'Conferma', @@ -45,13 +46,15 @@ 'needAccount' => 'Hai bisogno di un account?', 'rememberMe' => 'Ricordami?', 'forgotPassword' => 'Password dimenticata?', - 'useMagicLink' => 'Usa un Login Link', + 'useMagicLink' => 'Usa un Login Link', 'magicLinkSubject' => 'Il tuo Login Link', 'magicTokenNotFound' => 'Impossibile verificare il link.', 'magicLinkExpired' => 'Spiacente, il link è scaduto.', 'checkYourEmail' => 'Controlla la tua email!', 'magicLinkDetails' => 'Ti abbiamo appena inviato una mail contenente un Login link. È valido solo per {0} minuti.', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', 'successLogout' => 'Hai effettuato il logout con successo.', + 'backToLogin' => 'Torna al login', // Passwords 'errorPasswordLength' => 'Le password devono essere lunghe almeno {0, number} ccaratteri.', diff --git a/src/Language/ja/Auth.php b/src/Language/ja/Auth.php index 1ec2f346b..7823e88cb 100644 --- a/src/Language/ja/Auth.php +++ b/src/Language/ja/Auth.php @@ -30,6 +30,7 @@ 'password' => 'パスワード', // 'Password' 'passwordConfirm' => 'パスワード(再)', // 'Password (again)' 'haveAccount' => 'すでにアカウントをお持ちの方', // 'Already have an account?' + 'token' => 'トークン', // 'Token' // Buttons 'confirm' => '確認する', // 'Confirm' @@ -51,7 +52,9 @@ 'magicLinkExpired' => '申し訳ございません、リンクは切れています。', // 'Sorry, link has expired.' 'checkYourEmail' => 'メールをチェックしてください!', // 'Check your email!' 'magicLinkDetails' => 'ログインリンクが含まれたメールを送信しました。これは {0} 分間だけ有効です。', // 'We just sent you an email with a Login link inside. It is only valid for {0} minutes.' + 'magicLinkDisabled' => 'マジックリンクは使えません。', // 'Use of MagicLink is currently not allowed.' 'successLogout' => '正常にログアウトしました。', // 'You have successfully logged out.' + 'backToLogin' => 'ログインに戻る', // 'Back to Login' // Passwords 'errorPasswordLength' => 'パスワードは最低でも {0, number} 文字でなければなりません。', // 'Passwords must be at least {0, number} characters long.' diff --git a/src/Language/lt/Auth.php b/src/Language/lt/Auth.php new file mode 100644 index 000000000..035d80070 --- /dev/null +++ b/src/Language/lt/Auth.php @@ -0,0 +1,108 @@ + '{0} nėra teisingas autentifikatorius.', + 'unknownUserProvider' => 'Nepavyksta nustatyti, kokį reikėtų naudoti vartotojų šaltinį.', + 'invalidUser' => 'Nepavyksta rasti nurodyto vartotojo.', + 'bannedUser' => 'Jūsų vartotojas uždraustas, todėl prisijungti nepavyks.', + 'logOutBannedUser' => 'Sistema jus išregistravo, nes Jūsų vartotojas uždraustas.', + 'badAttempt' => 'Nepavyksta Jūsų prijungti. Patikrinkite prisijungimo duomenis.', + 'noPassword' => 'Negalima patvirtinti vartotojo be slaptažodžio.', + 'invalidPassword' => 'Nepavyksta Jūsų prijungti. Patikrinkite slaptažodį.', + 'noToken' => 'Kiekviena užklausa turi turėti prieigos raštą antraštėje {0}.', + 'badToken' => 'Prieigos raktas neteisingas.', + 'oldToken' => 'Prieigos raktas nebegalioja.', + 'noUserEntity' => 'Slaptažodžio patikrinimui turi būti pateiktas vartotojo subjektas.', + 'invalidEmail' => 'Neišeina patvirtinti, kad pateiktas el. pašto adresas atitinka turimą el. pašto įrašą.', + 'unableSendEmailToUser' => 'Deja, nepavyko išsiųsti el. laiško. Nepavyko išsiųsti laiško adresu "{0}".', + 'throttled' => 'Per daug užklausų iš šio IP adreso. Galite pamėginti iš naujo po {0} sekundžių.', + 'notEnoughPrivilege' => 'Neturite operacijai atlikti užtektinų leidimų.', + // JWT Exceptions + 'invalidJWT' => 'Raktas neteisingai suformuotas.', + 'expiredJWT' => 'Rakto galiojimas pasibaigęs.', + 'beforeValidJWT' => 'Rakto kol kas dar nėra.', + + 'email' => 'El. pašto adresas', + 'username' => 'Vartotojo vardas', + 'password' => 'Slaptažodis', + 'passwordConfirm' => 'Slaptažodis (pakartoti)', + 'haveAccount' => 'Jau turite paskyrą?', + 'token' => '(To be translated) Token', + + // Buttons + 'confirm' => 'Patvirtinti', + 'send' => 'Siųsti', + + // Registration + 'register' => 'Registruotis', + 'registerDisabled' => 'Šiuo metu registracija neleidžiama.', + 'registerSuccess' => 'Sveiki prisijungę!', + + // Login + 'login' => 'Prisijungimas', + 'needAccount' => 'Reikia paskyros?', + 'rememberMe' => 'Atsiminti mane?', + 'forgotPassword' => 'Pamiršote slaptažodį?', + 'useMagicLink' => 'Naudoti prisijungimo nuorodą', + 'magicLinkSubject' => 'Jūsų prisijungimo nuoroda', + 'magicTokenNotFound' => 'Nepavyksta patvirtinti nuorodos.', + 'magicLinkExpired' => 'Deja, nuorodos galiojimas baigėsi.', + 'checkYourEmail' => 'Patikrinkite savo el. paštą!', + 'magicLinkDetails' => 'Mes ką tik išsiuntėme Jums el. laišką su prisijungimo nuoroda. Ji galios tiki {0} minučių(-es).', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', + 'successLogout' => 'Jūs sėkmingai atsijungėte.', + 'backToLogin' => 'Grįžti į prisijungimą', + + // Passwords + 'errorPasswordLength' => 'Slaptažodis turi būti bent {0, number} ženklų ilgio.', + 'suggestPasswordLength' => 'Prisijungimo frazės - iki 255 ženklų ilgio - yra kur kas saugesni slaptažodžiai kuriuos lengva įsiminti.', + 'errorPasswordCommon' => 'Slaptažodis neturi būti paprastas žodis.', + 'suggestPasswordCommon' => 'Slaptažodis buvo patikrintas lyginant jį su daugiau nei 65 tūkst. įprastai naudojamų slaptažodžių ir slaptažodžių, kurie buvo išviešinti nulaužus sistemas.', + 'errorPasswordPersonal' => 'Slaptažodyje neturi būti įterpta asmeninės informacijos.', + 'suggestPasswordPersonal' => 'Slaptažodyje neturi būti naudojami menkai pakeisti el. pašto adreso arba vartotojo vardo variantai.', + 'errorPasswordTooSimilar' => 'Slaptažodis pernelyg panašus į vartotojo vardą.', + 'suggestPasswordTooSimilar' => 'Nenaudokite vartotojo vardo dalių slaptažodyje.', + 'errorPasswordPwned' => 'Slaptažodis {0} buvo išviešintas po internetinės sistemos nulaužimo ir buvo paskelbtas {1, number} kartus {2} nulaužtų slaptažodžių sąrašuose.', + 'suggestPasswordPwned' => '{0} neturi būti naudojamas kaip slaptažodis. Jei jį naudojate bet kur, tuoj pat pakeiskite.', + 'errorPasswordEmpty' => 'Reikia slaptažodžio.', + 'errorPasswordTooLongBytes' => 'Slaptažodis neturi būti ilgesnis nei {param} baitų(-ai).', + 'passwordChangeSuccess' => 'Slaptažodis sėkmingai pakeistas', + 'userDoesNotExist' => 'Slaptažodis nepakeistas. Tokio vartotojo nėra', + 'resetTokenExpired' => 'Deja, Jūsų slaptažodžio atkūrimo raktas nebegalioja.', + + // Email Globals + 'emailInfo' => 'Šiek tiek informacijos apie asmenį:', + 'emailIpAddress' => 'IP adresas:', + 'emailDevice' => 'Įrenginys:', + 'emailDate' => 'Data:', + + // 2FA + 'email2FATitle' => 'Dviejų faktorių autentifikacija', + 'confirmEmailAddress' => 'Patvirtinkite savo el. pašto adresą.', + 'emailEnterCode' => 'Patvirtinkite savo el. paštą', + 'emailConfirmCode' => 'Įrašykite 6 ženklų kodą, kurį ką tik išsiuntėme Jums el. paštu.', + 'email2FASubject' => 'Jūsų autentifikacijos kodas', + 'email2FAMailBody' => 'Jūsų autentifikacijos kodas yra:', + 'invalid2FAToken' => 'Kodas buvo neteisingas.', + 'need2FA' => 'Turite užbaigti dviejų faktorių autentifikaciją.', + 'needVerification' => 'Norėdami užbaigti paskyros aktyvavimą, patikrinkite savo el. pašto dėžutę.', + + // Activate + 'emailActivateTitle' => 'Aktyvavimas el. paštu', + 'emailActivateBody' => 'Mes ką tik išsiuntėme Jums el. laišką su kodu el. pašto adreso patvirtinimui. Nukopijuokite tą kodą ir įterpkite žemiau.', + 'emailActivateSubject' => 'Jūsų aktyvavimo kodas', + 'emailActivateMailBody' => 'Prašome naudoti žemiau esantį kodą paskyros aktyvavimui. Tuomet galėsite pradėti naudoti mūsų svetainę.', + 'invalidActivateToken' => 'Kodas buvo neteisingas.', + 'needActivate' => 'Turite baigti registraciją panaudodami kodą, išsiųstą Jums el. pašto adresu.', + 'activationBlocked' => 'Prieš prisijungdami turite aktyvuoti paskyrą.', + + // Groups + 'unknownGroup' => '{0} nėra egzistuojanti grupė.', + 'missingTitle' => 'Grupė turi turėti pavadinimą.', + + // Permissions + 'unknownPermission' => '{0} nėra žinomas leidimo tipas.', +]; diff --git a/src/Language/pt-BR/Auth.php b/src/Language/pt-BR/Auth.php index b2506bcdd..e4b479b5b 100644 --- a/src/Language/pt-BR/Auth.php +++ b/src/Language/pt-BR/Auth.php @@ -30,6 +30,7 @@ 'password' => 'Senha', 'passwordConfirm' => 'Senha (novamente)', 'haveAccount' => 'Já tem uma conta?', + 'token' => '(To be translated) Token', // Botões 'confirm' => 'Confirmar', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'Desculpe, o link expirou.', 'checkYourEmail' => 'Verifique seu e-mail!', 'magicLinkDetails' => 'Acabamos de enviar um e-mail com um link de Login. Ele é válido apenas por {0} minutos.', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', 'successLogout' => 'Você saiu com sucesso.', + 'backToLogin' => 'Voltar para o login', // Senhas 'errorPasswordLength' => 'As senhas devem ter pelo menos {0, number} caracteres.', diff --git a/src/Language/pt/Auth.php b/src/Language/pt/Auth.php index a5cfa8492..765db2070 100644 --- a/src/Language/pt/Auth.php +++ b/src/Language/pt/Auth.php @@ -30,6 +30,7 @@ 'password' => 'Senha', 'passwordConfirm' => 'Senha (novamente)', 'haveAccount' => 'Já tem uma conta?', + 'token' => '(To be translated) Token', // Botões 'confirm' => 'Confirmar', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'Desculpe, o link expirou.', 'checkYourEmail' => 'Verifique o seu e-mail!', 'magicLinkDetails' => 'Acabamos de enviar um e-mail com um link de Login. Ele é válido apenas por {0} minutos.', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', 'successLogout' => 'Saiu com sucesso.', + 'backToLogin' => 'Voltar ao login', // Senhas 'errorPasswordLength' => 'As passwords devem ter pelo menos {0, number} caracteres.', diff --git a/src/Language/sk/Auth.php b/src/Language/sk/Auth.php index 3424e88c4..11579e7ba 100644 --- a/src/Language/sk/Auth.php +++ b/src/Language/sk/Auth.php @@ -21,15 +21,16 @@ 'throttled' => 'Z tejto adresy IP bolo odoslaných príliš veľa žiadostí. Môžete to skúsiť znova o {0} sekúnd.', 'notEnoughPrivilege' => 'Nemáte potrebné povolenie na vykonanie požadovanej operácie.', // JWT Exceptions - 'invalidJWT' => '(To be translated) The token is invalid.', - 'expiredJWT' => '(To be translated) The token has expired.', - 'beforeValidJWT' => '(To be translated) The token is not yet available.', + 'invalidJWT' => 'Neplatný token.', + 'expiredJWT' => 'Platnosť tokenu vypršala.', + 'beforeValidJWT' => 'Token ešte nie je dostupný.', 'email' => 'Emailová adresa', 'username' => 'Používateľské meno', 'password' => 'Heslo', 'passwordConfirm' => 'Heslo (znova)', 'haveAccount' => 'Máte už účet?', + 'token' => '(To be translated) Token', // Buttons 'confirm' => 'Potvrdiť', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'Ľutujeme, platnosť odkazu vypršala.', 'checkYourEmail' => 'Skontrolujte e-mail', 'magicLinkDetails' => 'Práve sme vám poslali e-mail s odkazom na prihlásenie. Platí iba {0} minút.', + 'magicLinkDisabled' => 'Použitie magického linku momentálne nie je povolené.', 'successLogout' => 'Úspešne ste sa odhlásili.', + 'backToLogin' => 'Späť na prihlásenie', // Passwords 'errorPasswordLength' => 'Heslá musia mať aspoň {0, number} znakov.', diff --git a/src/Language/sr/Auth.php b/src/Language/sr/Auth.php index 6c8e71f01..9211ba0d4 100644 --- a/src/Language/sr/Auth.php +++ b/src/Language/sr/Auth.php @@ -30,6 +30,7 @@ 'password' => 'Lozinka', 'passwordConfirm' => 'Lozinka (ponovo)', 'haveAccount' => 'Već imate nalog?', + 'token' => '(To be translated) Token', // Buttons 'confirm' => 'Potvrdi', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'Žao nam je, link je istekao.', 'checkYourEmail' => 'Proverite Vaš email!', 'magicLinkDetails' => 'Upravo smo Vam poslali pristupni link. Pristupni link će biti validan još samo {0} minuta.', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', 'successLogout' => 'Uspešno ste se odjavili sa sistema.', + 'backToLogin' => 'Nazad na prijavljivanje', // Passwords 'errorPasswordLength' => 'Lozinka mora biti najmanje {0, number} znakova dužine.', diff --git a/src/Language/sv-SE/Auth.php b/src/Language/sv-SE/Auth.php index a37b09665..ab206d7d5 100644 --- a/src/Language/sv-SE/Auth.php +++ b/src/Language/sv-SE/Auth.php @@ -30,6 +30,7 @@ 'password' => 'Lösenord', 'passwordConfirm' => 'Lösenord (igen)', 'haveAccount' => 'Har du redan ett konto?', + 'token' => '(To be translated) Token', // Buttons 'confirm' => 'Bekräfta', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'Tyvärr, länken har gått ut.', 'checkYourEmail' => 'Kontrollera din epost!', 'magicLinkDetails' => 'En login-länk har skickats med epost. Den gäller bara i {0} minuter.', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', 'successLogout' => 'Du har loggats ut.', + 'backToLogin' => 'Tillbaka till inloggning', // Passwords 'errorPasswordLength' => 'Lösenordet måste vara minst {0, number} tecken långt.', diff --git a/src/Language/tr/Auth.php b/src/Language/tr/Auth.php index 57f515e31..2b35941d7 100644 --- a/src/Language/tr/Auth.php +++ b/src/Language/tr/Auth.php @@ -7,8 +7,8 @@ 'unknownAuthenticator' => '{0} geçerli bir kimlik doğrulayıcı değil.', 'unknownUserProvider' => 'Kullanılacak Kullanıcı Sağlayıcı belirlenemiyor.', 'invalidUser' => 'Belirtilen kullanıcı bulunamadı.', - 'bannedUser' => '(To be translated) Can not log you in as you are currently banned.', - 'logOutBannedUser' => '(To be translated) You have been logged out because you have been banned.', + 'bannedUser' => 'Bu hesap yasaklandı. Şu anda giriş yapamazsınız.', + 'logOutBannedUser' => 'Bu hesap yasaklandığından dolayı oturumunuz kapatıldı.', 'badAttempt' => 'Oturumunuz açılamıyor. Lütfen kimlik bilgilerinizi kontrol edin.', 'noPassword' => 'Parola olmadan bir kullanıcı doğrulanamaz.', 'invalidPassword' => 'Oturumunuz açılamıyor. Lütfen şifrenizi kontrol edin.', @@ -21,15 +21,16 @@ 'throttled' => 'Bu IP adresinden çok fazla istek yapıldı. {0} saniye sonra tekrar deneyebilirsiniz.', 'notEnoughPrivilege' => 'İstediğiniz işlemi gerçekleştirmek için gerekli izne sahip değilsiniz.', // JWT Exceptions - 'invalidJWT' => '(To be translated) The token is invalid.', - 'expiredJWT' => '(To be translated) The token has expired.', - 'beforeValidJWT' => '(To be translated) The token is not yet available.', + 'invalidJWT' => 'Token geçersiz.', + 'expiredJWT' => 'Tokenin süresi dolmuş.', + 'beforeValidJWT' => 'Token henüz geçerli değil.', 'email' => 'E-posta Adresi', 'username' => 'Kullanıcı Adı', 'password' => 'Şifre', 'passwordConfirm' => 'Şifre (tekrar)', 'haveAccount' => 'Zaten hesabınız var mı?', + 'token' => '(To be translated) Token', // Buttons 'confirm' => 'Onayla', @@ -38,7 +39,7 @@ // Registration 'register' => 'Kayıt Ol', 'registerDisabled' => 'Kayıt işlemine şu anda izin verilmiyor.', - 'registerSuccess' => 'Gemiye Hoşgeldiniz!', + 'registerSuccess' => 'Aramıza Hoşgeldiniz!', // Login 'login' => 'Giriş', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'Üzgünüm, bağlantının süresi doldu.', 'checkYourEmail' => 'E-postanı kontrol et!', 'magicLinkDetails' => 'Az önce size içinde bir Giriş bağlantısı olan bir e-posta gönderdik. Bağlantı {0} dakika için geçerlidir.', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', 'successLogout' => 'Başarıyla çıkış yaptınız.', + 'backToLogin' => 'Girişe Geri Dön', // Passwords 'errorPasswordLength' => 'Şifre en az {0, number} karakter uzunluğunda olmalıdır.', @@ -65,7 +68,7 @@ 'errorPasswordPwned' => '{0} şifresi, bir veri ihlali nedeniyle açığa çıktı ve güvenliği ihlal edilmiş şifrelerin {2} tanesinde {1, sayı} kez görüldü.', 'suggestPasswordPwned' => '{0} asla şifre olarak kullanılmamalıdır. Herhangi bir yerde kullanıyorsanız hemen değiştirin.', 'errorPasswordEmpty' => 'Şifre gerekli.', - 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', + 'errorPasswordTooLongBytes' => 'Şifre uzunluğu {param} baytı geçemez.', 'passwordChangeSuccess' => 'Şifre başarıyla değiştirildi.', 'userDoesNotExist' => 'Şifre değiştirilmedi. Kullanıcı yok.', 'resetTokenExpired' => 'Üzgünüz. Sıfırlama anahtarınızın süresi doldu.', @@ -94,7 +97,7 @@ 'emailActivateMailBody' => 'Hesabınızı etkinleştirmek ve siteyi kullanmaya başlamak için lütfen aşağıdaki kodu kullanın.', 'invalidActivateToken' => 'Kod yanlıştı.', 'needActivate' => 'E-posta adresinize gönderilen kodu onaylayarak kaydınızı tamamlamanız gerekmektedir.', - 'activationBlocked' => '(to be translated) You must activate your account before logging in.', + 'activationBlocked' => 'Giriş yapmadan önce hesabınızı etkinleştirmeniz gerekmektedir.', // Groups 'unknownGroup' => '{0} geçerli bir grup değil.', diff --git a/src/Language/uk/Auth.php b/src/Language/uk/Auth.php index 56828330c..d54b30dd1 100644 --- a/src/Language/uk/Auth.php +++ b/src/Language/uk/Auth.php @@ -30,6 +30,7 @@ 'password' => 'Пароль', 'passwordConfirm' => 'Пароль (ще раз)', 'haveAccount' => 'Вже є обліковий запис?', + 'token' => '(To be translated) Token', // Buttons 'confirm' => 'Підтвердити', @@ -51,7 +52,9 @@ 'magicLinkExpired' => 'Вибачте, термін дії посилання закінчився.', 'checkYourEmail' => 'Перевірте свою електронну пошту!', 'magicLinkDetails' => 'Ми щойно надіслали вам електронний лист із посиланням для входу. Він дійсний лише протягом {0} хвилин.', + 'magicLinkDisabled' => '(To be translated) Use of MagicLink is currently not allowed.', 'successLogout' => 'Ви успішно вийшли.', + 'backToLogin' => 'Повернутися до входу', // Passwords 'errorPasswordLength' => 'Паролі повинні містити принаймні {0, числових} символів.', diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index b15836da5..e0db1dec1 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -16,11 +16,21 @@ abstract class BaseModel extends Model */ protected array $tables; - protected function initialize(): void + protected Auth $authConfig; + + public function __construct() { - /** @var Auth $authConfig */ - $authConfig = config('Auth'); + $this->authConfig = config('Auth'); - $this->tables = $authConfig->tables; + if ($this->authConfig->DBGroup !== null) { + $this->DBGroup = $this->authConfig->DBGroup; + } + + parent::__construct(); + } + + protected function initialize(): void + { + $this->tables = $this->authConfig->tables; } } diff --git a/src/Models/CheckQueryReturnTrait.php b/src/Models/CheckQueryReturnTrait.php index 2c21441b1..687d7da96 100644 --- a/src/Models/CheckQueryReturnTrait.php +++ b/src/Models/CheckQueryReturnTrait.php @@ -32,7 +32,7 @@ protected function checkQueryReturn($return): void protected function checkValidationError(): void { - $validationErrors = $this->getValidationErrors(); + $validationErrors = $this->validation->getErrors(); if ($validationErrors !== []) { $message = 'Validation error:'; @@ -45,27 +45,6 @@ protected function checkValidationError(): void } } - /** - * Gets real validation errors that are not saved in the Session. - * - * @return string[] - */ - protected function getValidationErrors(): array - { - // @TODO When CI v4.3 is released, you don't need this hack. - // See https://github.com/codeigniter4/CodeIgniter4/pull/6384 - return $this->getValidationPropertyErrors(); - } - - protected function getValidationPropertyErrors(): array - { - $refClass = new ReflectionObject($this->validation); - $refProperty = $refClass->getProperty('errors'); - $refProperty->setAccessible(true); - - return $refProperty->getValue($this->validation); - } - protected function disableDBDebug(): void { if (! $this->db->DBDebug) { diff --git a/src/Models/UserIdentityModel.php b/src/Models/UserIdentityModel.php index ee19be499..b91c48712 100644 --- a/src/Models/UserIdentityModel.php +++ b/src/Models/UserIdentityModel.php @@ -6,6 +6,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\Authenticators\AccessTokens; +use CodeIgniter\Shield\Authentication\Authenticators\HmacSha256; use CodeIgniter\Shield\Authentication\Authenticators\Session; use CodeIgniter\Shield\Authentication\Passwords; use CodeIgniter\Shield\Entities\AccessToken; @@ -13,7 +14,9 @@ use CodeIgniter\Shield\Entities\UserIdentity; use CodeIgniter\Shield\Exceptions\LogicException; use CodeIgniter\Shield\Exceptions\ValidationException; +use Exception; use Faker\Generator; +use ReflectionException; class UserIdentityModel extends BaseModel { @@ -211,6 +214,140 @@ public function getAllAccessTokens(User $user): array ->findAll(); } + // HMAC + /** + * Find and Retrieve the HMAC AccessToken based on Token alone + * + * @return ?AccessToken Full HMAC Access Token object + */ + public function getHmacTokenByKey(string $key): ?AccessToken + { + return $this + ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN) + ->where('secret', $key) + ->asObject(AccessToken::class) + ->first(); + } + + /** + * Generates a new personal access token for the user. + * + * @param string $name Token name + * @param string[] $scopes Permissions the token grants + * + * @throws Exception + * @throws ReflectionException + */ + public function generateHmacToken(User $user, string $name, array $scopes = ['*']): AccessToken + { + $this->checkUserId($user); + + $return = $this->insert([ + 'type' => HmacSha256::ID_TYPE_HMAC_TOKEN, + 'user_id' => $user->id, + 'name' => $name, + 'secret' => bin2hex(random_bytes(16)), // Key + 'secret2' => bin2hex(random_bytes(config('AuthToken')->hmacSecretKeyByteSize)), // Secret Key + 'extra' => serialize($scopes), + ]); + + $this->checkQueryReturn($return); + + return $this + ->asObject(AccessToken::class) + ->find($this->getInsertID()); + } + + /** + * Retrieve Token object for selected HMAC Token. + * Note: These tokens are not hashed as they are considered shared secrets. + * + * @param User $user User Object + * @param string $key HMAC Key String + * + * @return ?AccessToken Full HMAC Access Token + */ + public function getHmacToken(User $user, string $key): ?AccessToken + { + $this->checkUserId($user); + + return $this->where('user_id', $user->id) + ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN) + ->where('secret', $key) + ->asObject(AccessToken::class) + ->first(); + } + + /** + * Given the ID, returns the given access token. + * + * @param int|string $id + * @param User $user User Object + * + * @return ?AccessToken Full HMAC Access Token + */ + public function getHmacTokenById($id, User $user): ?AccessToken + { + $this->checkUserId($user); + + return $this->where('user_id', $user->id) + ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN) + ->where('id', $id) + ->asObject(AccessToken::class) + ->first(); + } + + /** + * Retrieve all HMAC tokes for users + * + * @param User $user User object + * + * @return AccessToken[] + */ + public function getAllHmacTokens(User $user): array + { + $this->checkUserId($user); + + return $this + ->where('user_id', $user->id) + ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN) + ->orderBy($this->primaryKey) + ->asObject(AccessToken::class) + ->findAll(); + } + + /** + * Delete any HMAC tokens for the given key. + * + * @param User $user User object + * @param string $key HMAC Key + */ + public function revokeHmacToken(User $user, string $key): void + { + $this->checkUserId($user); + + $return = $this->where('user_id', $user->id) + ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN) + ->where('secret', $key) + ->delete(); + + $this->checkQueryReturn($return); + } + + /** + * Revokes all access tokens for this user. + */ + public function revokeAllHmacTokens(User $user): void + { + $this->checkUserId($user); + + $return = $this->where('user_id', $user->id) + ->where('type', HmacSha256::ID_TYPE_HMAC_TOKEN) + ->delete(); + + $this->checkQueryReturn($return); + } + /** * Used by 'magic-link'. */ @@ -319,6 +456,21 @@ public function revokeAccessToken(User $user, string $rawToken): void $this->checkQueryReturn($return); } + /** + * Delete any access tokens for the given secret token. + */ + public function revokeAccessTokenBySecret(User $user, string $secretToken): void + { + $this->checkUserId($user); + + $return = $this->where('user_id', $user->id) + ->where('type', AccessTokens::ID_TYPE_ACCESS_TOKEN) + ->where('secret', $secretToken) + ->delete(); + + $this->checkQueryReturn($return); + } + /** * Revokes all access tokens for this user. */ @@ -351,7 +503,7 @@ public function forceMultiplePasswordReset(array $userIds): void /** * Force global password reset. * This is useful for enforcing a password reset - * for ALL users incase of a security breach. + * for ALL users in case of a security breach. */ public function forceGlobalPasswordReset(): void { diff --git a/src/Models/UserModel.php b/src/Models/UserModel.php index ab5eb49c2..3c6f5f518 100644 --- a/src/Models/UserModel.php +++ b/src/Models/UserModel.php @@ -113,7 +113,8 @@ protected function fetchIdentities(array $data): array */ private function assignIdentities(array $data, array $identities): array { - $mappedUsers = []; + $mappedUsers = []; + $userIdentities = []; $users = $data['singleton'] ? [$data['data']] : $data['data']; @@ -122,15 +123,17 @@ private function assignIdentities(array $data, array $identities): array } unset($users); - // Now assign the identities to the user + // Now group the identities by user foreach ($identities as $identity) { - $userId = $identity->user_id; - - $newIdentities = $mappedUsers[$userId]->identities; - $newIdentities[] = $identity; + $userIdentities[$identity->user_id][] = $identity; + } + unset($identities); - $mappedUsers[$userId]->identities = $newIdentities; + // Now assign the identities to the user + foreach ($userIdentities as $userId => $identityArray) { + $mappedUsers[$userId]->identities = $identityArray; } + unset($userIdentities); return $mappedUsers; } diff --git a/src/Result.php b/src/Result.php index fa0ce1b37..9a3f368ca 100644 --- a/src/Result.php +++ b/src/Result.php @@ -13,7 +13,7 @@ class Result /** * Provides a simple explanation of * the error that happened. - * Typically a single sentence. + * Typically, a single sentence. */ protected ?string $reason = null; diff --git a/src/Test/MockInputOutput.php b/src/Test/MockInputOutput.php new file mode 100644 index 000000000..e4ce93dda --- /dev/null +++ b/src/Test/MockInputOutput.php @@ -0,0 +1,99 @@ +inputs = $inputs; + } + + /** + * Takes the last output from the output array. + */ + public function getLastOutput(): string + { + return array_pop($this->outputs); + } + + /** + * Takes the first output from the output array. + */ + public function getFirstOutput(): string + { + return array_shift($this->outputs); + } + + /** + * Returns all outputs. + */ + public function getOutputs(): string + { + return implode('', $this->outputs); + } + + public function prompt(string $field, $options = null, $validation = null): string + { + $input = array_shift($this->inputs); + + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + CITestStreamFilter::addErrorFilter(); + + PhpStreamWrapper::register(); + PhpStreamWrapper::setContent($input); + + $userInput = CLI::prompt($field, $options, $validation); + + PhpStreamWrapper::restore(); + + CITestStreamFilter::removeOutputFilter(); + CITestStreamFilter::removeErrorFilter(); + + if ($input !== $userInput) { + throw new LogicException($input . '!==' . $userInput); + } + + return $input; + } + + public function write( + string $text = '', + ?string $foreground = null, + ?string $background = null + ): void { + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + CLI::write($text, $foreground, $background); + $this->outputs[] = CITestStreamFilter::$buffer; + + CITestStreamFilter::removeOutputFilter(); + } + + public function error(string $text, string $foreground = 'light_red', ?string $background = null): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addErrorFilter(); + + CLI::error($text, $foreground, $background); + $this->outputs[] = CITestStreamFilter::$buffer; + + CITestStreamFilter::removeErrorFilter(); + } +} diff --git a/src/Validation/ValidationRules.php b/src/Validation/ValidationRules.php new file mode 100644 index 000000000..33ab171f2 --- /dev/null +++ b/src/Validation/ValidationRules.php @@ -0,0 +1,89 @@ +config = $authConfig; + $this->tables = $this->config->tables; + } + + public function getRegistrationRules(): array + { + helper('setting'); + + $setting = setting('Validation.registration'); + if ($setting !== null) { + return $setting; + } + + $usernameRules = $this->config->usernameValidationRules; + $usernameRules['rules'][] = sprintf( + 'is_unique[%s.username]', + $this->tables['users'] + ); + + $emailRules = $this->config->emailValidationRules; + $emailRules['rules'][] = sprintf( + 'is_unique[%s.secret]', + $this->tables['identities'] + ); + + $passwordRules = $this->getPasswordRules(); + $passwordRules['rules'][] = 'strong_password[]'; + + return [ + 'username' => $usernameRules, + 'email' => $emailRules, + 'password' => $passwordRules, + 'password_confirm' => $this->getPasswordConfirmRules(), + ]; + } + + public function getLoginRules(): array + { + helper('setting'); + + return setting('Validation.login') ?? [ + // 'username' => $this->config->usernameValidationRules, + 'email' => $this->config->emailValidationRules, + 'password' => $this->getPasswordRules(), + ]; + } + + public function getPasswordRules(): array + { + return [ + 'label' => 'Auth.password', + 'rules' => ['required', Passwords::getMaxLengthRule()], + 'errors' => [ + 'max_byte' => 'Auth.errorPasswordTooLongBytes', + ], + ]; + } + + public function getPasswordConfirmRules(): array + { + return [ + 'label' => 'Auth.passwordConfirm', + 'rules' => 'required|matches[password]', + ]; + } +} diff --git a/src/Views/email_activate_show.php b/src/Views/email_activate_show.php index 491fc2fbf..066bd0b1c 100644 --- a/src/Views/email_activate_show.php +++ b/src/Views/email_activate_show.php @@ -15,13 +15,14 @@

-
+ -
- + +
diff --git a/src/Views/login.php b/src/Views/login.php index 34193ce19..71b4501d0 100644 --- a/src/Views/login.php +++ b/src/Views/login.php @@ -32,13 +32,15 @@ -
- +
+ +
-
- +
+ +
diff --git a/src/Views/magic_link_form.php b/src/Views/magic_link_form.php index 35bfde4f1..ed820717e 100644 --- a/src/Views/magic_link_form.php +++ b/src/Views/magic_link_form.php @@ -28,9 +28,10 @@ -
- + +
@@ -38,6 +39,8 @@
+ +

diff --git a/src/Views/register.php b/src/Views/register.php index 4c8fb301e..7adba1aa3 100644 --- a/src/Views/register.php +++ b/src/Views/register.php @@ -28,23 +28,27 @@ -
- +
+ +
-
- +
+ +
-
- +
+ +
-
- +
+ +
diff --git a/tests/Authentication/Authenticators/AccessTokenAuthenticatorTest.php b/tests/Authentication/Authenticators/AccessTokenAuthenticatorTest.php index c5042dbb8..12ffadd54 100644 --- a/tests/Authentication/Authenticators/AccessTokenAuthenticatorTest.php +++ b/tests/Authentication/Authenticators/AccessTokenAuthenticatorTest.php @@ -110,7 +110,10 @@ public function testCheckNoToken(): void $result = $this->auth->check([]); $this->assertFalse($result->isOK()); - $this->assertSame(lang('Auth.noToken', [config('Auth')->authenticatorHeader['tokens']]), $result->reason()); + $this->assertSame( + lang('Auth.noToken', [config('AuthToken')->authenticatorHeader['tokens']]), + $result->reason() + ); } public function testCheckBadToken(): void @@ -174,7 +177,7 @@ public function testAttemptCannotFindUser(): void $this->assertFalse($result->isOK()); $this->assertSame(lang('Auth.badToken'), $result->reason()); - // A login attempt should have always been recorded + // A failed login attempt should have been recorded by default. $this->seeInDatabase($this->tables['token_logins'], [ 'id_type' => AccessTokens::ID_TYPE_ACCESS_TOKEN, 'identifier' => 'abc123', @@ -202,8 +205,8 @@ public function testAttemptSuccess(): void $this->assertInstanceOf(AccessToken::class, $foundUser->currentAccessToken()); $this->assertSame($token->token, $foundUser->currentAccessToken()->token); - // A login attempt should have been recorded - $this->seeInDatabase($this->tables['token_logins'], [ + // A successful login attempt is not recorded by default. + $this->dontSeeInDatabase($this->tables['token_logins'], [ 'id_type' => AccessTokens::ID_TYPE_ACCESS_TOKEN, 'identifier' => $token->raw_token, 'success' => 1, diff --git a/tests/Authentication/Authenticators/HmacAuthenticatorTest.php b/tests/Authentication/Authenticators/HmacAuthenticatorTest.php new file mode 100644 index 000000000..96db32cd0 --- /dev/null +++ b/tests/Authentication/Authenticators/HmacAuthenticatorTest.php @@ -0,0 +1,319 @@ +setProvider(model(UserModel::class)); + + config('AuthToken')->recordLoginAttempt = Auth::RECORD_LOGIN_ATTEMPT_ALL; + + /** @var HmacSha256 $authenticator */ + $authenticator = $auth->factory('hmac'); + $this->auth = $authenticator; + + Services::injectMock('events', new MockEvents()); + } + + public function testLogin(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + + $this->auth->login($user); + + // Stores the user + $this->assertInstanceOf(User::class, $this->auth->getUser()); + $this->assertSame($user->id, $this->auth->getUser()->id); + } + + public function testLogout(): void + { + // this one's a little odd since it's stateless, but roll with it... + $user = fake(UserModel::class); + + $this->auth->login($user); + $this->assertNotNull($this->auth->getUser()); + + $this->auth->logout(); + $this->assertNull($this->auth->getUser()); + } + + public function testLoginByIdNoToken(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + + $this->assertFalse($this->auth->loggedIn()); + + $this->auth->loginById($user->id); + + $this->assertTrue($this->auth->loggedIn()); + $this->assertNull($this->auth->getUser()->currentHmacToken()); + } + + public function testLoginByIdBadId(): void + { + fake(UserModel::class); + + $this->assertFalse($this->auth->loggedIn()); + + try { + $this->auth->loginById(0); + } catch (AuthenticationException $e) { + // Failed login + } + + $this->assertFalse($this->auth->loggedIn()); + $this->assertNull($this->auth->getUser()); + } + + public function testLoginByIdWithToken(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'); + $this->setRequestHeader($rawToken); + + $this->auth->loginById($user->id); + + $this->assertTrue($this->auth->loggedIn()); + $this->assertInstanceOf(AccessToken::class, $this->auth->getUser()->currentHmacToken()); + $this->assertSame($token->id, $this->auth->getUser()->currentHmacToken()->id); + } + + public function testLoginByIdWithMultipleTokens(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token1 = $user->generateHmacToken('foo'); + $user->generateHmacToken('bar'); + + $this->setRequestHeader($this->generateRawHeaderToken($token1->secret, $token1->secret2, 'bar')); + + $this->auth->loginById($user->id); + + $this->assertTrue($this->auth->loggedIn()); + $this->assertInstanceOf(AccessToken::class, $this->auth->getUser()->currentHmacToken()); + $this->assertSame($token1->id, $this->auth->getUser()->currentHmacToken()->id); + } + + public function testCheckNoToken(): void + { + $result = $this->auth->check([]); + + $this->assertFalse($result->isOK()); + $this->assertSame( + lang('Auth.noToken', [config('AuthToken')->authenticatorHeader['hmac']]), + $result->reason() + ); + } + + public function testCheckBadSignature(): void + { + $result = $this->auth->check([ + 'token' => 'abc123:lasdkjflksjdflksjdf', + 'body' => 'bar', + ]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.badToken'), $result->reason()); + } + + public function testCheckOldToken(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + /** @var UserIdentityModel $identities */ + $identities = model(UserIdentityModel::class); + $token = $user->generateHmacToken('foo'); + // CI 4.2 uses the Chicago timezone that has Daylight Saving Time, + // so subtracts 1 hour to make sure this test passes. + $token->last_used_at = Time::now()->subYears(1)->subHours(1)->subMinutes(1); + $identities->save($token); + + $result = $this->auth->check([ + 'token' => $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'), + 'body' => 'bar', + ]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.oldToken'), $result->reason()); + } + + public function testCheckSuccess(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $this->seeInDatabase($this->tables['identities'], [ + 'user_id' => $user->id, + 'type' => 'hmac_sha256', + 'last_used_at' => null, + ]); + + $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'); + + $result = $this->auth->check([ + 'token' => $rawToken, + 'body' => 'bar', + ]); + + $this->assertTrue($result->isOK()); + $this->assertInstanceOf(User::class, $result->extraInfo()); + $this->assertSame($user->id, $result->extraInfo()->id); + + $updatedToken = $result->extraInfo()->currentHmacToken(); + $this->assertNotEmpty($updatedToken->last_used_at); + + // Checking token in the same second does not throw "DataException : There is no data to update." + $this->auth->check(['token' => $rawToken, 'body' => 'bar']); + } + + public function testCheckBadToken(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $this->seeInDatabase($this->tables['identities'], [ + 'user_id' => $user->id, + 'type' => 'hmac_sha256', + 'last_used_at' => null, + ]); + + $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'foobar'); + + $result = $this->auth->check([ + 'token' => $rawToken, + 'body' => 'bar', + ]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.badToken'), $result->reason()); + } + + public function testAttemptCannotFindUser(): void + { + $result = $this->auth->attempt([ + 'token' => 'abc123:lsakdjfljsdflkajsfd', + 'body' => 'bar', + ]); + + $this->assertFalse($result->isOK()); + $this->assertSame(lang('Auth.badToken'), $result->reason()); + + // A login attempt should have always been recorded + $this->seeInDatabase($this->tables['token_logins'], [ + 'id_type' => HmacSha256::ID_TYPE_HMAC_TOKEN, + 'identifier' => 'abc123:lsakdjfljsdflkajsfd', + 'success' => 0, + ]); + } + + public function testAttemptSuccess(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'); + $this->setRequestHeader($rawToken); + + $result = $this->auth->attempt([ + 'token' => $rawToken, + 'body' => 'bar', + ]); + + $this->assertTrue($result->isOK()); + + $foundUser = $result->extraInfo(); + $this->assertInstanceOf(User::class, $foundUser); + $this->assertSame($user->id, $foundUser->id); + $this->assertInstanceOf(AccessToken::class, $foundUser->currentHmacToken()); + $this->assertSame($token->token, $foundUser->currentHmacToken()->token); + + // A login attempt should have been recorded + $this->seeInDatabase($this->tables['token_logins'], [ + 'id_type' => HmacSha256::ID_TYPE_HMAC_TOKEN, + 'identifier' => $rawToken, + 'success' => 1, + ]); + + // Check get key Method + $key = $this->auth->getHmacKeyFromToken(); + $this->assertSame($token->secret, $key); + + // Check get hash method + [, $hash] = explode(':', $rawToken); + $secretKey = $this->auth->getHmacHashFromToken(); + $this->assertSame($hash, $secretKey); + } + + public function testAttemptBanned(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $user->ban('Test ban.'); + + $token = $user->generateHmacToken('foo'); + $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'); + $this->setRequestHeader($rawToken); + + $result = $this->auth->attempt([ + 'token' => $rawToken, + 'body' => 'bar', + ]); + + $this->assertFalse($result->isOK()); + + $foundUser = $result->extraInfo(); + $this->assertNull($foundUser); + + // A login attempt should have been recorded + $this->seeInDatabase($this->tables['token_logins'], [ + 'id_type' => HmacSha256::ID_TYPE_HMAC_TOKEN, + 'identifier' => $rawToken, + 'success' => 0, + ]); + } + + protected function setRequestHeader(string $token): void + { + $request = service('request'); + $request->setHeader('Authorization', 'HMAC-SHA256 ' . $token); + } + + protected function generateRawHeaderToken(string $secret, string $secretKey, string $body): string + { + return $secret . ':' . hash_hmac('sha256', $body, $secretKey); + } +} diff --git a/tests/Authentication/Authenticators/JWTAuthenticatorTest.php b/tests/Authentication/Authenticators/JWTAuthenticatorTest.php index 5c074df12..3bd24070f 100644 --- a/tests/Authentication/Authenticators/JWTAuthenticatorTest.php +++ b/tests/Authentication/Authenticators/JWTAuthenticatorTest.php @@ -105,7 +105,7 @@ public function testCheckNoToken(): void $this->assertFalse($result->isOK()); $this->assertSame( - \lang('Auth.noToken', [config(AuthJWT::class)->authenticatorHeader]), + \lang('Auth.noToken', [config('AuthJWT')->authenticatorHeader]), $result->reason() ); } diff --git a/tests/Authentication/Filters/AbstractFilterTestCase.php b/tests/Authentication/Filters/AbstractFilterTestCase.php index 23ece0925..d44f9e931 100644 --- a/tests/Authentication/Filters/AbstractFilterTestCase.php +++ b/tests/Authentication/Filters/AbstractFilterTestCase.php @@ -69,6 +69,7 @@ static function ($routes): void { echo 'Open'; }); $routes->get('login', 'AuthController::login', ['as' => 'login']); + $routes->get('auth/a/show', 'AuthActionController::show', ['as' => 'auth-action-show']); $routes->get('protected-user-route', static function (): void { echo 'Protected'; }, ['filter' => $this->alias . ':users-read']); diff --git a/tests/Authentication/Filters/GroupFilterTest.php b/tests/Authentication/Filters/GroupFilterTest.php index 2e27e03ae..2690eeaff 100644 --- a/tests/Authentication/Filters/GroupFilterTest.php +++ b/tests/Authentication/Filters/GroupFilterTest.php @@ -31,6 +31,16 @@ public function testFilterNotAuthorized(): void $result->assertSee('Open'); } + public function testFilterNotAuthorizedStoresRedirectToEntranceUrlIntoSession(): void + { + $result = $this->call('get', 'protected-route'); + + $result->assertRedirectTo('/login'); + + $this->assertNotEmpty(session()->getTempdata('beforeLoginUrl')); + $this->assertSame(site_url('protected-route'), session()->getTempdata('beforeLoginUrl')); + } + public function testFilterSuccess(): void { /** @var User $user */ diff --git a/tests/Authentication/Filters/HmacFilterTest.php b/tests/Authentication/Filters/HmacFilterTest.php new file mode 100644 index 000000000..4a7acb9bc --- /dev/null +++ b/tests/Authentication/Filters/HmacFilterTest.php @@ -0,0 +1,135 @@ +call('get', 'protected-route'); + + $result->assertStatus(401); + + $result = $this->get('open-route'); + $result->assertStatus(200); + $result->assertSee('Open'); + } + + public function testFilterSuccess(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, ''); + $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $rawToken]) + ->get('protected-route'); + + $result->assertStatus(200); + $result->assertSee('Protected'); + + $this->assertSame($user->id, auth('hmac')->id()); + $this->assertSame($user->id, auth('hmac')->user()->id); + + // User should have the current token set. + $this->assertInstanceOf(AccessToken::class, auth('hmac')->user()->currentHmacToken()); + $this->assertSame($token->id, auth('hmac')->user()->currentHmacToken()->id); + } + + public function testFilterInvalidSignature(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar')]) + ->get('protected-route'); + + $result->assertStatus(401); + } + + public function testRecordActiveDate(): void + { + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->secret2, '')]) + ->get('protected-route'); + + // Last Active should be greater than 'updated_at' column + $this->assertGreaterThan(auth('hmac')->user()->updated_at, auth('hmac')->user()->last_active); + } + + public function testFiltersProtectsWithScopes(): void + { + /** @var User $user1 */ + $user1 = fake(UserModel::class); + $token1 = $user1->generateHmacToken('foo', ['users-read']); + /** @var User $user2 */ + $user2 = fake(UserModel::class); + $token2 = $user2->generateHmacToken('foo', ['users-write']); + + // User 1 should be able to access the route + $result1 = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token1->secret, $token1->secret2, '')]) + ->get('protected-user-route'); + + $result1->assertStatus(200); + // Last Active should be greater than 'updated_at' column + $this->assertGreaterThan(auth('hmac')->user()->updated_at, auth('hmac')->user()->last_active); + + // User 2 should NOT be able to access the route + $result2 = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token2->secret, $token2->secret2, '')]) + ->get('protected-user-route'); + + $result2->assertStatus(401); + } + + public function testBlocksInactiveUsers(): void + { + /** @var User $user */ + $user = fake(UserModel::class, ['active' => false]); + $token = $user->generateHmacToken('foo'); + + // Activation only required with email activation + setting('Auth.actions', ['register' => null]); + + $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->secret2, '')]) + ->get('protected-route'); + + $result->assertStatus(200); + $result->assertSee('Protected'); + + // Now require user activation and try again + setting('Auth.actions', ['register' => '\CodeIgniter\Shield\Authentication\Actions\EmailActivator']); + + $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->secret2, '')]) + ->get('protected-route'); + + $result->assertStatus(403); + + setting('Auth.actions', ['register' => null]); + } + + protected function generateRawHeaderToken(string $secret, string $secretKey, string $body): string + { + return $secret . ':' . hash_hmac('sha256', $body, $secretKey); + } +} diff --git a/tests/Authentication/Filters/JWTFilterTest.php b/tests/Authentication/Filters/JWTFilterTest.php index 082627888..e8f4d3b39 100644 --- a/tests/Authentication/Filters/JWTFilterTest.php +++ b/tests/Authentication/Filters/JWTFilterTest.php @@ -33,7 +33,8 @@ protected function setUp(): void $_SESSION = []; // Add JWT Authenticator - $config = config(Auth::class); + /** @var Auth $config */ + $config = config('Auth'); $config->authenticators['jwt'] = JWT::class; // Register our filter diff --git a/tests/Authentication/Filters/PermissionFilterTest.php b/tests/Authentication/Filters/PermissionFilterTest.php index 77aabdf4c..140bde35b 100644 --- a/tests/Authentication/Filters/PermissionFilterTest.php +++ b/tests/Authentication/Filters/PermissionFilterTest.php @@ -31,6 +31,16 @@ public function testFilterNotAuthorized(): void $result->assertSee('Open'); } + public function testFilterNotAuthorizedStoresRedirectToEntranceUrlIntoSession(): void + { + $result = $this->call('get', 'protected-route'); + + $result->assertRedirectTo('/login'); + + $this->assertNotEmpty(session()->getTempdata('beforeLoginUrl')); + $this->assertSame(site_url('protected-route'), session()->getTempdata('beforeLoginUrl')); + } + public function testFilterSuccess(): void { /** @var User $user */ diff --git a/tests/Authentication/Filters/SessionFilterTest.php b/tests/Authentication/Filters/SessionFilterTest.php index 759a88535..a217d7a74 100644 --- a/tests/Authentication/Filters/SessionFilterTest.php +++ b/tests/Authentication/Filters/SessionFilterTest.php @@ -58,7 +58,7 @@ public function testRecordActiveDate(): void $this->assertGreaterThan(auth('session')->user()->updated_at, auth('session')->user()->last_active); } - public function testBlocksInactiveUsers(): void + public function testBlocksInactiveUsersAndRedirectsToAuthAction(): void { $user = fake(UserModel::class, ['active' => false]); @@ -77,10 +77,21 @@ public function testBlocksInactiveUsers(): void $result = $this->actingAs($user) ->get('protected-route'); - $result->assertRedirectTo('/login'); + $result->assertRedirectTo('/auth/a/show'); // User should be logged out $this->assertNull(auth('session')->id()); setting('Auth.actions', ['register' => null]); } + + public function testStoreRedirectsToEntraceUrlIntoSession(): void + { + $result = $this->call('get', 'protected-route'); + + $result->assertRedirectTo('/login'); + + $session = session(); + $this->assertNotEmpty($session->get('beforeLoginUrl')); + $this->assertSame(site_url('protected-route'), $session->get('beforeLoginUrl')); + } } diff --git a/tests/Authentication/HasAccessTokensTest.php b/tests/Authentication/HasAccessTokensTest.php index 302e99906..9612b8136 100644 --- a/tests/Authentication/HasAccessTokensTest.php +++ b/tests/Authentication/HasAccessTokensTest.php @@ -101,6 +101,17 @@ public function testRevokeAccessToken(): void $this->assertCount(0, $this->user->accessTokens()); } + public function testRevokeAccessTokenBySecret(): void + { + $token = $this->user->generateAccessToken('foo'); + + $this->assertCount(1, $this->user->accessTokens()); + + $this->user->revokeAccessTokenBySecret($token->secret); + + $this->assertCount(0, $this->user->accessTokens()); + } + public function testRevokeAllAccessTokens(): void { $this->user->generateAccessToken('foo'); diff --git a/tests/Authentication/HasHmacTokensTest.php b/tests/Authentication/HasHmacTokensTest.php new file mode 100644 index 000000000..12ebcd284 --- /dev/null +++ b/tests/Authentication/HasHmacTokensTest.php @@ -0,0 +1,148 @@ +user = fake(UserModel::class); + $this->db->table($this->tables['identities'])->truncate(); + } + + public function testGenerateHmacToken(): void + { + $token = $this->user->generateHmacToken('foo'); + + $this->assertSame('foo', $token->name); + $this->assertNull($token->expires); + + $this->assertIsString($token->secret); + $this->assertIsString($token->secret2); + + // All scopes are assigned by default via wildcard + $this->assertSame(['*'], $token->scopes); + } + + public function testHmacTokens(): void + { + // Should return empty array when none exist + $this->assertSame([], $this->user->accessTokens()); + + // Give the user a couple of access tokens + $token1 = fake( + UserIdentityModel::class, + ['user_id' => $this->user->id, 'type' => 'hmac_sha256', 'secret' => 'key1', 'secret2' => 'secretKey1'] + ); + + $token2 = fake( + UserIdentityModel::class, + ['user_id' => $this->user->id, 'type' => 'hmac_sha256', 'secret' => 'key2', 'secret2' => 'secretKey2'] + ); + + $tokens = $this->user->hmacTokens(); + + $this->assertCount(2, $tokens); + $this->assertSame($token1->id, $tokens[0]->id); + $this->assertSame($token1->secret, $tokens[0]->secret); // Key + $this->assertSame($token1->secret2, $tokens[0]->secret2); // Secret Key + $this->assertSame($token2->id, $tokens[1]->id); + $this->assertSame($token2->secret, $tokens[1]->secret); + $this->assertSame($token2->secret2, $tokens[1]->secret2); + } + + public function testGetHmacToken(): void + { + // Should return null when not found + $this->assertNull($this->user->getHmacToken('foo')); + + $token = $this->user->generateHmacToken('foo'); + + $found = $this->user->getHmacToken($token->secret); + + $this->assertInstanceOf(AccessToken::class, $found); + $this->assertSame($token->id, $found->id); + $this->assertSame($token->secret, $found->secret); // Key + $this->assertSame($token->secret2, $found->secret2); // Secret Key + } + + public function testGetHmacTokenById(): void + { + // Should return null when not found + $this->assertNull($this->user->getHmacTokenById(123)); + + $token = $this->user->generateHmacToken('foo'); + $found = $this->user->getHmacTokenById($token->id); + + $this->assertInstanceOf(AccessToken::class, $found); + $this->assertSame($token->id, $found->id); + $this->assertSame($token->secret, $found->secret); // Key + $this->assertSame($token->secret2, $found->secret2); // Secret Key + } + + public function testRevokeHmacToken(): void + { + $token = $this->user->generateHmacToken('foo'); + + $this->assertCount(1, $this->user->hmacTokens()); + + $this->user->revokeHmacToken($token->secret); + + $this->assertCount(0, $this->user->hmacTokens()); + } + + public function testRevokeAllHmacTokens(): void + { + $this->user->generateHmacToken('foo'); + $this->user->generateHmacToken('foo'); + + $this->assertCount(2, $this->user->hmacTokens()); + + $this->user->revokeAllHmacTokens(); + + $this->assertCount(0, $this->user->hmacTokens()); + } + + public function testHmacTokenCanNoTokenSet(): void + { + $this->assertFalse($this->user->hmacTokenCan('foo')); + } + + public function testHmacTokenCanBasics(): void + { + $token = $this->user->generateHmacToken('foo', ['foo:bar']); + $this->user->setHmacToken($token); + + $this->assertTrue($this->user->hmacTokenCan('foo:bar')); + $this->assertFalse($this->user->hmacTokenCan('foo:baz')); + } + + public function testHmacTokenCantNoTokenSet(): void + { + $this->assertTrue($this->user->hmacTokenCant('foo')); + } + + public function testHmacTokenCant(): void + { + $token = $this->user->generateHmacToken('foo', ['foo:bar']); + $this->user->setHmacToken($token); + + $this->assertFalse($this->user->hmacTokenCant('foo:bar')); + $this->assertTrue($this->user->hmacTokenCant('foo:baz')); + } +} diff --git a/tests/Authorization/AuthorizableTest.php b/tests/Authorization/AuthorizableTest.php index 464cfcf1c..1dc098abf 100644 --- a/tests/Authorization/AuthorizableTest.php +++ b/tests/Authorization/AuthorizableTest.php @@ -305,6 +305,28 @@ public function testCanGetsInvalidPermission(): void $this->assertTrue($this->user->can('developer')); } + /** + * @see https://github.com/codeigniter4/shield/pull/791#discussion_r1297712860 + */ + public function testCanWorksWithMultiplePermissions(): void + { + // Check for user's direct permissions (user-level permissions) + $this->user->addPermission('users.create', 'users.edit'); + + $this->assertTrue($this->user->can('users.create', 'users.edit')); + $this->assertFalse($this->user->can('beta.access', 'admin.access')); + + $this->user->removePermission('users.create', 'users.edit'); + + $this->assertFalse($this->user->can('users.edit', 'users.create')); + + // Check for user's group permissions (group-level permissions) + $this->user->addGroup('superadmin'); + + $this->assertTrue($this->user->can('admin.access', 'beta.access')); + $this->assertTrue($this->user->can('admin.*', 'users.*')); + } + /** * @see https://github.com/codeigniter4/shield/pull/238 */ diff --git a/tests/Commands/SetupTest.php b/tests/Commands/SetupTest.php index 43225adf9..648f0f12e 100644 --- a/tests/Commands/SetupTest.php +++ b/tests/Commands/SetupTest.php @@ -4,9 +4,9 @@ namespace Tests\Commands; -use CodeIgniter\CodeIgniter; use CodeIgniter\Shield\Commands\Setup; -use CodeIgniter\Test\Filters\CITestStreamFilter; +use CodeIgniter\Shield\Test\MockInputOutput; +use Config\Email as EmailConfig; use Config\Services; use org\bovigo\vfs\vfsStream; use Tests\Support\TestCase; @@ -16,49 +16,42 @@ */ final class SetupTest extends TestCase { - private $streamFilter; - - protected function setUp(): void - { - parent::setUp(); - - if (version_compare(CodeIgniter::CI_VERSION, '4.3.0', '>=')) { - CITestStreamFilter::registration(); - CITestStreamFilter::addOutputFilter(); - } else { - CITestStreamFilter::$buffer = ''; - $this->streamFilter = stream_filter_append(STDOUT, 'CITestStreamFilter'); - } - } + private ?MockInputOutput $io = null; protected function tearDown(): void { parent::tearDown(); - if (version_compare(CodeIgniter::CI_VERSION, '4.3.0', '>=')) { - CITestStreamFilter::removeOutputFilter(); - CITestStreamFilter::removeErrorFilter(); - } else { - stream_filter_remove($this->streamFilter); - } + Setup::resetInputOutput(); + } + + /** + * Set MockInputOutput and user inputs. + * + * @param array $inputs User inputs + * @phpstan-param list $inputs + */ + private function setMockIo(array $inputs): void + { + $this->io = new MockInputOutput(); + $this->io->setInputs($inputs); + Setup::setInputOutput($this->io); } public function testRun(): void { - $root = vfsStream::setup('root'); - vfsStream::copyFromFileSystem( - APPPATH, - $root - ); - $appFolder = $root->url() . '/'; + // Set MockIO and your inputs. + $this->setMockIo([ + 'y', + 'admin@example.com', + 'y', + 'Site Administrator', + 'y', + ]); - $command = $this->getMockBuilder(Setup::class) - ->setConstructorArgs([Services::logger(), Services::commands()]) - ->onlyMethods(['cliPrompt']) - ->getMock(); - $command - ->method('cliPrompt') - ->willReturn('y'); + $appFolder = $this->createFilesystem(); + + $command = new Setup(Services::logger(), Services::commands()); $this->setPrivateProperty($command, 'distPath', $appFolder); @@ -68,20 +61,26 @@ public function testRun(): void $this->assertStringContainsString('namespace Config;', $auth); $this->assertStringContainsString('use CodeIgniter\Shield\Config\Auth as ShieldAuth;', $auth); + $authToken = file_get_contents($appFolder . 'Config/AuthToken.php'); + $this->assertStringContainsString('namespace Config;', $authToken); + $this->assertStringContainsString('use CodeIgniter\Shield\Config\AuthToken as ShieldAuthToken;', $authToken); + $routes = file_get_contents($appFolder . 'Config/Routes.php'); $this->assertStringContainsString('service(\'auth\')->routes($routes);', $routes); $security = file_get_contents($appFolder . 'Config/Security.php'); $this->assertStringContainsString('$csrfProtection = \'session\';', $security); - $result = str_replace(["\033[0;32m", "\033[0m"], '', CITestStreamFilter::$buffer); + $result = $this->getOutputWithoutColorCode(); $this->assertStringContainsString( ' Created: vfs://root/Config/Auth.php Created: vfs://root/Config/AuthGroups.php + Created: vfs://root/Config/AuthToken.php Updated: vfs://root/Controllers/BaseController.php Updated: vfs://root/Config/Routes.php - Updated: We have updated file \'vfs://root/Config/Security.php\' for security reasons.', + Updated: We have updated file \'vfs://root/Config/Security.php\' for security reasons. + Updated: vfs://root/Config/Email.php', $result ); $this->assertStringContainsString( @@ -89,4 +88,59 @@ public function testRun(): void $result ); } + + public function testRunEmailConfigIsFine(): void + { + // Set MockIO and your inputs. + $this->setMockIo(['y']); + + $config = config(EmailConfig::class); + $config->fromEmail = 'admin@example.com'; + $config->fromName = 'Site Admin'; + + $appFolder = $this->createFilesystem(); + + $command = new Setup(Services::logger(), Services::commands()); + + $this->setPrivateProperty($command, 'distPath', $appFolder); + + $command->run([]); + + $result = $this->getOutputWithoutColorCode(); + + $this->assertStringContainsString( + ' Created: vfs://root/Config/Auth.php + Created: vfs://root/Config/AuthGroups.php + Created: vfs://root/Config/AuthToken.php + Updated: vfs://root/Controllers/BaseController.php + Updated: vfs://root/Config/Routes.php + Updated: We have updated file \'vfs://root/Config/Security.php\' for security reasons.', + $result + ); + } + + /** + * @return string app folder path + */ + private function createFilesystem(): string + { + $root = vfsStream::setup('root'); + vfsStream::copyFromFileSystem( + APPPATH, + $root + ); + + return $root->url() . '/'; + } + + private function getOutputWithoutColorCode(): string + { + $output = str_replace(["\033[0;32m", "\033[0m"], '', $this->io->getOutputs()); + + if (is_windows()) { + $output = str_replace("\r\n", "\n", $output); + } + + return $output; + } } diff --git a/tests/Commands/UserModelGeneratorTest.php b/tests/Commands/UserModelGeneratorTest.php index 41c539593..7b8ebcb3e 100644 --- a/tests/Commands/UserModelGeneratorTest.php +++ b/tests/Commands/UserModelGeneratorTest.php @@ -4,7 +4,6 @@ namespace Tests\Commands; -use CodeIgniter\CodeIgniter; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Filters\CITestStreamFilter; @@ -13,22 +12,13 @@ */ final class UserModelGeneratorTest extends CIUnitTestCase { - private $streamFilter; - protected function setUp(): void { parent::setUp(); - if (version_compare(CodeIgniter::CI_VERSION, '4.3.0', '>=')) { - CITestStreamFilter::registration(); - CITestStreamFilter::addOutputFilter(); - CITestStreamFilter::addErrorFilter(); - } else { - CITestStreamFilter::$buffer = ''; - - $this->streamFilter = stream_filter_append(STDOUT, 'CITestStreamFilter'); - $this->streamFilter = stream_filter_append(STDERR, 'CITestStreamFilter'); - } + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + CITestStreamFilter::addErrorFilter(); if (is_file(HOMEPATH . 'src/Models/UserModel.php')) { copy(HOMEPATH . 'src/Models/UserModel.php', HOMEPATH . 'src/Models/UserModel.php.bak'); @@ -41,12 +31,8 @@ protected function tearDown(): void { parent::tearDown(); - if (version_compare(CodeIgniter::CI_VERSION, '4.3.0', '>=')) { - CITestStreamFilter::removeOutputFilter(); - CITestStreamFilter::removeErrorFilter(); - } else { - stream_filter_remove($this->streamFilter); - } + CITestStreamFilter::removeOutputFilter(); + CITestStreamFilter::removeErrorFilter(); $this->deleteTestFiles(); diff --git a/tests/Commands/UserTest.php b/tests/Commands/UserTest.php new file mode 100644 index 000000000..98a706563 --- /dev/null +++ b/tests/Commands/UserTest.php @@ -0,0 +1,604 @@ + $inputs User inputs + * @phpstan-param list $inputs + */ + private function setMockIo(array $inputs): void + { + $this->io = new MockInputOutput(); + $this->io->setInputs($inputs); + User::setInputOutput($this->io); + } + + private function getOutputWithoutColorCode(): string + { + $output = str_replace(["\033[0;32m", "\033[0m"], '', $this->io->getOutputs()); + + if (is_windows()) { + $output = str_replace("\r\n", "\n", $output); + } + + return $output; + } + + public function testNoAction(): void + { + $this->setMockIo([]); + + command('shield:user'); + + $this->assertStringContainsString( + 'Specify a valid action: create,activate,deactivate,changename,changeemail,delete,password,list,addgroup,removegroup', + $this->io->getLastOutput() + ); + } + + public function testCreate(): void + { + $this->setMockIo([ + 'Secret Passw0rd!', + 'Secret Passw0rd!', + ]); + + command('shield:user create -n user1 -e user1@example.com'); + + $this->assertStringContainsString( + 'User "user1" created', + $this->io->getFirstOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user1@example.com']); + $this->seeInDatabase($this->tables['identities'], [ + 'user_id' => $user->id, + 'secret' => 'user1@example.com', + ]); + $this->seeInDatabase($this->tables['users'], [ + 'id' => $user->id, + 'active' => 0, + ]); + } + + public function testCreateNotUniqueName(): void + { + $user = $this->createUser([ + 'username' => 'user1', + 'email' => 'user1@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo([ + 'Secret Passw0rd!', + 'Secret Passw0rd!', + ]); + + command('shield:user create -n user1 -e userx@example.com'); + + $this->assertStringContainsString( + 'The Username field must contain a unique value.', + $this->io->getFirstOutput() + ); + $this->assertStringContainsString( + 'User creation aborted', + $this->io->getFirstOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'userx@example.com']); + $this->assertNull($user); + } + + public function testCreatePasswordNotMatch(): void + { + $user = $this->createUser([ + 'username' => 'user1', + 'email' => 'user1@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo([ + 'password', + 'badpassword', + ]); + + command('shield:user create -n user1 -e userx@example.com'); + + $this->assertStringContainsString( + "The passwords don't match", + $this->io->getFirstOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'userx@example.com']); + $this->assertNull($user); + } + + /** + * Create an active user. + */ + private function createUser(array $userData): UserEntity + { + /** @var UserEntity $user */ + $user = fake(UserModel::class, ['username' => $userData['username']]); + $user->createEmailIdentity([ + 'email' => $userData['email'], + 'password' => $userData['password'], + ]); + + return $user; + } + + public function testActivate(): void + { + $user = $this->createUser([ + 'username' => 'user2', + 'email' => 'user2@example.com', + 'password' => 'secret123', + ]); + + $user->deactivate(); + $users = model(UserModel::class); + $users->save($user); + + $this->setMockIo(['y']); + + command('shield:user activate -n user2'); + + $this->assertStringContainsString( + 'User "user2" activated', + $this->io->getLastOutput() + ); + + $user = $users->findByCredentials(['email' => 'user2@example.com']); + $this->seeInDatabase($this->tables['users'], [ + 'id' => $user->id, + 'active' => 1, + ]); + } + + public function testDeactivate(): void + { + $this->createUser([ + 'username' => 'user3', + 'email' => 'user3@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user deactivate -n user3'); + + $this->assertStringContainsString( + 'User "user3" deactivated', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user3@example.com']); + $this->seeInDatabase($this->tables['users'], [ + 'id' => $user->id, + 'active' => 0, + ]); + } + + public function testChangename(): void + { + $this->createUser([ + 'username' => 'user4', + 'email' => 'user4@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user changename -n user4 --new-name newuser4'); + + $this->assertStringContainsString( + 'Username "user4" changed to "newuser4"', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user4@example.com']); + $this->seeInDatabase($this->tables['users'], [ + 'id' => $user->id, + 'username' => 'newuser4', + ]); + } + + public function testChangenameInvalidName(): void + { + $this->createUser([ + 'username' => 'user4', + 'email' => 'user4@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user changename -n user4 --new-name 1'); + + $this->assertStringContainsString( + 'The Username field must be at least 3 characters in length.', + $this->io->getFirstOutput() + ); + $this->assertStringContainsString( + 'User name change aborted', + $this->io->getFirstOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user4@example.com']); + $this->seeInDatabase($this->tables['users'], [ + 'id' => $user->id, + 'username' => 'user4', + ]); + } + + public function testChangeemail(): void + { + $this->createUser([ + 'username' => 'user5', + 'email' => 'user5@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user changeemail -n user5 --new-email newuser5@example.jp'); + + $this->assertStringContainsString( + 'Email for "user5" changed to newuser5@example.jp', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'newuser5@example.jp']); + $this->seeInDatabase($this->tables['users'], [ + 'id' => $user->id, + 'username' => 'user5', + ]); + } + + public function testChangeemailInvalidEmail(): void + { + $this->createUser([ + 'username' => 'user5', + 'email' => 'user5@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user changeemail -n user5 --new-email invalid'); + + $this->assertStringContainsString( + 'The Email Address field must contain a valid email address.', + $this->io->getFirstOutput() + ); + $this->assertStringContainsString( + 'User email change aborted', + $this->io->getFirstOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'invalid']); + $this->assertNull($user); + } + + public function testDelete(): void + { + $this->createUser([ + 'username' => 'user6', + 'email' => 'user6@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user delete -n user6'); + + $this->assertStringContainsString( + 'User "user6" deleted', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user6@example.com']); + $this->assertNull($user); + } + + public function testDeleteById(): void + { + $user = $this->createUser([ + 'username' => 'user6', + 'email' => 'user6@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user delete -i ' . $user->id); + + $this->assertStringContainsString( + 'User "user6" deleted', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user6@example.com']); + $this->assertNull($user); + } + + public function testDeleteUserNotExist(): void + { + $this->createUser([ + 'username' => 'user6', + 'email' => 'user6@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user delete -n userx'); + + $this->assertStringContainsString( + "User doesn't exist", + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user6@example.com']); + $this->assertNotNull($user); + } + + public function testPassword(): void + { + $this->createUser([ + 'username' => 'user7', + 'email' => 'user7@example.com', + 'password' => 'secret123', + ]); + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user7@example.com']); + $oldPasswordHash = $user->password_hash; + + $this->setMockIo(['y', 'newpassword', 'newpassword']); + + command('shield:user password -n user7'); + + $this->assertStringContainsString( + 'Password for "user7" set', + $this->io->getLastOutput() + ); + + $user = $users->findByCredentials(['email' => 'user7@example.com']); + $this->assertNotSame($oldPasswordHash, $user->password_hash); + } + + public function testPasswordWithoutOptionsAndSpecifyEmail(): void + { + $this->createUser([ + 'username' => 'user7', + 'email' => 'user7@example.com', + 'password' => 'secret123', + ]); + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user7@example.com']); + $oldPasswordHash = $user->password_hash; + + $this->setMockIo([ + 'e', 'user7@example.com', 'y', 'newpassword', 'newpassword', + ]); + + command('shield:user password'); + + $this->assertStringContainsString( + 'Password for "user7" set', + $this->io->getLastOutput() + ); + + $user = $users->findByCredentials(['email' => 'user7@example.com']); + $this->assertNotSame($oldPasswordHash, $user->password_hash); + } + + public function testPasswordNotMatch(): void + { + $this->createUser([ + 'username' => 'user7', + 'email' => 'user7@example.com', + 'password' => 'secret123', + ]); + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user7@example.com']); + $oldPasswordHash = $user->password_hash; + + $this->setMockIo([ + 'u', 'user7', 'y', 'newpassword', 'badpassword', + ]); + + command('shield:user password'); + + $this->assertStringContainsString( + "The passwords don't match", + $this->io->getLastOutput() + ); + + $user = $users->findByCredentials(['email' => 'user7@example.com']); + $this->assertSame($oldPasswordHash, $user->password_hash); + } + + public function testList(): void + { + $this->createUser([ + 'username' => 'user8', + 'email' => 'user8@example.com', + 'password' => 'secret123', + ]); + $this->createUser([ + 'username' => 'user9', + 'email' => 'user9@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo([]); + + command('shield:user list'); + + $this->assertStringContainsString( + 'Id User +1 user8 (user8@example.com) +2 user9 (user9@example.com) +', + $this->getOutputWithoutColorCode() + ); + } + + public function testListByEmail(): void + { + $this->createUser([ + 'username' => 'user8', + 'email' => 'user8@example.com', + 'password' => 'secret123', + ]); + $this->createUser([ + 'username' => 'user9', + 'email' => 'user9@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo([]); + + command('shield:user list -e user9@example.com'); + + $this->assertStringContainsString( + 'Id User +2 user9 (user9@example.com) +', + $this->getOutputWithoutColorCode() + ); + } + + public function testAddgroup(): void + { + $this->createUser([ + 'username' => 'user10', + 'email' => 'user10@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['y']); + + command('shield:user addgroup -n user10 -g admin'); + + $this->assertStringContainsString( + 'User "user10" added to group "admin"', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user10@example.com']); + $this->assertTrue($user->inGroup('admin')); + } + + public function testAddgroupCancel(): void + { + $this->createUser([ + 'username' => 'user10', + 'email' => 'user10@example.com', + 'password' => 'secret123', + ]); + + $this->setMockIo(['n']); + + command('shield:user addgroup -n user10 -g admin'); + + $this->assertStringContainsString( + 'Addition of the user "user10" to the group "admin" cancelled', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user10@example.com']); + $this->assertFalse($user->inGroup('admin')); + } + + public function testRemovegroup(): void + { + $this->createUser([ + 'username' => 'user11', + 'email' => 'user11@example.com', + 'password' => 'secret123', + ]); + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user11@example.com']); + $user->addGroup('admin'); + $this->assertTrue($user->inGroup('admin')); + + $this->setMockIo(['y']); + + command('shield:user removegroup -n user11 -g admin'); + + $this->assertStringContainsString( + 'User "user11" removed from group "admin"', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user11@example.com']); + $this->assertFalse($user->inGroup('admin')); + } + + public function testRemovegroupCancel(): void + { + $this->createUser([ + 'username' => 'user11', + 'email' => 'user11@example.com', + 'password' => 'secret123', + ]); + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user11@example.com']); + $user->addGroup('admin'); + $this->assertTrue($user->inGroup('admin')); + + $this->setMockIo(['n']); + + command('shield:user removegroup -n user11 -g admin'); + + $this->assertStringContainsString( + 'Removal of the user "user11" from the group "admin" cancelled', + $this->io->getLastOutput() + ); + + $users = model(UserModel::class); + $user = $users->findByCredentials(['email' => 'user11@example.com']); + $this->assertTrue($user->inGroup('admin')); + } +} diff --git a/tests/Controllers/ActionsTest.php b/tests/Controllers/ActionsTest.php index c8530ffe4..cb815af40 100644 --- a/tests/Controllers/ActionsTest.php +++ b/tests/Controllers/ActionsTest.php @@ -93,7 +93,7 @@ public function testEmail2FAHandleInvalidEmail(): void ]); $result->assertRedirect(); - $result->assertEquals(site_url('/auth/a/show'), $result->getRedirectUrl()); + $result->assertSame(site_url('/auth/a/show'), $result->getRedirectUrl()); $result->assertSessionHas('error', lang('Auth.invalidEmail')); } diff --git a/tests/Controllers/LoginTest.php b/tests/Controllers/LoginTest.php index b18ca3f3d..f4c7bc5f7 100644 --- a/tests/Controllers/LoginTest.php +++ b/tests/Controllers/LoginTest.php @@ -4,7 +4,6 @@ namespace Tests\Controllers; -use CodeIgniter\CodeIgniter; use CodeIgniter\Config\Factories; use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\Actions\Email2FA; @@ -110,11 +109,7 @@ public function testLoginTooLongPasswordArgon2id(): void public function testLoginActionEmailSuccess(): void { - if (version_compare(CodeIgniter::CI_VERSION, '4.3.0', '>=')) { - Time::setTestNow('March 10, 2017', 'UTC'); - } else { - Time::setTestNow('March 10, 2017', 'America/Chicago'); - } + Time::setTestNow('March 10, 2017', 'UTC'); $this->user->createEmailIdentity([ 'email' => 'foo@example.com', @@ -166,11 +161,7 @@ public function testAfterLoggedInNotDisplayLoginPage(): void public function testLoginActionUsernameSuccess(): void { - if (version_compare(CodeIgniter::CI_VERSION, '4.3.0', '>=')) { - Time::setTestNow('March 10, 2017', 'UTC'); - } else { - Time::setTestNow('March 10, 2017', 'America/Chicago'); - } + Time::setTestNow('March 10, 2017', 'UTC'); // Add 'username' to $validFields $authConfig = config('Auth'); @@ -183,7 +174,7 @@ public function testLoginActionUsernameSuccess(): void 'password' => 'required', ]; }; - $config->login['username'] = config('AuthSession')->usernameValidationRules; + $config->login['username'] = config('Auth')->usernameValidationRules; Factories::injectMock('config', 'Validation', $config); $this->user->createEmailIdentity([ diff --git a/tests/Controllers/MagicLinkTest.php b/tests/Controllers/MagicLinkTest.php index bec2370b1..b8b1f794e 100644 --- a/tests/Controllers/MagicLinkTest.php +++ b/tests/Controllers/MagicLinkTest.php @@ -114,4 +114,58 @@ public function testMagicLinkVerifyPendingRegistrationActivation(): void ); $this->assertFalse(auth()->loggedIn()); } + + public function testBackToLoginLinkOnPage(): void + { + $result = $this->get('/login/magic-link'); + $this->assertStringContainsString(lang('Auth.backToLogin'), $result->getBody()); + } + + public function testMagicLinkRedirectsIfNotAllowed(): void + { + $config = config('Auth'); + $config->allowMagicLinkLogins = false; + Factories::injectMock('config', 'Auth', $config); + + $result = $this->withSession()->get('/login/magic-link'); + + $result->assertStatus(302); + $result->assertRedirect(); + $result->assertSessionHas( + 'error', + lang('Auth.magicLinkDisabled'), + ); + } + + public function testMagicLinkActionRedirectsIfNotAllowed(): void + { + $config = config('Auth'); + $config->allowMagicLinkLogins = false; + Factories::injectMock('config', 'Auth', $config); + + $result = $this->withSession()->post('/login/magic-link'); + + $result->assertStatus(302); + $result->assertRedirect(); + $result->assertSessionHas( + 'error', + lang('Auth.magicLinkDisabled'), + ); + } + + public function testMagicLinkVerifyRedirectsIfNotAllowed(): void + { + $config = config('Auth'); + $config->allowMagicLinkLogins = false; + Factories::injectMock('config', 'Auth', $config); + + $result = $this->withSession()->get('/login/verify-magic-link'); + + $result->assertStatus(302); + $result->assertRedirect(); + $result->assertSessionHas( + 'error', + lang('Auth.magicLinkDisabled'), + ); + } } diff --git a/tests/Controllers/RegisterTest.php b/tests/Controllers/RegisterTest.php index 320db297f..630c13b38 100644 --- a/tests/Controllers/RegisterTest.php +++ b/tests/Controllers/RegisterTest.php @@ -82,6 +82,16 @@ public function testRegisterActionSuccess(): void $this->assertTrue($user->active); } + public function testRegisterActionSuccessWithNoEmailLogin(): void + { + /** @var Auth $config */ + $config = config('Auth'); + // Use `username` for login + $config->validFields = ['username']; + + $this->testRegisterActionSuccess(); + } + public function testRegisterTooLongPasswordDefault(): void { $result = $this->withSession()->post('/register', [ @@ -294,6 +304,24 @@ public function testRegisterActionRedirectsIfLoggedIn(): void $result->assertRedirectTo(config('Auth')->registerRedirect()); } + public function testRegisterActionWithBadEmailValue(): void + { + $result = $this->withSession()->post('/register', [ + 'username' => 'JohnDoe', + 'email' => 'john.doe', + 'password' => '123456789aa', + 'password_confirm' => '123456789aa', + ]); + + $result->assertStatus(302); + $result->assertRedirect(); + $result->assertSessionMissing('error'); + $result->assertSessionHas( + 'errors', + ['email' => 'The Email Address field must contain a valid email address.'] + ); + } + protected function setupConfig(): void { $config = config('Validation'); diff --git a/tests/Language/AbstractTranslationTestCase.php b/tests/Language/AbstractTranslationTestCase.php index 05097897c..b3b45a21f 100644 --- a/tests/Language/AbstractTranslationTestCase.php +++ b/tests/Language/AbstractTranslationTestCase.php @@ -47,7 +47,8 @@ abstract class AbstractTranslationTestCase extends TestCase * @var array */ public static array $locales = [ - // ArabicTranslationTest::class => 'ar', + ArabicTranslationTest::class => 'ar', + BulgarianTranslationTest::class => 'bg', // BosnianTranslationTest::class => 'bs', // CzechTranslationTest::class => 'cs', GermanTranslationTest::class => 'de', @@ -59,7 +60,7 @@ abstract class AbstractTranslationTestCase extends TestCase ItalianTranslationTest::class => 'it', JapaneseTranslationTest::class => 'ja', // KoreanTranslationTest::class => 'ko', - // LithuanianTranslationTest::class => 'lt', + LithuanianTranslationTest::class => 'lt', // LatvianTranslationTest::class => 'lv', // MalayalamTranslationTest::class => 'ml', // DutchTranslationTest::class => 'nl', diff --git a/tests/Language/ArabicTranslationTest.php b/tests/Language/ArabicTranslationTest.php new file mode 100644 index 000000000..22eeaf14f --- /dev/null +++ b/tests/Language/ArabicTranslationTest.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Language; + +/** + * @internal + */ +final class ArabicTranslationTest extends AbstractTranslationTestCase +{ +} diff --git a/tests/Language/BulgarianTranslationTest.php b/tests/Language/BulgarianTranslationTest.php new file mode 100644 index 000000000..1125d144e --- /dev/null +++ b/tests/Language/BulgarianTranslationTest.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Language; + +/** + * @internal + */ +final class BulgarianTranslationTest extends AbstractTranslationTestCase +{ +} diff --git a/tests/Language/LithuanianTranslationTest.php b/tests/Language/LithuanianTranslationTest.php new file mode 100644 index 000000000..b9a5372f0 --- /dev/null +++ b/tests/Language/LithuanianTranslationTest.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Language; + +/** + * @internal + */ +final class LithuanianTranslationTest extends AbstractTranslationTestCase +{ +} diff --git a/tests/Unit/Authentication/JWT/Adapters/FirebaseAdapaterTest.php b/tests/Unit/Authentication/JWT/Adapters/FirebaseAdapaterTest.php index 9618c151e..8385a3f00 100644 --- a/tests/Unit/Authentication/JWT/Adapters/FirebaseAdapaterTest.php +++ b/tests/Unit/Authentication/JWT/Adapters/FirebaseAdapaterTest.php @@ -82,7 +82,8 @@ public function testDecodeInvalidTokenExceptionUnexpectedValueException(): void $token = $this->generateJWT(); // Change algorithm and it makes the key invalid. - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $config->keys['default'][0]['alg'] = 'ES256'; $adapter = new FirebaseAdapter(); @@ -110,7 +111,8 @@ public function testDecodeInvalidArgumentException(): void $token = $this->generateJWT(); // Set invalid key. - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $config->keys['default'][0] = [ 'alg' => '', 'secret' => '', @@ -128,7 +130,8 @@ public function testEncodeLogicExceptionLogicException(): void $this->expectExceptionMessage('Cannot encode JWT: Algorithm not supported'); // Set unsupported algorithm. - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $config->keys['default'][0]['alg'] = 'PS256'; $adapter = new FirebaseAdapter(); diff --git a/tests/Unit/Authentication/JWT/JWTManagerTest.php b/tests/Unit/Authentication/JWT/JWTManagerTest.php index 7f5adfc1a..045e70633 100644 --- a/tests/Unit/Authentication/JWT/JWTManagerTest.php +++ b/tests/Unit/Authentication/JWT/JWTManagerTest.php @@ -55,7 +55,8 @@ public function testGenerateTokenPayload(array $data): void $manager = $this->createJWTManager(); $payload = $manager->parse($token); - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $expected = [ 'iss' => $config->defaultClaims['iss'], 'sub' => '1', @@ -121,7 +122,8 @@ public function testIssuePayload(array $data): void $manager = $this->createJWTManager(); $payload = $manager->parse($token); - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $expected = [ 'iss' => $config->defaultClaims['iss'], 'user_id' => '1', @@ -137,7 +139,8 @@ public function testIssueSetKid(): void $manager = $this->createJWTManager(); // Set kid - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $config->keys['default'][0]['kid'] = 'Key01'; $payload = [ @@ -181,7 +184,8 @@ public function testIssueWithAsymmetricKey(): void { $manager = $this->createJWTManager(); - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $config->keys['default'][0] = [ 'alg' => 'RS256', // algorithm. 'public' => '', // Public Key @@ -257,7 +261,8 @@ private function decodeJWT(string $token, $part): array public function testParseCanDecodeTokenSignedByOldKey(): void { - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $config->keys['default'] = [ [ 'kid' => 'Key01', @@ -294,7 +299,8 @@ public function testParseCanDecodeTokenSignedByOldKey(): void public function testParseCanSpecifyKey(): void { - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $config->keys['mobile'] = [ [ 'kid' => 'Key01', @@ -329,7 +335,8 @@ private function generateJWTWithAsymmetricKey(): string { $manager = $this->createJWTManager(); - $config = config(AuthJWT::class); + /** @var AuthJWT $config */ + $config = config('AuthJWT'); $config->keys['default'][0] = [ 'alg' => 'RS256', // algorithm. 'public' => <<<'EOD' diff --git a/tests/Unit/FilterInCliTest.php b/tests/Unit/FilterInCliTest.php index dd96fc09a..fa9a7c77d 100644 --- a/tests/Unit/FilterInCliTest.php +++ b/tests/Unit/FilterInCliTest.php @@ -10,7 +10,6 @@ use CodeIgniter\Shield\Filters\ChainAuth; use CodeIgniter\Shield\Filters\SessionAuth; use CodeIgniter\Shield\Filters\TokenAuth; -use Generator; use Tests\Support\TestCase; /** @@ -19,7 +18,7 @@ final class FilterInCliTest extends TestCase { /** - * @dataProvider filterProvider + * @dataProvider provideWhenInCliDoNothing */ public function testWhenInCliDoNothing(FilterInterface $filter): void { @@ -31,7 +30,7 @@ public function testWhenInCliDoNothing(FilterInterface $filter): void $filter->before($clirequest); } - public static function filterProvider(): Generator + public static function provideWhenInCliDoNothing(): iterable { yield from [ [new AuthRates()], diff --git a/tests/Unit/NothingPersonalValidatorTest.php b/tests/Unit/NothingPersonalValidatorTest.php index c1bc93c1f..b3e7d8e77 100644 --- a/tests/Unit/NothingPersonalValidatorTest.php +++ b/tests/Unit/NothingPersonalValidatorTest.php @@ -158,7 +158,7 @@ public function testFalseForSensibleMatch(): void * * $config->maxSimilarity = 50; is the highest setting where all tests pass. * - * @dataProvider passwordProvider + * @dataProvider provideIsNotPersonalFalsePositivesCaughtByIsNotSimilar * * @param mixed $password */ @@ -180,7 +180,7 @@ public function testIsNotPersonalFalsePositivesCaughtByIsNotSimilar($password): $this->assertNotSame($isNotPersonal, $isNotSimilar); } - public static function passwordProvider(): array + public static function provideIsNotPersonalFalsePositivesCaughtByIsNotSimilar(): iterable { return [ ['JoeTheCaptain'], @@ -195,7 +195,7 @@ public static function passwordProvider(): array } /** - * @dataProvider firstLastNameProvider + * @dataProvider provideConfigPersonalFieldsValues * * @param mixed $firstName * @param mixed $lastName @@ -225,7 +225,7 @@ public function testConfigPersonalFieldsValues($firstName, $lastName, $expected) $this->assertSame($expected, $result->isOK()); } - public static function firstLastNameProvider() + public static function provideConfigPersonalFieldsValues(): iterable { return [ [ @@ -247,7 +247,7 @@ public static function firstLastNameProvider() } /** - * @dataProvider maxSimilarityProvider + * @dataProvider provideMaxSimilarityZeroTurnsOffSimilarityCalculation * * The calculated similarity of 'captnjoe' and 'CaptainJoe' is 88.89. * With $config->maxSimilarity = 66; the password should be rejected, @@ -275,7 +275,7 @@ public function testMaxSimilarityZeroTurnsOffSimilarityCalculation($maxSimilarit $this->assertSame($expected, $result->isOK()); } - public static function maxSimilarityProvider() + public static function provideMaxSimilarityZeroTurnsOffSimilarityCalculation(): iterable { return [ [ @@ -287,4 +287,41 @@ public static function maxSimilarityProvider() ], ]; } + + /** + * @dataProvider provideCheckPasswordWithBadEmail + */ + public function testCheckPasswordWithBadEmail(string $email, bool $expected): void + { + $config = new Auth(); + $this->validator = new NothingPersonalValidator($config); + + $user = new User([ + 'username' => 'CaptainJoe', + 'email' => $email, + ]); + + $password = '123456789a'; + + $result = $this->validator->check($password, $user); + + $this->assertSame($expected, $result->isOK()); + } + + public static function provideCheckPasswordWithBadEmail(): iterable + { + return [ + [ + 'test', + true, + ], [ + 'test@example', + true, + ], + [ + 'test@example.com', + true, + ], + ]; + } } diff --git a/tests/Unit/PwnedValidatorTest.php b/tests/Unit/PwnedValidatorTest.php index 8c86706fb..1d0c7ec90 100644 --- a/tests/Unit/PwnedValidatorTest.php +++ b/tests/Unit/PwnedValidatorTest.php @@ -122,10 +122,10 @@ public function testCheckCatchesAndRethrowsCurlExceptionAsAuthException(): void ->getMock(); $curlrequest->method('get') - ->will($this->throwException(HTTPException::forCurlError( + ->willThrowException(HTTPException::forCurlError( '7', 'Failed to connect' - ))); + )); Services::injectMock('curlrequest', $curlrequest); $this->expectException(AuthenticationException::class); diff --git a/tests/Unit/UserTest.php b/tests/Unit/UserTest.php index b10860ebc..f2554fa62 100644 --- a/tests/Unit/UserTest.php +++ b/tests/Unit/UserTest.php @@ -87,6 +87,35 @@ public function testModelFindByIdWithIdentities(): void $this->assertCount(2, $user->identities); } + public function testModelFindAllWithIdentitiesWhereInQuery(): void + { + fake(UserIdentityModel::class, ['user_id' => $this->user->id, 'type' => 'password']); + fake(UserIdentityModel::class, ['user_id' => $this->user->id, 'type' => 'access_token']); + + // Grab the user again, using the model's identity helper + $users = model(UserModel::class)->withIdentities()->findAll(); + + $identities = []; + + foreach ($users as $user) { + if ($user->id !== $this->user->id) { + continue; + } + + $identities = $user->identities; + + // Check the last query and see if a proper type of query was used + $query = (string) model(UserModel::class)->getLastQuery(); + $this->assertMatchesRegularExpression( + '/WHERE\s+.*\s+IN\s+\([^)]+\)/i', + $query, + 'Identities were not obtained with the single query (missing "WHERE ... IN" condition)' + ); + } + + $this->assertCount(2, $identities); + } + public function testModelFindByIdWithIdentitiesUserNotExists(): void { $user = model(UserModel::class)->where('active', 0)->withIdentities()->findById(1); @@ -180,6 +209,7 @@ public function testUpdateEmail(): void $users = model(UserModel::class); $users->save($this->user); + /** @var User $user */ $user = $users->find($this->user->id); $this->seeInDatabase($this->tables['identities'], [ @@ -221,6 +251,7 @@ public function testUpdatePasswordHash(): void $users = model(UserModel::class); $users->save($this->user); + /** @var User $user */ $user = $users->find($this->user->id); $this->seeInDatabase($this->tables['identities'], [