diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 8e0274f8..34f2a5a9 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -2,4 +2,15 @@ # Introduction - [Overview](readme.md) -- [Installation](installation.md) + +# Setup + - [Installation](installation.md) + - [Using docker](installation-docker.md) + +# Usage + - [Administration](usage/README.md) + - [Admin API](usage/api.md) + - [Webhooks](webhook.md) + - [User Authentication](authentication.md) + - [JWT Configuration](authentication-jwt.md) + - [LDAP Configuration](authentication-ldap.md) diff --git a/docs/authentication-jwt.md b/docs/authentication-jwt.md new file mode 100644 index 00000000..74399f26 --- /dev/null +++ b/docs/authentication-jwt.md @@ -0,0 +1,105 @@ +# JWT API Authentication + +By default, packeton is storage api tokens in database for each user. But when user +loaded from custom user provider, like LDAP need to enable JWT configuration to use API. So Packeton can be +configured with a non-standard login type to support [JSON Web Tokens](https://en.wikipedia.org/wiki/JSON_Web_Token). + +The JSON Web Token integration in Packeton uses the [Firebase library](https://github.com/firebase/php-jwt). +Also, JWT authentication can be enabled only for API. + +Add `yaml` configuration file to path `config/packages/`, for example `config/packages/jwt.yaml` to enable it. + +```yaml +packeton: + jwt_authentication: + private_key: '%kernel.project_dir%/var/jwt/eddsa-key.pem' + public_key: '%kernel.project_dir%/var/jwt/eddsa-public.pem' +``` + +Full configurations: + +```yaml +# config/packages/config/packages/jwt.yaml +packeton: + jwt_authentication: + private_key: '%kernel.project_dir%/var/jwt/eddsa-key.pem' # required for token sign + public_key: '%kernel.project_dir%/var/jwt/eddsa-public.pem' # required for token verification + passphrase: ~ + algo: EdDSA # Sign algo, here libsodium EdDSA +``` + +## Generate the public/private keys + +``` +bin/console packagist:jwt:generate-keypair + +bin/console packagist:jwt:generate-keypair --overwrite +``` + +Available options: + * --overwrite will overwrite your keys if they already exist. + +If keys already exists, a warning message will be raised to prevent you from overwriting your keys accidentally. + +### JWT Token TTL. + +JWT Token is never expire. It was done for compatibility with composer basic HTTP authorization. +Each time the api is called, Packeton is checked that the user exists in the database and that +he has the same set of permissions and roles. + +### Digital signatures algos. + +We support all algos from Firebase lib: HMAC, OpenSSL RSA, OpenSSL + +Rsa, HMAC, EdDSA algorithms generate invariant tokens, i.e. the value of the token will be constant for the same user. +It might be convenient as the app does not store the generated tokens. + +Example how to change algo: + +```yaml +packeton: + jwt_authentication: + ... + algo: RS256 # RSA 256 +``` + +### Generating keys using OpenSSL + +Example, how to generate an RSA private key, `key.pem` - private key. `public.pem` - public + +``` +openssl genrsa -out key.pem 2048 +openssl rsa -in key.pem -outform PEM -pubout -out public.pem +``` + +Example, how to generate an ES256 (elliptic curve) key pairs. + +``` +openssl ecparam -name prime256v1 -genkey -noout -out key.pem +openssl ec -in key.pem -pubout -out public.pem +``` + +## Obtain the token + +You can run command `packagist:user:manager` to show the api token: + +``` +bin/console packagist:user:manager admin --show-token --token-format=jwt +``` + +Or you can found api token on your profile page. + +[![Keys](img/jwt_keys.png)](img/jwt_keys.png) + +### Use the token + +Simply use the JWT, like standard API token for composer api. + +#### Cache LDAP user loading. + +Since 2.0 composer downloads all packages in parallel, it may run more 12 request at the same time. +To prevent calls external LDAP provider each time for JWT token verify, the obtained LDAP user object +placed to cache with 60 sec TTL. + + + diff --git a/docs/authentication-ldap.md b/docs/authentication-ldap.md new file mode 100644 index 00000000..c29be7a5 --- /dev/null +++ b/docs/authentication-ldap.md @@ -0,0 +1,125 @@ +# Authenticating against an LDAP server + +You can enable LDAP authenticating only on configuration level. + +Packeton has pre-installed Symfony LDAP component. Add the file `config/packages/ldap.yaml` to enable LDAP +with following content. See LDAP in [Symfony Docs](https://symfony.com/doc/current/security/ldap.html) + +```yaml +parameters: + default_login_provider: 'form_login_ldap' + default_login_options: + provider: all_users + login_path: /login + use_forward: false + check_path: /login + failure_path: null + service: Symfony\Component\Ldap\Ldap + dn_string: 'uid={username},dc=example,dc=com' + +services: + Symfony\Component\Ldap\Ldap: + arguments: ['@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter'] + tags: + - ldap + + Symfony\Component\Ldap\Adapter\ExtLdap\Adapter: + arguments: + - host: ldap.forumsys.com + port: 389 + +security: + providers: + users_ldap: + ldap: + service: Symfony\Component\Ldap\Ldap + base_dn: dc=example,dc=com + search_dn: "cn=read-only-admin,dc=example,dc=com" + search_password: password + default_roles: ROLE_MAINTAINER + uid_key: uid + + all_users: + chain: + providers: ['packagist', 'users_ldap'] +``` + +Here is working example where used test `ldap.forumsys.com` server https://www.forumsys.com/2022/05/10/online-ldap-test-server/ + +Using LDAP integration does not prevent you from creating user manually from CLI and assign more accessible roles. +At the same LDAP password validation will be done on LDAP server side, because `CheckLdapCredentialsListener` has higher priority +loading than default check listener. Therefore, if user is not enable in LDAP - it will not able login to packeton. + +## User providers priority. + +Packeton use Symfony [Chain User Provider](https://symfony.com/doc/current/security/user_providers.html#chain-user-provider) +to lookup users. + +If you want to use customer user restriction by vendors and versions, `packagist` user provider must load before ldap. + +```yaml +security: + providers: + users_ldap: + ldap: + ... + + all_users: + chain: + providers: ['packagist', 'users_ldap'] # Load user/roles form default packagist and if not found - use ldap user + providers: ['users_ldap', 'packagist'] # packagist users will be ignore +``` + +## Load different roles from LDAP. + +You can use more 1 user providers: + +```yaml +security: + providers: + users_ldap: + ldap: + service: Symfony\Component\Ldap\Ldap + base_dn: dc=example,dc=com + search_dn: "cn=read-only-admin,dc=example,dc=com" + filter: "(&(objectclass=groupOfUniqueNames)(ou=scientists)(uniqueMember=uid={username},dc=example,dc=com))" + search_password: password + default_roles: ROLE_MAINTAINER + uid_key: uid + + users_ldap_admin: + ldap: + service: Symfony\Component\Ldap\Ldap + base_dn: dc=example,dc=com + search_dn: "cn=read-only-admin,dc=example,dc=com" + filter: "(&(objectclass=groupOfUniqueNames)(ou=mathematicians)(uniqueMember=uid={username},dc=example,dc=com))" + search_password: password + default_roles: ROLE_ADMIN + uid_key: uid + + all_users: + chain: + providers: ['packagist', 'users_ldap', 'users_ldap_admin'] +``` + +Here test example where exists two Groups (ou) that include: + + * ou=mathematicians,dc=example,dc=com - assign role ROLE_ADMIN + * ou=scientists,dc=example,dc=com - assign role ROLE_MAINTAINER + +## API authentication with LDAP users. + +By default, packeton is storage api token in database for each user. +But if the user was loaded by custom external users' provider, but from the database, you will need enable JWT configuration. +See [JWT Configuration](authentication-jwt.md) + +## Enable LDAP for docker runtime. + +You can use docker volume to share own configuration to application. + +``` +... + volumes: + - .docker:/data + - ${PWD}/ldap.yaml:/var/www/packagist/config/packages/ldap.yaml +``` diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 00000000..955c4259 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,53 @@ +# User Authentication + +Packeton may support multiple methods of authenticating users. It can additionally be extended to support +custom authentication schemes. + +## Web User authentication +Included in packeton is support for authenticating users via: + +* A username and password. +* An email address and password. + +But possible to enable LDAP only via configuration, see [ldap authentication](./authentication-ldap.md) + +## Composer API authentication + +Packeton is support API authentication only with api token. Password usage is not allowed. +You can see api token in thr user profile menu. + +Support for authenticating users via: +* HTTP Basic Authentication (username and api token) +* Short query param `token` = `username:apiToken` +* Default packagist hook API (query params: `username` = username, `apiToken` = apiToken) + +Your customer needs to authenticate to access their Composer repository: +The simplest way to provide your credentials is providing your set of credentials inline with the repository specification such as: + +```json +{ + "repositories": [ + { + "type": "composer", + "url": "https://:@example.org" + } + ] +} +``` + +When you don't want to hard code your credentials into your composer.json, you can set up it global. + +``` +composer config --global --auth http-basic.example.org username api_token +``` + +Example API call. + +``` +curl https://example.com/packages/list.json + -u "username:apiToken" +``` + +``` +curl https://example.com/packages/list.json?token=username:apiToken +``` diff --git a/docs/img/intro1.png b/docs/img/intro1.png new file mode 100644 index 00000000..09ab5581 Binary files /dev/null and b/docs/img/intro1.png differ diff --git a/docs/img/intro2.png b/docs/img/intro2.png new file mode 100644 index 00000000..274b19c1 Binary files /dev/null and b/docs/img/intro2.png differ diff --git a/docs/img/intro3.png b/docs/img/intro3.png new file mode 100644 index 00000000..abcec666 Binary files /dev/null and b/docs/img/intro3.png differ diff --git a/docs/img/intro4.png b/docs/img/intro4.png new file mode 100644 index 00000000..eba4c107 Binary files /dev/null and b/docs/img/intro4.png differ diff --git a/docs/img/intro5.png b/docs/img/intro5.png new file mode 100644 index 00000000..fd01f354 Binary files /dev/null and b/docs/img/intro5.png differ diff --git a/docs/img/intro6.png b/docs/img/intro6.png new file mode 100644 index 00000000..8c65477d Binary files /dev/null and b/docs/img/intro6.png differ diff --git a/docs/img/intro7.png b/docs/img/intro7.png new file mode 100644 index 00000000..195bfea6 Binary files /dev/null and b/docs/img/intro7.png differ diff --git a/docs/img/intro8.png b/docs/img/intro8.png new file mode 100644 index 00000000..90f2b871 Binary files /dev/null and b/docs/img/intro8.png differ diff --git a/docs/img/jwt_keys.png b/docs/img/jwt_keys.png new file mode 100644 index 00000000..20256aef Binary files /dev/null and b/docs/img/jwt_keys.png differ diff --git a/docs/installation-docker.md b/docs/installation-docker.md new file mode 100644 index 00000000..a7a1789d --- /dev/null +++ b/docs/installation-docker.md @@ -0,0 +1,190 @@ +Install and Run in Docker +------------------------ + +You can use prebuild [packeton/packeton](https://hub.docker.com/r/packeton/packeton) image + +## Quick start +``` +docker run -d --name packeton \ + --mount type=volume,src=packeton-data,dst=/data \ + -p 8080:80 \ + packeton/packeton:latest +``` + +After container is running, you may wish to create an admin user via command `packagist:user:manager` +``` +docker exec -it packeton bin/console packagist:user:manager admin --password=123456 --admin +``` + +## Docker Environment + +All env variables is optional. By default, Packeton uses an SQLite database and build-in redis service, +but you can overwrite it by env REDIS_URL and DATABASE_URL. The all app data is stored in the VOLUME /data + +- `APP_SECRET` - Must be static, used for encrypt SSH keys in database. The value is generated automatically, see `.env` in the data volume. +- `APP_COMPOSER_HOME` - composer home, default /data/composer +- `DATABASE_URL` - Database DSN, default sqlite:////data/app.db. Example for postgres "postgresql://app:pass@127.0.0.1:5432/app?serverVersion=14&charset=utf8" +- `PACKAGIST_DIST_PATH` - Default /data/zipball, path to storage zipped versions +- `REDIS_URL` - Redis DB, default redis://localhost +- `PACKAGIST_DIST_HOST` - Hostname, (auto) default use the current host header in the request. + Overwrite packagist host (example https://packagist.youcomany.org). Used for downloading the mirroring zip packages. +(The host add into dist url for composer metadata). The default value is define dynamically from the header Host. + +- `TRUSTED_PROXIES` - Ips for Reverse Proxy. See [Symfony docs](https://symfony.com/doc/current/deployment/proxies.html) +- `PUBLIC_ACCESS` - Allow anonymous users access to read packages metadata, default: `false` +- `MAILER_DSN` - Mailer for reset password, default disabled +- `MAILER_FROM` - Mailer from +- `ADMIN_USER` Creating admin account, by default there is no admin user created so you won't be able to login to +the packagist. To create an admin account you need to use environment variables to pass +in an initial username and password (`ADMIN_PASSWORD`, `ADMIN_EMAIL`). +- `ADMIN_PASSWORD` - used together with `ADMIN_USER` +- `ADMIN_EMAIL` - used together with `ADMIN_USER` +- `PRIVATE_REPO_DOMAIN_LIST` - Save ssh fingerprints to known_hosts for this domain. + +#### VOLUME + +The all app data is stored in the VOLUME /data + +### User docker compose + +The typical example `docker-compose.yml` + +```yaml +version: '3.6' + +services: + packeton: + image: packeton/packeton:latest + container_name: packeton + hostname: packeton + environment: + ADMIN_USER: admin + ADMIN_PASSWORD: 123456 + ADMIN_EMAIL: admin@example.com + DATABASE_URL: mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8&charset=utf8mb4 + ports: + - '127.0.0.1:8080:80' + volumes: + - .docker:/data +``` + +By default, the container starts the supervisor, which is used to run other tasks: +nginx, redis, php-fpm, cron, however, you can start one service per container. +See docker-compose-prod.yml example: + +```yaml +version: '3.9' + +x-volumes: &default-volume + volumes: + - app-data:/data + - app-var:/var/www/packagist/var + +x-restart-policy: &restart_policy + restart: unless-stopped + +x-environment: &default-environment + REDIS_URL: redis://redis + DATABASE_URL: "postgresql://packeton:pack123@postgres:5432/packeton?serverVersion=14&charset=utf8" + SKIP_INIT: 1 + +services: + redis: + image: redis:7-alpine + hostname: redis + <<: *restart_policy + volumes: + - redis-data:/data + + postgres: + image: postgres:14-alpine + hostname: postgres + <<: *restart_policy + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: packeton + POSTGRES_PASSWORD: pack123 + POSTGRES_DB: packeton + + php-fpm: + image: packeton/packeton:latest + hostname: php-fpm + command: ['php-fpm', '-F'] + <<: *restart_policy + <<: *default-volume + environment: + <<: *default-environment + SKIP_INIT: 0 + WAIT_FOR_HOST: 'postgres:5432' + depends_on: + - "postgres" + - "redis" + + nginx: + image: packeton/packeton:latest + hostname: nginx + ports: + - '127.0.0.1:8088:80' + <<: *restart_policy + <<: *default-volume + command: > + bash -c 'sed s/_PHP_FPM_HOST_/php-fpm:9000/g < docker/nginx/nginx-tpl.conf > /etc/nginx/nginx.conf && nginx' + environment: + <<: *default-environment + WAIT_FOR_HOST: 'php-fpm:9000' + depends_on: + - "php-fpm" + + worker: + image: packeton/packeton:latest + hostname: packeton-worker + command: ['bin/console', 'packagist:run-workers', '-v'] + user: www-data + <<: *restart_policy + <<: *default-volume + environment: + <<: *default-environment + WAIT_FOR_HOST: 'php-fpm:9000' + depends_on: + - "php-fpm" + + cron: + image: packeton/packeton:latest + hostname: packeton-cron + command: ['bin/console', 'okvpn:cron', '--demand', '--time-limit=3600'] + user: www-data + <<: *restart_policy + <<: *default-volume + environment: + <<: *default-environment + WAIT_FOR_HOST: 'php-fpm:9000' + depends_on: + - "php-fpm" + +volumes: + redis-data: + postgres-data: + app-data: + app-var: + +``` + +## Build and run docker container with docker-compose + +1. Clone repository + +```bash +git clone https://github.com/vtsykun/packeton.git /var/www/packeton/ +cd /var/www/packeton/ +``` + +2. Run `docker-compose build` + +```bash +docker-compose build + +# start container. +docker-compose up -d # Run with single supervisor container +docker-compose up -f docker-compose-prod.yml -d # Or split +``` diff --git a/docs/installation.md b/docs/installation.md index 055d934f..68ac5cbe 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,13 +1,216 @@ -Install and Run in Docker ------------------------- +Install and Run +---------------- -You can use [packeton/packeton](https://hub.docker.com/r/packeton/packeton) image +There is an official packeton image available at [https://hub.docker.com/r/packeton/packeton](https://hub.docker.com/r/packeton/packeton) +which can be used with the docker-compose file, see [docker installation](installation-docker.md) +Installation +------------ + +### Requirements + +- PHP 8.1+ +- Redis for some functionality (favorites, download statistics, worker queue). +- git/svn/hg depending on which repositories you want to support. +- Supervisor to run a background job worker +- (optional) MySQL or PostgresSQL for the main data store, default SQLite + +1. Clone the repository + +```bash +git clone https://github.com/vtsykun/packeton.git /var/www/packeton/ +cd /var/www/packeton/ +``` + +2. Install dependencies `composer install` +3. Create `.env.local` and copy needed environment variables into it. See [Configuration](#configuration) +4. IMPORTANT! Don't forget change `APP_SECRET` +4. Run `bin/console doctrine:schema:create` to setup the DB +5. Create admin user via console. + +```bash +php bin/console packagist:user:manager username --email=admin@example.com --password=123456 --admin +``` + +6. Setup nginx or any webserver, for example nginx config looks like. + +``` +server { + listen *:443 ssl http2; + server_name packeton.example.org; + root /var/www/packeton/public; + + ssl_certificate /etc/nginx/ssl/example.crt; + ssl_certificate_key /etc/nginx/ssl/example.key; + + ssl_ciphers 'TLS13-CHACHA20-POLY1305-SHA256:TLS13-AES-128-GCM-SHA256:TLS13-AES-256-GCM-SHA384:ECDHE:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4'; + + ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + ssl_session_cache builtin:1000 shared:SSL:10m; + ssl_session_timeout 5m; + + rewrite ^/index\.php/?(.+)$ /$1 permanent; + try_files $uri @rewriteapp; + + location @rewriteapp { + rewrite ^(.*)$ /index.php/$1 last; + } + + access_log off; + + location ~ ^/index\.php(/|$) { + fastcgi_split_path_info ^(.+\.php)(/.*)$; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_index index.php; + send_timeout 300; + fastcgi_read_timeout 300; + fastcgi_pass unix:/run/php/php8.1-fpm.sock; + } +} +``` + +7. Change cache permission to made it accessible for web server. + +```bash +chown www-data:www-data -R var/ +``` + +8. If you get a 500 error in index page `packeton.example.org`, please check your logs `var/log/prod.log` or/and webserver log +and fix permissions, database config, redis etc. + +9. Enable cron tabs and background jobs. + +Enable crontab `crontab -e -u www-data` + +``` +* * * * * /var/www/packeton/bin/console --env=prod okvpn:cron >> /dev/null +``` + +**Setup Supervisor to run worker** + +```bash +sudo apt -y --no-install-recommends install supervisor ``` -docker run -d --name packeton \ - --mount type=volume,src=packeton-data,dst=/data \ - -p 8080:80 \ - packeton/packeton:latest + +Create a new supervisor configuration. + +```bash +sudo vim /etc/supervisor/conf.d/packagist.conf ``` +Add the following lines to the file. -etc. +``` +[program:packeton-workers] +environment = + HOME=/var/www/ +command=/var/www/packeton/bin/console packagist:run-workers +directory=/var/www/packeton/ +process_name=%(program_name)s_%(process_num)02d +numprocs=1 +autostart=true +autorestart=true +startsecs=0 +redirect_stderr=true +priority=1 +user=www-data +``` + +### Configuration + +Create a file `.env.local` and change next options + +- `APP_SECRET` - Must be static, used for encrypt SSH keys in database. +- `APP_COMPOSER_HOME` - composer home, default `/var/www/packeton/var/.composer/` +- `DATABASE_URL` - Database DSN, default sqlite:///%kernel.project_dir%/var/app.db + +Example for postgres `postgresql://app:pass@127.0.0.1:5432/app?serverVersion=14&charset=utf8` +Example for mysql `mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8&charset=utf8mb4` + +``` +# .env.local + +DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=14&charset=utf8" +``` + +- `PACKAGIST_DIST_PATH` - Default `%kernel.project_dir%/var/zipball`, path to storage zipped artifacts +- `REDIS_URL` - Redis DB, default `redis://localhost` +- `PACKAGIST_DIST_HOST` - Hostname, (auto) default use the current host header in the request. +- `TRUSTED_PROXIES` - Ips for Reverse Proxy. See [Symfony docs](https://symfony.com/doc/current/deployment/proxies.html) +- `PUBLIC_ACCESS` - Allow anonymous users access to read packages metadata, default: `false` +- `MAILER_DSN` - Mailer for reset password, default disabled +- `MAILER_FROM` - Mailer from + +### Ssh key access and composer oauth token. + +Packagist uses the composer config and global ssh-key to get read access to your repositories, so +the supervisor worker `packagist:run-workers` and web-server must run under the user, +that have ssh key or composer config that gives it read (clone) access to your git/svn/hg repositories. +For example, if your application runs under `www-data` and have home directory `/var/www`, directory +structure must be like this. + +``` + └── /var/www/ + └── .ssh/ # ssh keys directory + ├── config + ├── id_rsa # main ssh key + ├── private_key_2 # additional ssh key + └── private_key_3 + └── packeton/ # project dir + ├── config APP_COMPOSER_HOME="%kernel.project_dir%/var/.composer" + ├── public + .... + ├── src + └── var + ├── cache + .... + └── .composer # APP_COMPOSER_HOME="%kernel.project_dir%/var/.composer" +``` + +By default, composer configuration load from `COMPOSER_HOME` and it placed at path `%kernel.project_dir%/var/.composer`. +if you want to setup authentication in `auth.json` need to place this file to composer home, i e. `/var/www/packeton/var/.composer/` +See [Authentication in auth.json](https://getcomposer.org/doc/articles/authentication-for-private-packages.md#authentication-in-auth-json-per-project) + +``` +# Example /var/www/packeton/var/.composer/auth.json +{ + "http-basic": { + "git.example.pl": { + "username": "kastus", + "password": "489df705a503ac0173256ce01f" + } + } +} +``` + +Example ssh config for multiple SSH Keys for different github account/repos, +see [here for details](https://gist.github.com/jexchan/2351996) + +``` +# ~/.ssh/config - example + +Host github-oroinc + HostName github.com + User git + IdentityFile /var/www/.ssh/private_key_2 + IdentitiesOnly yes + +Host github-org2 + HostName github.com + User git + IdentityFile /var/www/.ssh/private_key_3 + IdentitiesOnly yes + +``` + +#### Allow connections to http. + +You can create `config.json` in the composer home (see `APP_COMPOSER_HOME` env var) or add this option +in the UI credentials form. + +```json +{ + "secure-http": false +} +``` diff --git a/docs/readme.md b/docs/readme.md index eb621061..aa5c1872 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -1,9 +1,9 @@ # Introduction -Packeton - Private PHP package repository for vendors +Packeton - Private PHP package repository for vendors. [GitHub Source Code](https://github.com/vtsykun/packeton) Packeton is an open-source Composer repository homeserver for PHP private packages. -We began development in 2018, bases on Packagist and Satis fork. +We began development in **2018**, bases on Packagist and Satis fork. ## Main Features @@ -16,3 +16,26 @@ We began development in 2018, bases on Packagist and Satis fork. ## Demo See our [Administration Demo](https://demo.packeton.org). Username/password (admin/123456) + +[![Demo](img/intro1.png)](img/intro1.png) + +#### Create Webhooks +[![Webhooks](img/intro2.png)](img/intro2.png) + +#### Webhooks status +[![Webhooks Status](img/intro3.png)](img/intro3.png) + +#### SSH Keys +[![SSH Keys](img/intro4.png)](img/intro4.png) + +#### Submit +[![Submit](img/intro5.png)](img/intro5.png) + +#### Groups +[![Groups](img/intro6.png)](img/intro6.png) + +#### Users view +[![Users view](img/intro7.png)](img/intro7.png) + +#### Package view +[![Package view](img/intro8.png)](img/intro8.png) diff --git a/docs/usage/README.md b/docs/usage/README.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/usage/api.md b/docs/usage/api.md new file mode 100644 index 00000000..e69de29b diff --git a/src/Command/GenerateJWTPairsCommand.php b/src/Command/GenerateJWTPairsCommand.php index 821ec23b..30c9473c 100644 --- a/src/Command/GenerateJWTPairsCommand.php +++ b/src/Command/GenerateJWTPairsCommand.php @@ -15,7 +15,7 @@ class GenerateJWTPairsCommand extends Command { protected static $defaultName = 'packagist:jwt:generate-keypair'; - protected static $defaultDescription = 'Generate JWT public/private keys for use in API authorizations'; + protected static $defaultDescription = 'Generate JWT public/private keys for packeton jwt_authentication'; public function __construct( protected Filesystem $filesystem, @@ -30,7 +30,6 @@ public function __construct( */ protected function configure(): void { - $this->addOption('only-if-not-exists', null, InputOption::VALUE_NONE, 'Do not update key files if they already exist.'); $this->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite key files if they already exist.'); $this->addOption('algo', null, InputOption::VALUE_OPTIONAL, 'Key sign algo, default config value.', $this->jwtSignAlgorithm); } diff --git a/src/Controller/PackageController.php b/src/Controller/PackageController.php index 06619d20..4db29b18 100644 --- a/src/Controller/PackageController.php +++ b/src/Controller/PackageController.php @@ -101,7 +101,7 @@ public function listAction(Request $req) } elseif ($req->query->get('vendor')) { $names = $repo->getPackageNamesByVendor($req->query->get('vendor')); } else { - $names = $this->providerManager->getPackageNames(); + $names = $repo->getPackageNames(); } return new JsonResponse(['packageNames' => $names]); diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 5e697dc0..9cf1beb2 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -75,6 +75,10 @@ public function showAction(Request $request) public function editAction(Request $request) { $user = $this->getUser(); + if (!$user instanceof User) { + throw $this->createNotFoundException(); + } + $form = $this->createForm(ProfileFormType::class, $user); $form->handleRequest($request); @@ -99,6 +103,10 @@ public function changePasswordAction(Request $request) { /** @var User $user */ $user = $this->getUser(); + if (!$user instanceof User) { + throw $this->createNotFoundException(); + } + $form = $this->createForm(ChangePasswordFormType::class, $user); $form->handleRequest($request); diff --git a/src/Controller/WebhookController.php b/src/Controller/WebhookController.php index deb874e3..80b6959f 100644 --- a/src/Controller/WebhookController.php +++ b/src/Controller/WebhookController.php @@ -8,6 +8,7 @@ use Packeton\Attribute\Vars; use Packeton\Entity\Job; use Packeton\Entity\Package; +use Packeton\Entity\User; use Packeton\Entity\Webhook; use Packeton\Form\Type\WebhookType; use Packeton\Webhook\HookResponse; @@ -43,6 +44,7 @@ public function __construct( */ public function indexAction(Request $request) { + $user = $this->getUser(); $qb = $this->registry ->getRepository(Webhook::class) ->createQueryBuilder('w') @@ -51,7 +53,7 @@ public function indexAction(Request $request) $qb->where('w.owner IS NULL') ->orWhere("w.visibility = 'global'") ->orWhere("w.visibility = 'user' AND IDENTITY(w.owner) = :owner") - ->setParameter('owner', $this->getUser()->getId()); + ->setParameter('owner', $user instanceof User ? $user->getId() : null); /** @var Webhook[] $webhooks */ $webhooks = $qb->getQuery()->getResult(); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 9d17c0ba..41c2d55c 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -32,7 +32,11 @@ public function getConfigTreeBuilder() ->end() ->arrayNode('jwt_authentication') ->children() - ->enumNode('algo')->defaultNull()->values(array_keys(JWT::$supported_algs))->end() + ->enumNode('algo') + ->info("Sign algo, default EdDSA libsodium") + ->defaultNull() + ->values(array_keys(JWT::$supported_algs)) + ->end() ->scalarNode('private_key')->cannotBeEmpty()->end() ->scalarNode('public_key')->cannotBeEmpty()->end() ->booleanNode('passphrase')->defaultNull()->end() diff --git a/src/DependencyInjection/PacketonExtension.php b/src/DependencyInjection/PacketonExtension.php index 10007b75..2a63c7ba 100644 --- a/src/DependencyInjection/PacketonExtension.php +++ b/src/DependencyInjection/PacketonExtension.php @@ -29,7 +29,7 @@ public function load(array $configs, ContainerBuilder $container) $container->setParameter('packeton_github_no_api', $config['github_no_api'] ?? false); $container->setParameter('packeton_jws_config', $config['jwt_authentication'] ?? []); - $container->setParameter('packeton_jws_algo', $config['jwt_authentication']['algo'] ?? 'ES256'); + $container->setParameter('packeton_jws_algo', $config['jwt_authentication']['algo'] ?? 'EdDSA'); } /** diff --git a/src/Menu/MenuBuilder.php b/src/Menu/MenuBuilder.php index 88b579fe..5dcea068 100644 --- a/src/Menu/MenuBuilder.php +++ b/src/Menu/MenuBuilder.php @@ -4,37 +4,19 @@ use Knp\Menu\FactoryInterface; use Knp\Menu\ItemInterface; +use Packeton\Entity\User; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Contracts\Translation\TranslatorInterface; class MenuBuilder { - private $factory; - private $username; - private $translator; - private $checker; - - /** - * @param FactoryInterface $factory - * @param TokenStorageInterface $tokenStorage - * @param TranslatorInterface $translator - * @param AuthorizationCheckerInterface $checker - */ public function __construct( - FactoryInterface $factory, - TokenStorageInterface $tokenStorage, - TranslatorInterface $translator, - AuthorizationCheckerInterface $checker - ) { - $this->factory = $factory; - $this->translator = $translator; - $this->checker = $checker; - - if ($tokenStorage->getToken() && $tokenStorage->getToken()->getUser()) { - $this->username = $tokenStorage->getToken()->getUser()->getUsername(); - } - } + private readonly FactoryInterface $factory, + private readonly TokenStorageInterface $tokenStorage, + private readonly TranslatorInterface $translator, + private readonly AuthorizationCheckerInterface $checker + ) {} public function createUserMenu() { @@ -73,12 +55,26 @@ public function createAdminMenu() private function addProfileMenu(ItemInterface $menu) { + $user = $this->tokenStorage->getToken() ? $this->tokenStorage->getToken()->getUser() : null; $menu->addChild($this->translator->trans('menu.profile'), ['label' => '' . $this->translator->trans('menu.profile'), 'route' => 'profile_show', 'extras' => ['safe_label' => true]]); - $menu->addChild($this->translator->trans('menu.settings'), ['label' => '' . $this->translator->trans('menu.settings'), 'route' => 'profile_edit', 'extras' => ['safe_label' => true]]); - $menu->addChild($this->translator->trans('menu.change_password'), ['label' => '' . $this->translator->trans('menu.change_password'), 'route' => 'change_password', 'extras' => ['safe_label' => true]]); - if ($this->checker->isGranted('ROLE_MAINTAINER')) { - $menu->addChild($this->translator->trans('menu.my_packages'), ['label' => '' . $this->translator->trans('menu.my_packages'), 'route' => 'user_packages', 'routeParameters' => ['name' => $this->username], 'extras' => ['safe_label' => true]]); - $menu->addChild($this->translator->trans('menu.my_favorites'), ['label' => '' . $this->translator->trans('menu.my_favorites'), 'route' => 'user_favorites', 'routeParameters' => ['name' => $this->username], 'extras' => ['safe_label' => true]]); + + if ($user instanceof User) { + $menu->addChild($this->translator->trans('menu.settings'), ['label' => '' . $this->translator->trans('menu.settings'), 'route' => 'profile_edit', 'extras' => ['safe_label' => true]]); + $menu->addChild($this->translator->trans('menu.change_password'), ['label' => '' . $this->translator->trans('menu.change_password'), 'route' => 'change_password', 'extras' => ['safe_label' => true]]); + + if ($this->checker->isGranted('ROLE_MAINTAINER')) { + $menu->addChild($this->translator->trans('menu.my_packages'), ['label' => '' . $this->translator->trans('menu.my_packages'), 'route' => 'user_packages', 'routeParameters' => ['name' => $this->getUsername()], 'extras' => ['safe_label' => true]]); + $menu->addChild($this->translator->trans('menu.my_favorites'), ['label' => '' . $this->translator->trans('menu.my_favorites'), 'route' => 'user_favorites', 'routeParameters' => ['name' => $this->getUsername()], 'extras' => ['safe_label' => true]]); + } } } + + private function getUsername() + { + if ($this->tokenStorage->getToken() && $this->tokenStorage->getToken()->getUser()) { + return $this->tokenStorage->getToken()->getUser()->getUserIdentifier(); + } + + return null; + } } diff --git a/src/Security/Acl/OwnerVoter.php b/src/Security/Acl/OwnerVoter.php index c3e7c2f9..1d08edd1 100644 --- a/src/Security/Acl/OwnerVoter.php +++ b/src/Security/Acl/OwnerVoter.php @@ -14,13 +14,13 @@ class OwnerVoter implements VoterInterface */ public function vote(TokenInterface $token, $object, array $attributes) { - if (!$object instanceof OwnerAwareInterface) { + if (!$object instanceof OwnerAwareInterface || !in_array('VIEW', $attributes)) { return self::ACCESS_ABSTAIN; } $user = $token->getUser(); if (!$user instanceof User) { - return self::ACCESS_DENIED; + return $object->getVisibility() === OwnerAwareInterface::GLOBAL_VISIBLE ? self::ACCESS_GRANTED : self::ACCESS_DENIED; } if ($object->getVisibility() === OwnerAwareInterface::USER_VISIBLE && $object->getOwner() && $object->getOwner()->getId() !== $user->getId()) { diff --git a/src/Security/Api/ApiTokenAuthenticator.php b/src/Security/Api/ApiTokenAuthenticator.php index 02cb8bc6..3402650a 100644 --- a/src/Security/Api/ApiTokenAuthenticator.php +++ b/src/Security/Api/ApiTokenAuthenticator.php @@ -85,7 +85,8 @@ public function validateAndLoadUser(string $username, string $token): UserInterf try { // Check JWT token user. $loadedUser = $this->tokenUserManager->loadUserFromToken($token); - if ($loadedUser->isEqualTo($user)) { + // Compare only attributes. If $user instance of User, then return database instance. + if ($loadedUser->isEqualUserAttributes($user)) { return $user instanceof User ? $user : $loadedUser; } } catch (\Exception $e) { @@ -142,10 +143,12 @@ private function getCredentials(Request $request) protected function retrieveUser(string $username, string $token): UserInterface { - $preFetchUser = null; + $preFetchUser = $tokenIsValid = null; $userJwtCacheKey = 'jwt-user-' . sha1($username . "\x00" . $token); + if ($useJwtUser = $this->tokenUserManager->checkTokenFormat($token)) { $preFetchUser = $this->fastPrefetchJwtUser($userJwtCacheKey, $username); + $tokenIsValid = $this->tokenUserManager->isValidJWTToken($token); } try { @@ -159,7 +162,7 @@ protected function retrieveUser(string $username, string $token): UserInterface if (!$user instanceof User && true === $useJwtUser) { $user = $this->tokenUserManager->convertToJwtUser($user); - if ($preFetchUser === null) { + if ($preFetchUser === null && true === $tokenIsValid) { $this->serializeJwtUser($userJwtCacheKey, $user); } } diff --git a/src/Security/JWTUserManager.php b/src/Security/JWTUserManager.php index 01f7fab7..77a882ca 100644 --- a/src/Security/JWTUserManager.php +++ b/src/Security/JWTUserManager.php @@ -36,6 +36,15 @@ public function loadUserFromToken(string $token): JWTUser return new JWTUser($username, $roles); } + public function isValidJWTToken(string $token): bool + { + try { + return (bool) $this->loadUserFromToken($token); + } catch (\Throwable) {} + + return false; + } + public function checkTokenFormat(?string $token): bool { return $token && str_starts_with($token, self::JWT_PREFIX) && strlen($token) > 32;