diff --git a/bin/podman_shared b/bin/podman_shared index 4da85b6f..3362e252 100644 --- a/bin/podman_shared +++ b/bin/podman_shared @@ -1,324 +1,327 @@ #!/bin/bash PODMAN=podman podman__build() { path=$1 shift name=$1 shift if [[ "$CACHE_REGISTRY" != "" ]]; then CACHE_ARGS="--layers --cache-from=$CACHE_REGISTRY/$name --cache-to=$CACHE_REGISTRY/$name --cache-ttl=24h" fi podman build $@ $CACHE_ARGS $path -t $name } podman__build_base() { podman__build docker/base/ apheleia/almalinux9 -f almalinux9 podman__build docker/swoole apheleia/swoole } podman__build_webapp() { podman__build docker/webapp kolab-webapp --ulimit nofile=65535:65535 \ ${KOLAB_GIT_REMOTE:+"--build-arg=GIT_REMOTE=$KOLAB_GIT_REMOTE"} \ ${KOLAB_GIT_REF:+"--build-arg=GIT_REF=$KOLAB_GIT_REF"} } podman__build_meet() { podman__build docker/meet kolab-meet --ulimit nofile=65535:65535 \ ${KOLAB_GIT_REMOTE:+"--build-arg=GIT_REMOTE=$KOLAB_GIT_REMOTE"} \ ${KOLAB_GIT_REF:+"--build-arg=GIT_REF=$KOLAB_GIT_REF"} } podman__build_roundcube() { podman__build docker/roundcube roundcube --ulimit nofile=65535:65535 \ ${GIT_REMOTE_ROUNDCUBEMAIL:+"--build-arg=GIT_REMOTE_ROUNDCUBEMAIL=$GIT_REMOTE_ROUNDCUBEMAIL"} \ ${GIT_REF_ROUNDCUBEMAIL:+"--build-arg=GIT_REF_ROUNDCUBEMAIL=$GIT_REF_ROUNDCUBEMAIL"} \ ${GIT_REMOTE_ROUNDCUBEMAIL_PLUGINS:+"--build-arg=GIT_REMOTE_ROUNDCUBEMAIL_PLUGINS=$GIT_REMOTE_ROUNDCUBEMAIL_PLUGINS"} \ ${GIT_REF_ROUNDCUBEMAIL_PLUGINS:+"--build-arg=GIT_REF_ROUNDCUBEMAIL_PLUGINS=$GIT_REF_ROUNDCUBEMAIL_PLUGINS"} \ ${GIT_REMOTE_CHWALA:+"--build-arg=GIT_REMOTE_CHWALA=$GIT_REMOTE_CHWALA"} \ ${GIT_REF_CHWALA:+"--build-arg=GIT_REF_CHWALA=$GIT_REF_CHWALA"} \ ${GIT_REMOTE_SYNCROTON:+"--build-arg=GIT_REMOTE_SYNCROTON=$GIT_REMOTE_SYNCROTON"} \ ${GIT_REF_SYNCROTON:+"--build-arg=GIT_REF_SYNCROTON=$GIT_REF_SYNCROTON"} \ ${GIT_REMOTE_AUTOCONF:+"--build-arg=GIT_REMOTE_AUTOCONF=$GIT_REMOTE_AUTOCONF"} \ ${GIT_REF_AUTOCONF:+"--build-arg=GIT_REF_AUTOCONF=$GIT_REF_AUTOCONF"} \ ${GIT_REMOTE_IRONY:+"--build-arg=GIT_REMOTE_IRONY=$GIT_REMOTE_IRONY"} \ ${GIT_REF_IRONY:+"--build-arg=GIT_REF_IRONY=$GIT_REF_IRONY"} \ ${GIT_REMOTE_FREEBUSY:+"--build-arg=GIT_REMOTE_FREEBUSY=$GIT_REMOTE_FREEBUSY"} \ ${GIT_REF_FREEBUSY:+"--build-arg=GIT_REF_FREEBUSY=$GIT_REF_FREEBUSY"} } podman__build_postfix() { podman__build docker/postfix kolab-postfix } podman__build_imap() { podman__build docker/imap kolab-imap \ ${IMAP_GIT_REMOTE:+"--build-arg=GIT_REMOTE=$IMAP_GIT_REMOTE"} \ ${IMAP_GIT_REF:+"--build-arg=GIT_REF=$IMAP_GIT_REF"} } podman__build_amavis() { podman__build docker/amavis kolab-amavis } podman__build_proxy() { podman__build docker/proxy kolab-proxy } podman__build_collabora() { podman build docker/collabora -t kolab-collabora --build-arg=REPOSITORY="https://www.collaboraoffice.com/repos/CollaboraOnline/23.05-CODE/CODE-rpm/" } podman__build_coturn() { podman build docker/coturn -t kolab-coturn } podman__build_utils() { podman build docker/utils -t kolab-utils } podman__build_all() { podman__build_base podman__build_webapp podman__build_meet podman__build_postfix podman__build_imap podman__build_amavis podman__build_collabora podman build docker/mariadb -t mariadb podman build docker/redis -t redis podman__build_proxy podman__build_coturn podman__build_utils podman build docker/fluentbit -t fluentbit podman build docker/synapse -t synapse podman build docker/element -t element podman__build_roundcube } kolab__validate() { POD=$1 $PODMAN exec $POD-imap testsaslauthd -u cyrus-admin -p simple123 $PODMAN exec $POD-imap testsaslauthd -u "john@kolab.org" -p simple123 # Ensure the inbox is created FOUND=false for i in {1..60}; do if $PODMAN exec $POD-imap bash -c 'echo "lm" | cyradm --auth PLAIN -u cyrus-admin -w simple123 --port 11143 localhost | grep "user/john@kolab.org"'; then echo "Found mailbox"; FOUND=true break else echo "Waiting for mailbox"; sleep 1; fi done if ! $FOUND; then echo "Failed to find the inbox for john@kolab.org" exit 1 fi } podman__is_ready() { if [[ "$(timeout 5 podman wait --condition running $1)" != "-1" ]]; then echo "Container $1 is not running" return 1 fi # We can only wait for healthy if healthcheck is available return 0 } podman__healthcheck() { for CONTAINER in $@; do echo "Waiting for ${CONTAINER} become healthy " while [ $(podman healthcheck run ${CONTAINER}) ]; do echo -n "."; sleep 5; done done } podman__run_proxy() { $PODMAN run -dt --pod $POD --name $POD-proxy --replace \ -v $CERTS_PATH:/etc/certs:ro \ -v /etc/letsencrypt/:/etc/letsencrypt/:ro \ -e APP_WEBSITE_DOMAIN \ -e SSL_CERTIFICATE=${KOLAB_SSL_CERTIFICATE} \ -e SSL_CERTIFICATE_KEY=${KOLAB_SSL_CERTIFICATE_KEY} \ -e WEBAPP_BACKEND="http://localhost:8000" \ -e MEET_BACKEND="http://localhost:12080" \ -e ROUNDCUBE_BACKEND="http://localhost:8080" \ -e DAV_BACKEND="http://localhost:11080/dav" \ -e COLLABORA_BACKEND="http://localhost:9980" \ -e MATRIX_BACKEND="http://localhost:8008" \ -e ELEMENT_BACKEND="http://localhost:8880" \ -e SIEVE_BACKEND="localhost:4190" \ kolab-proxy:latest } podman__run_roundcube() { $PODMAN run -dt --pod $POD --name $POD-roundcube --replace \ -v ./ext:/src.orig:ro \ -e APP_DOMAIN \ -e DES_KEY \ -e DB_HOST \ -e DB_RC_DATABASE="roundcube" \ -e DB_RC_USERNAME="roundcube" \ -e DB_RC_PASSWORD="${DB_PASSWORD:?"missing env variable"}" \ -e IMAP_HOST=127.0.0.1 \ -e IMAP_PORT=11143 \ -e IMAP_ADMIN_LOGIN \ -e IMAP_ADMIN_PASSWORD \ -e SUBMISSION_HOST=127.0.0.1 \ -e SUBMISSION_ENCRYPTION=starttls \ -e SUBMISSION_PORT=10587 \ -e IMAP_DEBUG \ -e LOG_DRIVER=stdout \ -e KOLAB_FILES_SERVER_URL=http://localhost:8080/chwala \ -e FILEAPI_WOPI_OFFICE=http://localhost:9980 \ -e FILEAPI_KOLABFILES_BASEURI=http://localhost:8000/api \ -e FILE_API_SERVER_URL=http://localhost:8080/chwala/api/ \ -e KOLAB_ADDRESSBOOK_CARDDAV_SERVER=http://localhost:11080/dav \ -e CALENDAR_CALDAV_SERVER=http://localhost:11080/dav \ -e TASKLIST_CALDAV_SERVER=http://localhost:11080/dav \ roundcube:latest } podman__run_mariadb() { $PODMAN run -dt --pod $POD --name $POD-mariadb --replace \ $MARIADB_STORAGE \ -e MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:?"missing env variable"} \ -e TZ="+02:00" \ -e DB_HKCCP_DATABASE="kolabdev" \ -e DB_HKCCP_USERNAME="kolabdev" \ -e DB_HKCCP_PASSWORD=${DB_PASSWORD:?"missing env variable"} \ -e DB_KOLAB_DATABASE="kolab" \ -e DB_KOLAB_USERNAME="kolab" \ -e DB_KOLAB_PASSWORD=${DB_PASSWORD:?"missing env variable"} \ -e DB_RC_DATABASE="roundcube" \ -e DB_RC_USERNAME="roundcube" \ -e DB_RC_PASSWORD=${DB_PASSWORD:?"missing env variable"} \ --health-cmd "mysqladmin -u root ping && test -e /tmp/initialized" \ mariadb:latest } podman__run_redis() { $PODMAN run -dt --pod $POD --name $POD-redis --replace \ $REDIS_STORAGE \ --health-cmd "redis-cli ping || exit 1" \ redis:latest } podman__run_minio() { $PODMAN run -dt --pod $POD --name $POD-minio --replace \ $MINIO_STORAGE \ -e MINIO_ROOT_USER=${MINIO_USER:?"missing env variable"} \ -e MINIO_ROOT_PASSWORD=${MINIO_PASSWORD:?"missing env variable"} \ --health-cmd "mc ready local || exit 1" \ --entrypoint sh \ quay.io/minio/minio:latest -c 'mkdir -p /data/kolab && minio server /data --console-address ":9001"' } podman__run_webapp() { # We run with a fixed config.demo overlay and override the environment with ci/env $PODMAN run -dt --pod $POD --name $POD-webapp --replace \ --env-file=$1 \ -v ./src:/src/kolabsrc.orig:ro \ -v ./$2/src:/src/overlay:ro \ -e NOENVFILE=true \ -e APP_SERVICES_ALLOWED_DOMAINS="webapp,localhost,services.$HOST" \ -e KOLAB_ROLE=combined \ -e PASSPORT_PRIVATE_KEY="$PASSPORT_PRIVATE_KEY" \ -e PASSPORT_PUBLIC_KEY="$PASSPORT_PUBLIC_KEY" \ -e MINIO_ENDPOINT="http://localhost:9000" \ -e MEET_SERVER_URLS="http://127.0.0.1:12080/meetmedia/api/" \ -e MEET_SERVER_VERIFY_TLS=false \ --health-cmd "./artisan octane:status || exit 1" \ kolab-webapp:latest } podman__run_imap() { $PODMAN run -dt --pod $POD --name $POD-imap --replace \ $IMAP_SPOOL_STORAGE \ $IMAP_LIB_STORAGE \ -e APP_SERVICES_DOMAIN="localhost" \ -e SERVICES_PORT=8000 \ -e IMAP_ADMIN_LOGIN \ -e IMAP_ADMIN_PASSWORD \ --health-cmd "test -e /run/saslauthd/mux && kill -0 \$(cat /var/run/master.pid)" \ kolab-imap:latest } podman__run_postfix() { $PODMAN run -dt --pod $POD --name $POD-postfix --replace \ --privileged \ $POSTFIX_SPOOL_STORAGE \ $POSTFIX_LIB_STORAGE \ -v $CERTS_PATH:/etc/certs:ro \ -v /etc/letsencrypt/:/etc/letsencrypt/:ro \ -e SSL_CERTIFICATE="$KOLAB_SSL_CERTIFICATE" \ -e SSL_CERTIFICATE_FULLCHAIN="$KOLAB_SSL_CERTIFICATE_FULLCHAIN" \ -e SSL_CERTIFICATE_KEY="$KOLAB_SSL_CERTIFICATE_KEY" \ -e APP_DOMAIN \ -e APP_SERVICES_DOMAIN="localhost" \ -e SERVICES_PORT=8000 \ -e AMAVIS_HOST=127.0.0.1 \ -e DB_HOST=127.0.0.1 \ -e DB_USERNAME \ -e DB_PASSWORD \ -e DB_DATABASE \ -e LMTP_DESTINATION="localhost:11024" \ --health-cmd "test -e /run/saslauthd/mux && kill -0 \$(cat /var/spool/postfix/pid/master.pid)" \ kolab-postfix:latest } podman__run_amavis() { $PODMAN run -dt --pod $POD --name $POD-amavis --replace \ -e APP_DOMAIN \ -e POSTFIX_HOST=localhost \ -e DB_HOST=localhost \ -e DB_USERNAME \ -e DB_PASSWORD \ -e DB_DATABASE \ kolab-amavis:latest } podman__run_collabora() { $PODMAN run -dt --pod $POD --name $POD-collabora --replace \ --privileged \ -e ALLOWED_HOSTS=${APP_DOMAIN} \ kolab-collabora:latest } podman__run_synapse() { $PODMAN run -dt --pod $POD --name $POD-synapse --replace \ - $SYNAPSE_STORAGE \ + $SYNAPSE_STORAGE \ + -v $CERTS_PATH:/etc/certs:ro \ -e APP_DOMAIN \ -e KOLAB_URL="http://127.0.0.1:8000" \ + -e SYNAPSE_OAUTH_CLIENT_ID="${PASSPORT_SYNAPSE_OAUTH_CLIENT_ID:?"missing env variable"}" \ + -e SYNAPSE_OAUTH_CLIENT_SECRET="${PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET:?"missing env variable"}" \ synapse:latest } podman__run_element() { $PODMAN run -dt --pod $POD --name $POD-element --replace \ -e APP_DOMAIN \ element:latest } podman__run_meet() { $PODMAN run -dt --pod $POD --name $POD-meet --replace \ -v ./meet/server:/src/meet:ro \ -e WEBRTC_LISTEN_IP=0.0.0.0 \ -e WEBRTC_ANNOUNCED_ADDRESS=${PUBLIC_IP:?"missing env variable"} \ -e PUBLIC_DOMAIN=$APP_DOMAIN \ -e LISTENING_HOST=127.0.0.1 \ -e LISTENING_PORT=12080 \ -e DEBUG="*" \ -e TURN_SERVER=none \ -e AUTH_TOKEN=${MEET_SERVER_TOKEN} \ -e WEBHOOK_TOKEN=${MEET_WEBHOOK_TOKEN} \ -e WEBHOOK_URL=$APP_DOMAIN/api/webhooks/meet \ -e SSL_CERT=none \ -e FORCE_WSS=true \ kolab-meet:latest } diff --git a/ci/env b/ci/env index e8d5530e..ddb34895 100644 --- a/ci/env +++ b/ci/env @@ -1,174 +1,176 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=https://kolab.local APP_PUBLIC_URL=https://kolab.local APP_DOMAIN=kolab.local APP_WEBSITE_DOMAIN=kolab.local APP_THEME=default APP_TENANT_ID=5 APP_LOCALE=en APP_LOCALES= APP_WITH_ADMIN=1 APP_WITH_RESELLER=1 APP_WITH_SERVICES=1 APP_WITH_FILES=1 APP_WITH_WALLET=1 APP_WITH_SIGNUP=1 APP_LDAP=0 APP_IMAP=1 APP_HEADER_CSP="connect-src 'self'; child-src 'self'; font-src 'self'; form-action 'self' data:; frame-ancestors 'self'; img-src blob: data: 'self' *; media-src 'self';" APP_HEADER_XFO=sameorigin ASSET_URL=https://kolab.local WEBMAIL_URL=/roundcubemail/ SUPPORT_URL=/support LOG_CHANNEL=stdout LOG_SLOW_REQUESTS=5 LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=simple123 DB_ROOT_PASSWORD=simple123 DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=redis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 MFA_DSN=mysql://roundcube:simple123@127.0.0.1/roundcube MFA_TOTP_DIGITS=6 MFA_TOTP_INTERVAL=30 MFA_TOTP_DIGEST=sha1 IMAP_URI=localhost:11143 IMAP_HOST=localhost IMAP_PORT=11143 IMAP_GUAM_PORT=11143 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=simple123 IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false IMAP_WITH_GROUPWARE_DEFAULT_FOLDERS=false SMTP_HOST=localhost SMTP_PORT=10587 MEET_SERVER_URLS=https://127.0.0.1:6443/meetmedia/api/ MEET_SERVER_VERIFY_TLS=false MEET_WEBRTC_LISTEN_IP='127.0.0.1' MEET_PUBLIC_DOMAIN=kolab.local MEET_LISTENING_HOST=127.0.0.1 PGP_ENABLE=true PGP_BINARY=/usr/bin/gpg PGP_AGENT=/usr/bin/gpg-agent PGP_GPGCONF=/usr/bin/gpgconf PGP_LENGTH= REDIS_HOST=localhost REDIS_PASSWORD=null REDIS_PORT=6379 OCTANE_HTTP_HOST=kolab.local SWOOLE_PACKAGE_MAX_LENGTH=10485760 MAIL_MAILER=smtp MAIL_HOST=localhost MAIL_PORT=587 MAIL_USERNAME="noreply@kolab.local" MAIL_PASSWORD="simple123" MAIL_ENCRYPTION=starttls MAIL_FROM_ADDRESS="noreply@kolab.local" MAIL_FROM_NAME="kolab.local" MAIL_REPLYTO_ADDRESS="noreply@kolab.local" MAIL_REPLYTO_NAME=null MAIL_VERIFY_PEER='false' RATELIMIT_WHITELIST="noreply@kolab.local" DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null MIX_ASSET_PATH='/' PASSWORD_POLICY= COMPANY_NAME=kolab.org COMPANY_ADDRESS= COMPANY_DETAILS= COMPANY_EMAIL= COMPANY_LOGO= COMPANY_FOOTER= VAT_COUNTRIES=CH,LI VAT_RATE=7.7 KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= KB_PAYMENT_SYSTEM= KOLAB_SSL_CERTIFICATE=/etc/certs/kolab.local.cert KOLAB_SSL_CERTIFICATE_FULLCHAIN=/etc/certs/kolab.local.chain.pem KOLAB_SSL_CERTIFICATE_KEY=/etc/certs/kolab.local.key OPENEXCHANGERATES_API_KEY= FIREBASE_API_KEY= MINIO_ENDPOINT=http://localhost:9000 MINIO_USER=minio MINIO_PASSWORD=simple123 MINIO_BUCKET=kolab FILESYSTEM_DISK=minio TRUSTED_PROXIES="172.18.0.7/8,127.0.0.1/8" MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= APP_PASSPHRASE=simple123 MEET_WEBHOOK_TOKEN=simple123 MEET_SERVER_TOKEN=simple123 APP_KEY=base64:EFXja/fHF01EMKiXW200b5zWOynbPzAHfUM78bOp+28= PASSPORT_PROXY_OAUTH_CLIENT_ID=5909ca4f-df7e-45fe-b355-e7c195aef117 PASSPORT_PROXY_OAUTH_CLIENT_SECRET=3URb+3JGJM9wPuDnlUSTPOw2mqmHsoOV8NXanx9xwQM= +PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=2909ca4f-df7e-45fe-b355-e7c195aef112 +PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=2URb+3JGJM9wPuDnlUSTPOw2mqmHsoOV8NXanx9xwQM= DES_KEY=kBxUM/53N9p9abusAoT0ZEAxwI2pxFz/ KOLAB_GIT_REF=master KOLAB_GIT_REMOTE=https://git.kolab.org/source/kolab GIT_REF_ROUNDCUBEMAIL=dev/kolab-1.5 GIT_REMOTE_ROUNDCUBEMAIL=https://git.kolab.org/source/roundcubemail.git GIT_REF_ROUNDCUBEMAIL_PLUGINS=master GIT_REMOTE_ROUNDCUBEMAIL_PLUGINS=https://git.kolab.org/diffusion/RPK/roundcubemail-plugins-kolab.git GIT_REF_CHWALA=master GIT_REMOTE_CHWALA=https://git.kolab.org/diffusion/C/chwala.git GIT_REF_SYNCROTON=master GIT_REMOTE_SYNCROTON=https://git.kolab.org/diffusion/S/syncroton.git GIT_REF_AUTOCONF=master GIT_REMOTE_AUTOCONF=https://git.kolab.org/diffusion/AC/autoconf.git GIT_REF_IRONY=master GIT_REMOTE_IRONY=https://git.kolab.org/source/iRony.git GIT_REF_FREEBUSY=master GIT_REMOTE_FREEBUSY=https://git.kolab.org/diffusion/F/freebusy.git IMAP_GIT_REF=dev/kolab-3.6 IMAP_GIT_REMOTE=https://git.kolab.org/source/cyrus-imapd diff --git a/ci/testctl b/ci/testctl index 244bd65f..e3e9fe87 100755 --- a/ci/testctl +++ b/ci/testctl @@ -1,452 +1,453 @@ #!/bin/bash base_dir="$(dirname $(realpath "$0"))" pushd "${base_dir}" pushd .. set -e PASSPORT_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQCmYeRp7XXnPe8w X0iOJRpeskfUuOJ/Gqz5dsMIWFB6fPaI5/9tkMEyp+vCEF7eFXLBrXeQi6F/VNmV wn+dGEQhkhuDoEXr8Z4c333wLH8iOEF4WQbt/WF3ERdjmJt3vKry8B/OLNmmcK7j 4sz828h6L2ZT6GPcbGsNukxBMcIMOpflo0SLHy4VThdo6b1Q4nD2K/PX1ypyfFao nj3OfHBdSVLmTgd7BvB/azYFYWHP4INY8cylZWItDXuqPlBGSU2ff2xTKY/WRco/ djvrO9bM1WeI+8W36EeLHERru1QRpN22TgWCQ2dbLRsVrsMg8Ly6SMe8ceDXQt5C LKAN24jFt1UnBgr+qK1TrxkBtu5+V2WPYWhUvBLI/2qnFQh1GiWMKinWQO7rFCIC rRUcQBUu2AylmG0P/oPjPrjhAnxq3HguOn8cS1OeBpOH7+8tz0CeEdyVfT8maVs/ VWRZbEb0UjFLRNU+iVEGzz3jyQuKhOJ/2WuW0mJzF3pPQ64Dl+fLyXqF1KXNoPem evmmRjCZWfkWAEAWd3+yRfoOxGz55vaU1qGS81lnXnP1R5TZGXon24HHS9uRwHt6 JII+FEwgqr8K2TISDPxx7iQbXx8kcMUMBJG8aNoG73WVXmHs0uaEUsXMy9vtegeu //IPpNUTlbjsn8Ot+t68mTNLUZX74wIDAQABAoICAE5fZT8KVlPfJiikcWJXktTR aKmIj1Qs5ha6PQNUyk/wRhbWJUjge0jXtWNb37v/4WbexafGRgPbHYUAMal3kTw4 /RHi8JzD2uUh10pHQ3mEgz5jvTJkfMEfwWMuMulTazj1KB4vnTRb9t2saz+ebZA0 fKCAom1leoXkX+ADxrKI9Rz766EWxlfNyZQnKgCMMYabzIg6t6lm7VEO/PEjR7CB hfWrArYOXkG+6BrftLm9OVGv0GSGXZj4NWzLXnfFNrWvSYDg3nqhtDNxh6b2MGeb DGKHqipHVU/vOEGA44hOHwutM8YY5voZRJ1RjWOaUmPzPXaEM9NiEZydNaVhaEpq m7jNpu7S5xa2Eodt2iz2uQhnDHrYnGVCH5psal6TZAo9APWwwBOsFQ+nXwjxTeL9 +3JL6+jrP0eqzNVhl8c0cHJnBDpSVNG734RsK8XOxmJyq3Xt8Roi3Ud7gjy/FGpv XgzDpkFvd5uETn1VIuAfirm7MD8RbTIZAWCgqCrE7NuXOcnBGHuC955KF8OAx8np 8yCtlmBSXKifoIeeyu32L8s3g7md+xRuaU8yRtuClTLKG+6oRZYcaFNcVKKZzyu5 xnxUS6Haphd5/LhgnA3ujXkkNPdmHxPvJOWYABSNFeXzNF1npL/4wFLNvppMCPR1 v7M7AnbvyEvKm1Q2ePe9AoIBAQDigI4AJIaHeQiuqFSIWhm8NYkOZF0jfvWM7K8v 1IAE0WATP8KbeTINS2fUYZrNFs7S66Pl1WdPH7atVoi7QVcIoFhlYYRqILETpKJr z0dFLIiaajzQ9kTPzhLRDGBhO3TKb7RpFndYAuxzSw1C/3JHb4crD8kDIB8xVoba xvsXdVssqBQgScUrj1Ff4ZPtFhqLPsWnvdBpbM6LV/2t/CnTu4qU2szJZQNGP1Qf gEapbuZC6YFahXDTgYFTfn/vKzyKb/Fiskz3Rs9jgY08gRxIandeUqJIEoJi+CwZ q6twD8qKzGhB9nxSAOwhJzDg4SyhNnRQt5X8XQWVjpxs3HxnAoIBAQC8DPsIDN5r 7joZj5d4/k8Yg+q1ecySm9zYy9Lzf0WUFgRu9NW9UeUPRjGXhNo5VOxxB62rMZCJ E81ItxUVQwHH4S62ycBPbsYEapE/itS+KdEzWQP2u3HAkLD3N28snMlIhTJR8fXB GasWngs9Q7uB7Wk0niKa8T7fBDx9pOyjMlIPwo0lZCrUAnmjOgZ+RvvuGDgqpDdp h7JUxtFmsWPgBFNZtr5BTRcr5hWRoSXJgQODqpTQHjQddMWy7LCJg3qKLiKVIOd5 +iGzhUIZzo95FYiyt8Ojdt3Y0k5J99NOrOwAPNLvbC5TTshtA144E9uwEqBbTm+S RtLZeVBWZ1clAoIBAQC0j26jxnpH/MBjG2Vn3Quu8a50fqWQ6mCtGvD83BXBwXcp YSat8gtodbgrojNZUtlFYvug+GIGvW1O+TC+tfO/uLM+/mIkiDMhSZkBAJf8GOg8 0HvyyJ9KWSi+5XLfkBomVq4nJ/Wzf4Em16mWwzRCpjHGriq8BxtWpXeTaBQ6Ox+X ldWVd7lqZDGmkZju4zP91OiUM8i0gjyU8GwWCnL9iv+KcnHWCmR1134kLool/3Yn 2SV5F+89bHvAJ5OtAXadlWeEGkcoyJYC6P/CP9pgEB9gXddoRPkUFGpzfFqKVsxL oW9rRicM6BdUxn08h8SgL1zCC9fQ+ga9lpY0Yf/5AoIBAH7S5k5El5EE5mwsukRg hqmK9jUUAtLxiR0xQYD02dEIlE7cknYPEEOf3HxKnf5Cdv+35PlrAQZhs3YR+4cO XNoX1TBzml434BZEZNcM43Oosi1GIHU7b3kmXCMuYK0exGVDZ296lnp3vDoRtpTH 5GK44dYZvE7w2qz/p2g5XVqm6k80r4qDJps7XBuoW464gtnNvbuMas6iNLQWLk1q 32fKowgDRga2XiU+FFfV7a0bdGpNFfXSGOWwxlBobpsfb/pXKP2YZmSOPEJdYfoT pBFOY5Xcd3X8CZxcIW6jVABggP2cB8pvFEMdA/D5b4a0Zdo2ha1ulbJ6T2NZ/MN5 CH0CggEBAMLRnxLQRCgdyrYroqdSBU85fAk0uU//rn7i/1vQG6pUy4Dq6W/yBhFV /Fph6c9NXHUUbM3HlvyY2Ht4aUQl8d50wsyU6enxvpdwzti6N2WXyrEX4WtVqgNP OKHEu+mii3m6kOfvDD97AT4hAGzCZR4lkb06t49y7ua4NRZaKTrTiG3g2uTtBR81 /w1GtL+DNUEFzO1Iy2dscWxr76I+ZX6VlFHGneUlhyN9VJk8WHVI5xpVV9y7ay3I jXXFDgNqjqiSC6BU7iYpkVEKl/hvaGJU7CKLKFbxzBgseyY/7XsMHvWbwjK8a0Lm bakhie7hJBP7BoOup+dD5NQPlXBQ434= -----END PRIVATE KEY-----" PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApmHkae115z3vMF9IjiUa XrJH1Ljifxqs+XbDCFhQenz2iOf/bZDBMqfrwhBe3hVywa13kIuhf1TZlcJ/nRhE IZIbg6BF6/GeHN998Cx/IjhBeFkG7f1hdxEXY5ibd7yq8vAfzizZpnCu4+LM/NvI ei9mU+hj3GxrDbpMQTHCDDqX5aNEix8uFU4XaOm9UOJw9ivz19cqcnxWqJ49znxw XUlS5k4Hewbwf2s2BWFhz+CDWPHMpWViLQ17qj5QRklNn39sUymP1kXKP3Y76zvW zNVniPvFt+hHixxEa7tUEaTdtk4FgkNnWy0bFa7DIPC8ukjHvHHg10LeQiygDduI xbdVJwYK/qitU68ZAbbufldlj2FoVLwSyP9qpxUIdRoljCop1kDu6xQiAq0VHEAV LtgMpZhtD/6D4z644QJ8atx4Ljp/HEtTngaTh+/vLc9AnhHclX0/JmlbP1VkWWxG 9FIxS0TVPolRBs8948kLioTif9lrltJicxd6T0OuA5fny8l6hdSlzaD3pnr5pkYw mVn5FgBAFnd/skX6DsRs+eb2lNahkvNZZ15z9UeU2Rl6J9uBx0vbkcB7eiSCPhRM IKq/CtkyEgz8ce4kG18fJHDFDASRvGjaBu91lV5h7NLmhFLFzMvb7XoHrv/yD6TV E5W47J/DrfrevJkzS1GV++MCAwEAAQ== -----END PUBLIC KEY-----" export HOST=kolab.local export APP_WEBSITE_DOMAIN="$HOST" export APP_DOMAIN=$HOST export DES_KEY=kBxUM/53N9p9abusAoT0ZEAxwI2pxFz/ export DB_HOST=127.0.0.1 export KOLAB_SSL_CERTIFICATE=/etc/certs/kolab.local.cert export KOLAB_SSL_CERTIFICATE_KEY=/etc/certs/kolab.local.key export IMAP_HOST=localhost export IMAP_PORT=11143 export IMAP_ADMIN_LOGIN=cyrus-admin export IMAP_ADMIN_PASSWORD=simple123 export MAIL_HOST=localhost export MAIL_PORT=10587 export IMAP_DEBUG=true export FILEAPI_WOPI_OFFICE=https://$HOST export CALENDAR_CALDAV_SERVER=http://localhost:11080/dav export KOLAB_ADDRESSBOOK_CARDDAV_SERVER=http://localhost:11080/dav export DB_ROOT_PASSWORD=simple123 export DB_HKCCP_PASSWORD=simple123 export DB_KOLAB_PASSWORD=simple123 export DB_RC_PASSWORD=simple123 export DB_PASSWORD=simple123 export DB_USERNAME=kolabdev export DB_DATABASE=kolabdev export MINIO_ROOT_USER=minio export MINIO_ROOT_PASSWORD=simple123 export MINIO_USER=minio export MINIO_PASSWORD=simple123 export MEET_SERVER_TOKEN=simple123 export MEET_WEBHOOK_TOKEN=simple123 export PUBLIC_IP=127.0.0.1 export CERTS_PATH=./ci/certs export IMAP_SPOOL_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,tmpfs-mode=777,destination=/var/spool/imap,U=true,notmpcopyup export IMAP_LIB_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,tmpfs-mode=777,destination=/var/lib/imap,U=true,notmpcopyup export SYNAPSE_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,tmpfs-mode=777,destination=/data,U=true,notmpcopyup export MARIADB_STORAGE=--mount=type=tmpfs,tmpfs-size=512M,destination=/var/lib/mysql,U=true export REDIS_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,destination=/var/lib/redis,U=true export MINIO_STORAGE=--mount=type=tmpfs,tmpfs-size=128M,destination=/data,U=true - +export PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=2909ca4f-df7e-45fe-b355-e7c195aef112 +export PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=2URb+3JGJM9wPuDnlUSTPOw2mqmHsoOV8NXanx9xwQM= export PODMAN_IGNORE_CGROUPSV1_WARNING=true PODMAN="podman" source bin/podman_shared # Teardown the currently running environment kolab__teardown() { $PODMAN pod rm --force tests $PODMAN pod rm --force dev } # Build all containers required for testing kolab__build() { if [[ $1 != "" ]]; then if declare -f "podman__build_$1" >/dev/null 2>&1; then podman__build_$1 else podman__build docker/$1 $1 fi else podman__build_base podman__build_webapp podman__build_meet podman__build_imap podman__build docker/mariadb mariadb podman__build docker/redis redis podman__build_proxy podman__build docker/synapse synapse podman__build docker/element element podman__build_roundcube podman__build docker/tests kolab-tests --ulimit nofile=65535:65535 env CERT_DIR=ci/certs APP_DOMAIN=$HOST bin/regen-certs fi } # Setup the test environment kolab__setup() { echo "Build" kolab__build echo "Setup" export POD=tests # Create the pod first $PODMAN pod create --replace --name $POD podman__run_mariadb podman__run_redis podman__healthcheck $POD-mariadb $POD-redis podman__run_imap podman__run_webapp ci/env config.demo podman__healthcheck $POD-webapp podman__healthcheck $POD-imap # Ensure all commands are processed echo "Flushing work queue" $PODMAN exec -ti $POD-webapp ./artisan queue:work --stop-when-empty podman__run_minio podman__healthcheck $POD-minio # Validate the test environment kolab__validate $POD } # "testsuite" # "quicktest" # "tests/Feature/Jobs/WalletCheckTest.php" kolab__test() { export POD=tests $PODMAN run -ti --pod tests --name $POD-kolab-tests --replace \ --env-file=ci/env \ -v ./src:/src/kolabsrc.orig:ro \ -e APP_SERVICES_DOMAINS="localhost" \ -e PASSPORT_PRIVATE_KEY="$PASSPORT_PRIVATE_KEY" \ -e PASSPORT_PUBLIC_KEY="$PASSPORT_PUBLIC_KEY" \ -e APP_URL="http://kolab.local" \ -e APP_PUBLIC_URL="http://kolab.local" \ -e APP_HEADER_CSP="" \ -e APP_HEADER_XFO="" \ -e ASSET_URL="http://kolab.local" \ -e MEET_SERVER_URLS="http://kolab.local/meetmedia/api/" \ kolab-tests:latest /init.sh $@ } kolab__proxytest() { $PODMAN run -ti --pod tests --name $POD-proxy-tests --replace \ -v ./ci/certs/:/etc/certs/:ro \ --env-file=ci/env \ -e SSL_CERTIFICATE=${KOLAB_SSL_CERTIFICATE} \ -e SSL_CERTIFICATE_KEY=${KOLAB_SSL_CERTIFICATE_KEY} \ kolab-proxy:latest /init.sh validate } kolab__lint() { $PODMAN run --rm -ti \ -v ./src:/src/kolabsrc.orig:ro \ kolab-tests:latest /init.sh lint } # Setup the test environment and run a complete testsuite kolab__testrun() { echo "Setup" kolab__setup echo "Test" kolab__test testsuite } # Setup the test environment and run all testsuites kolab__testrun_complete() { echo "Setup" kolab__setup echo "Test" kolab__test lint kolab__test testsuite kolab__rctest syncroton lint kolab__rctest syncroton testsuite kolab__rctest irony lint # kolab__rctest irony testsuite kolab__rctest roundcubemail-plugins-kolab lint # kolab__rctest roundcubemail-plugins-kolab testsuite } # Get a shell inside the test container to run/debug tests kolab__shell() { if [[ $1 != "" ]]; then POD="dev" container=$1 shift command podman exec -ti $POD-$container /bin/bash else kolab__test shell fi } # Run the roundcube testsuite kolab__rctest() { $PODMAN run -t --pod tests --name $POD-roundcube --replace \ -v ./ext:/src.orig:ro \ -e APP_DOMAIN=kolab.local \ -e DES_KEY=kBxUM/53N9p9abusAoT0ZEAxwI2pxFz/ \ -e DB_HOST=127.0.0.1 \ -e DB_RC_DATABASE=roundcube \ -e DB_RC_USERNAME=roundcube \ -e DB_RC_PASSWORD=simple123 \ -e IMAP_HOST=localhost \ -e IMAP_PORT=11143 \ -e IMAP_ADMIN_LOGIN=cyrus-admin \ -e IMAP_ADMIN_PASSWORD=simple123 \ -e IMAP_DEBUG=false \ -e SQL_DEBUG=false \ -e ACTIVESYNC_DEBUG=false \ -e RUN_MIGRATIONS=true \ -e MAIL_HOST=localhost \ -e MAIL_PORT=10587 \ -e FILEAPI_WOPI_OFFICE=https://kolab.local \ -e CALENDAR_CALDAV_SERVER=http://imap:11080/dav \ -e KOLAB_ADDRESSBOOK_CARDDAV_SERVER=http://imap:11080/dav \ roundcube:latest ./init.sh $@ } # Get a shell inside the roundcube test container to run/debug tests kolab__rcshell() { $PODMAN run -ti --pod tests --name $POD-roundcube --replace \ -v ./ext:/src.orig:ro \ -e APP_DOMAIN=kolab.local \ -e DES_KEY=kBxUM/53N9p9abusAoT0ZEAxwI2pxFz/ \ -e DB_HOST=127.0.0.1 \ -e DB_RC_DATABASE=roundcube \ -e DB_RC_USERNAME=roundcube \ -e DB_RC_PASSWORD=simple123 \ -e IMAP_HOST=localhost \ -e IMAP_PORT=11143 \ -e IMAP_ADMIN_LOGIN=cyrus-admin \ -e IMAP_ADMIN_PASSWORD=simple123 \ -e MAIL_HOST=localhost \ -e MAIL_PORT=10587 \ -e FILEAPI_WOPI_OFFICE=https://kolab.local \ -e CALENDAR_CALDAV_SERVER=http://localhost:11080/dav \ -e KOLAB_ADDRESSBOOK_CARDDAV_SERVER=http://localhost:11080/dav \ roundcube:latest ./init.sh shell } kolab__validate() { POD=$1 $PODMAN exec $POD-imap testsaslauthd -u cyrus-admin -p simple123 $PODMAN exec $POD-imap testsaslauthd -u "john@kolab.org" -p simple123 # Ensure the inbox is created FOUND=false for i in {1..60}; do if $PODMAN exec $POD-imap bash -c 'echo "lm" | cyradm --auth PLAIN -u cyrus-admin -w simple123 --port 11143 localhost | grep "user/john@kolab.org"'; then echo "Found mailbox"; FOUND=true break else echo "Waiting for mailbox"; sleep 1; fi done if ! $FOUND; then echo "Failed to find the inbox for john@kolab.org" exit 1 fi } kolab__run() { export POD=dev podman__run_$1 } kolab__run() { POD=dev podman__run_$1 } kolab__deploy() { export POD=dev # Create the pod first $PODMAN pod create \ --replace \ --add-host=kolab.local:127.0.0.1 \ --publish "443:6443" \ --publish "465:6465" \ --publish "587:6587" \ --publish "143:6143" \ --publish "993:6993" \ --publish "44444:44444/udp" \ --publish "44444:44444/tcp" \ --name $POD podman__run_mariadb podman__run_redis podman__healthcheck $POD-mariadb $POD-redis podman__run_imap podman__run_webapp ci/env config.prod podman__healthcheck $POD-webapp podman__healthcheck $POD-imap # Ensure all commands are processed echo "Flushing work queue" $PODMAN exec -ti $POD-webapp ./artisan queue:work --stop-when-empty $PODMAN exec $POD-webapp ./artisan user:password "admin@kolab.local" "simple123" podman__run_synapse podman__run_element podman__run_minio podman__healthcheck $POD-minio podman__run_meet podman__run_roundcube podman__run_proxy podman__run_postfix podman__run_amavis } # Monitor vue files for changes, and automatically reload the dev webapp container if anything changes. # Requires "entr" on the host kolab__watch() { trap 'kill $(jobs -p) 2>/dev/null' EXIT find src/resources/ src/app -regex '.*\.\(vue\|php\|js\)$' | entr podman exec -ti dev-webapp bash -c "/update-source.sh; ./artisan octane:reload" & podman exec -ti dev-webapp npm run watch } # Get the host to trust the generated ca kolab__add_ca_trust() { sudo trust anchor --store ci/certs/ca.cert sudo update-ca-trust } kolab__generate_mail() { $PODMAN run --pod=dev -t --rm kolab-utils:latest ./generatemail.py --maxAttachmentSize=3 --type=mail --count 100 --username admin@kolab.local --password simple123 --host localhost --port 11143 INBOX } kolab__syncroton_sync() { $PODMAN run -t --network=host --add-host=kolab.local:127.0.0.1 --rm kolab-utils:latest ./activesynccli.py --host kolab.local --user admin@kolab.local --password simple123 sync 38b950ebd62cd9a66929c89615d0fc04 } kolab__logs() { POD=dev command podman logs --tail=1000 -f $POD-$1 } kolab__db() { POD=dev $PODMAN exec -ti $POD-mariadb /bin/bash -c "mysql -h 127.0.0.1 -u kolabdev --password=simple123 kolabdev" } kolab__help() { cat </dev/null 2>&1; then "kolab__$cmdname" "${@:1}" else echo "Function $cmdname not recognized" >&2 kolab__help exit 1 fi diff --git a/config.demo/src/database/seeds/PassportSeeder.php b/config.demo/src/database/seeds/PassportSeeder.php index e3551f0d..6fc68401 100644 --- a/config.demo/src/database/seeds/PassportSeeder.php +++ b/config.demo/src/database/seeds/PassportSeeder.php @@ -1,34 +1,49 @@ forceFill([ 'user_id' => null, 'name' => "Kolab Password Grant Client", 'secret' => \config('auth.proxy.client_secret'), 'provider' => 'users', 'redirect' => 'https://' . \config('app.website_domain'), 'personal_access_client' => 0, 'password_client' => 1, 'revoked' => false, ]); $client->id = \config('auth.proxy.client_id'); $client->save(); + + // Create a client for synapse oauth + $client = Passport::client()->forceFill([ + 'user_id' => null, + 'name' => "Synapse oauth client", + 'secret' => \config('auth.synapse.client_secret'), + 'provider' => 'users', + 'redirect' => 'https://' . \config('app.website_domain') . "/_synapse/client/oidc/callback", + 'personal_access_client' => 0, + 'password_client' => 0, + 'revoked' => false, + 'allowed_scopes' => ['email'], + ]); + $client->id = \config('auth.synapse.client_id'); + $client->save(); } } diff --git a/config.prod/src/database/seeds/PassportSeeder.php b/config.prod/src/database/seeds/PassportSeeder.php index e3551f0d..6fc68401 100644 --- a/config.prod/src/database/seeds/PassportSeeder.php +++ b/config.prod/src/database/seeds/PassportSeeder.php @@ -1,34 +1,49 @@ forceFill([ 'user_id' => null, 'name' => "Kolab Password Grant Client", 'secret' => \config('auth.proxy.client_secret'), 'provider' => 'users', 'redirect' => 'https://' . \config('app.website_domain'), 'personal_access_client' => 0, 'password_client' => 1, 'revoked' => false, ]); $client->id = \config('auth.proxy.client_id'); $client->save(); + + // Create a client for synapse oauth + $client = Passport::client()->forceFill([ + 'user_id' => null, + 'name' => "Synapse oauth client", + 'secret' => \config('auth.synapse.client_secret'), + 'provider' => 'users', + 'redirect' => 'https://' . \config('app.website_domain') . "/_synapse/client/oidc/callback", + 'personal_access_client' => 0, + 'password_client' => 0, + 'revoked' => false, + 'allowed_scopes' => ['email'], + ]); + $client->id = \config('auth.synapse.client_id'); + $client->save(); } } diff --git a/docker/synapse/Dockerfile b/docker/synapse/Dockerfile index 4147152e..84989a3c 100644 --- a/docker/synapse/Dockerfile +++ b/docker/synapse/Dockerfile @@ -1,47 +1,47 @@ FROM apheleia/almalinux9 ENV HOME=/opt/app-root/src RUN dnf -y install \ --setopt=install_weak_deps=False \ --setopt 'tsflags=nodocs' \ libtiff-devel \ libjpeg-devel \ libzip-devel \ freetype-devel \ lcms2 \ libwebp-devel \ tcl-devel \ tk-devel \ python3 \ python3-pip \ libffi-devel \ openssl-devel \ sed \ wget && \ - pip3 install matrix-synapse && \ + pip3 install matrix-synapse authlib && \ dnf clean all COPY /rootfs / RUN id default || (groupadd -g 1001 default && useradd -d /opt/app-root/ -u 1001 -g 1001 default) -RUN PATHS=(/opt/app-root/src) && \ +RUN PATHS=(/opt/app-root/src /etc/pki/ca-trust/extracted/ /etc/pki/ca-trust/source/anchors/) && \ mkdir -p ${PATHS[@]} && \ chmod -R 777 ${PATHS[@]} && \ chown -R 1001:0 ${PATHS[@]} && \ chmod -R g=u ${PATHS[@]} USER 1001 WORKDIR ${HOME} VOLUME /data/ # Synapse just always hits the 10s timeout and get's killed anyways, so let's be quick about it. STOPSIGNAL SIGKILL CMD ["/opt/app-root/src/init.sh"] EXPOSE 8008/tcp diff --git a/docker/synapse/rootfs/opt/app-root/src/homeserver.yaml b/docker/synapse/rootfs/opt/app-root/src/homeserver.yaml index 0c0139e4..7ae200d5 100644 --- a/docker/synapse/rootfs/opt/app-root/src/homeserver.yaml +++ b/docker/synapse/rootfs/opt/app-root/src/homeserver.yaml @@ -1,147 +1,157 @@ server_name: "APP_DOMAIN" public_baseurl: "https://APP_DOMAIN" pid_file: /opt/app-root/src/homeserver.pid listeners: - port: 8008 tls: false type: http x_forwarded: true bind_addresses: ['::'] resources: - names: [client, federation] compress: false database: name: sqlite3 args: database: /data/homeserver.db log_config: "/opt/app-root/src/log.config" web_client: False soft_file_limit: 0 # We have no registration # registration_shared_secret: "REGISTRATION_SHARED_SECRET" # We just use a default derived from the signing key # macaroon_secret_key: "MACAROON_SECRET_KEY" # Only required for consent forms that we don't use # form_secret: "FORM_SECRET" report_stats: false enable_metrics: false signing_key_path: "/data/signing.key" old_signing_keys: {} key_refresh_interval: "1d" trusted_key_servers: [] ## Performance ## event_cache_size: "10K" ## Ratelimiting ## rc_messages_per_second: 0.2 rc_message_burst_count: 10.0 federation_rc_window_size: 1000 federation_rc_sleep_limit: 10 federation_rc_sleep_delay: 500 federation_rc_reject_limit: 50 federation_rc_concurrent: 3 ## Files ## media_store_path: /data/media_store max_upload_size: 50M max_image_pixels: 32M dynamic_thumbnails: false # media_retention: # local_media_lifetime: 90d # remote_media_lifetime: 14d # List of thumbnail to precalculate when an image is uploaded. thumbnail_sizes: - width: 32 height: 32 method: crop - width: 96 height: 96 method: crop - width: 320 height: 240 method: scale - width: 640 height: 480 method: scale - width: 800 height: 600 method: scale url_preview_enabled: False max_spider_size: "10M" ## Captcha ## enable_registration_captcha: False ## Turn ## turn_uris: [TURN_URIS] turn_shared_secret: "TURN_SHARED_SECRET" turn_user_lifetime: "1h" turn_allow_guests: false ## Registration ## enable_registration: false enable_registration_without_verification: false bcrypt_rounds: 12 allow_guest_access: false enable_group_creation: false inhibit_user_in_use_error: true user_directory: enabled: false search_all_users: false prefer_local_users: false allow_public_rooms_without_auth: false enable_set_displayname: false enable_set_avatar_url: false enable_3pid_changes: false # Avoid leaking profile information require_auth_for_profile_requests: true limit_profile_requests_to_users_who_share_rooms: true include_profile_data_on_invite: false federation_domain_whitelist: - APP_DOMAIN -# oidc_providers: -# - idp_id: kolab -# idp_name: "Kolab" -# issuer: "https://127.0.0.1:8443/auth/realms/{realm_name}" -# client_id: "synapse" -# client_secret: "copy secret generated from above" -# scopes: ["openid", "profile"] -# user_mapping_provider: -# config: -# localpart_template: "\{\{ user.preferred_username }}" -# display_name_template: "\{\{ user.name }}" +sso: + client_whitelist: + - https://APP_DOMAIN/ + update_profile_information: true + +oidc_providers: + - idp_id: kolab + idp_name: "Kolab" + discover: false + issuer: "https://APP_DOMAIN" + authorization_endpoint: "https://APP_DOMAIN/authorize" + #These connections go over localhost, but must still be https (otherwise it doesn't work). Also the certificate must match, so we can't use 127.0.0.1. + token_endpoint: "https://APP_DOMAIN:6443/oauth/token" + userinfo_endpoint: "https://APP_DOMAIN:6443/api/oauth/info" + client_id: "SYNAPSE_OAUTH_CLIENT_ID" + client_secret: "SYNAPSE_OAUTH_CLIENT_SECRET" + client_auth_method: client_secret_post + allow_existing_users: true + allow_registration: false + scopes: ['oauth'] + user_mapping_provider: + config: + subject_claim: "id" + email_template: "{{ user.email }}" + display_name_template: "{{ user.settings.first_name }}" -modules: -- module: kolab_auth_provider.KolabAuthProvider - config: - kolab_url: "KOLAB_URL" ## API Configuration ## # app_service_config_files: # - /config/hookshot.yaml expire_access_token: false password_config: enabled: true localdb_enabled: false # Configure a default retention policy (can be overriden ber room) retention: allowed_lifetime_min: 1d allowed_lifetime_max: 1y default_policy: min_lifetime: 1d max_lifetime: 1y diff --git a/docker/synapse/rootfs/opt/app-root/src/init.sh b/docker/synapse/rootfs/opt/app-root/src/init.sh index 62351d45..371ae9df 100755 --- a/docker/synapse/rootfs/opt/app-root/src/init.sh +++ b/docker/synapse/rootfs/opt/app-root/src/init.sh @@ -1,11 +1,18 @@ #!/bin/bash set -e +if [[ -f /etc/certs/ca.cert ]]; then + cp /etc/certs/ca.cert /etc/pki/ca-trust/source/anchors/ + update-ca-trust +fi + sed -i -r \ -e "s|APP_DOMAIN|$APP_DOMAIN|g" \ -e "s|KOLAB_URL|$KOLAB_URL|g" \ -e "s|TURN_SHARED_SECRET|$TURN_SHARED_SECRET|g" \ -e "s|TURN_URIS|$TURN_URIS|g" \ + -e "s|SYNAPSE_OAUTH_CLIENT_ID|$SYNAPSE_OAUTH_CLIENT_ID|g" \ + -e "s|SYNAPSE_OAUTH_CLIENT_SECRET|$SYNAPSE_OAUTH_CLIENT_SECRET|g" \ /opt/app-root/src/homeserver.yaml exec synctl --no-daemonize start ${CONFIGFILE:-/opt/app-root/src/homeserver.yaml} diff --git a/kolabctl b/kolabctl index 71aac4ae..1a112dd7 100755 --- a/kolabctl +++ b/kolabctl @@ -1,425 +1,435 @@ #!/bin/bash set -e CONFIG=${CONFIG:-"config.prod"} export HOST=${HOST:-"kolab.local"} OPENEXCHANGERATES_API_KEY=${OPENEXCHANGERATES_API_KEY} FIREBASE_API_KEY=${FIREBASE_API_KEY} PUBLIC_IP=${PUBLIC_IP:-"127.0.0.1"} export CERTS_PATH=./docker/certs export POD=kolab-prod export IMAP_SPOOL_STORAGE="--mount=type=volume,src=$POD-imap-spool,destination=/var/spool/imap,U=true" export IMAP_LIB_STORAGE="--mount=type=volume,src=$POD-imap-lib,destination=/var/lib/imap,U=true" export POSTFIX_SPOOL_STORAGE="--mount=type=volume,src=$POD-postfix-spool,destination=/var/spool/imap,U=true" export POSTFIX_LIB_STORAGE="--mount=type=volume,src=$POD-postfix-lib,destination=/var/lib/imap,U=true" export SYNAPSE_STORAGE="--mount=type=volume,src=$POD-synapse-data,destination=/data,U=true" export MARIADB_STORAGE="--mount=type=volume,src=$POD-mariadb-data,destination=/var/lib/mysql,U=true" export REDIS_STORAGE="--mount=type=volume,src=$POD-redis-data,destination=/var/lib/redis,U=true" export MINIO_STORAGE="--mount=type=volume,src=$POD-minio-data,destination=/data,U=true" export PODMAN_IGNORE_CGROUPSV1_WARNING=true source bin/podman_shared __export_env() { source src/.env export APP_WEBSITE_DOMAIN export APP_DOMAIN export DB_HOST export IMAP_HOST export IMAP_PORT export IMAP_ADMIN_LOGIN export IMAP_ADMIN_PASSWORD export MAIL_HOST export MAIL_PORT export IMAP_DEBUG export FILEAPI_WOPI_OFFICE export CALENDAR_CALDAV_SERVER export KOLAB_ADDRESSBOOK_CARDDAV_SERVER export DB_ROOT_PASSWORD export DB_USERNAME export DB_PASSWORD export DB_DATABASE export MINIO_USER export MINIO_PASSWORD export PASSPORT_PRIVATE_KEY export PASSPORT_PUBLIC_KEY export DES_KEY export MEET_SERVER_TOKEN export MEET_WEBHOOK_TOKEN export KOLAB_SSL_CERTIFICATE export KOLAB_SSL_CERTIFICATE_FULLCHAIN export KOLAB_SSL_CERTIFICATE_KEY export PUBLIC_IP } kolab__configure() { if [[ "$1" == "--force" ]]; then rm src/.env fi # Generate the .env once with all the necessary secrets if [[ -f src/.env ]]; then echo "src/.env already exists, not regenerating" return fi cp "$CONFIG/src/.env" src/.env if [[ -z $ADMIN_PASSWORD ]]; then echo "Please enter your new admin password for the admin@$HOST user:" read -r ADMIN_PASSWORD fi if [[ -z $PUBLIC_IP ]]; then PUBLIC_IP=$(ip -o route get to 8.8.8.8 | sed -n 's/.*src \([0-9.]\+\).*/\1/p') fi # Generate random secrets if ! grep -q "COTURN_STATIC_SECRET" src/.env; then COTURN_STATIC_SECRET=$(openssl rand -hex 32); echo "COTURN_STATIC_SECRET=${COTURN_STATIC_SECRET}" >> src/.env fi if ! grep -q "MEET_WEBHOOK_TOKEN" src/.env; then MEET_WEBHOOK_TOKEN=$(openssl rand -hex 32); echo "MEET_WEBHOOK_TOKEN=${MEET_WEBHOOK_TOKEN}" >> src/.env fi if ! grep -q "MEET_SERVER_TOKEN" src/.env; then MEET_SERVER_TOKEN=$(openssl rand -hex 32); echo "MEET_SERVER_TOKEN=${MEET_SERVER_TOKEN}" >> src/.env fi if ! grep -q "APP_KEY=base64:" src/.env; then APP_KEY=$(openssl rand -base64 32); echo "APP_KEY=base64:${APP_KEY}" >> src/.env fi if ! grep -q "PASSPORT_PROXY_OAUTH_CLIENT_ID=" src/.env; then PASSPORT_PROXY_OAUTH_CLIENT_ID=$(uuidgen); echo "PASSPORT_PROXY_OAUTH_CLIENT_ID=${PASSPORT_PROXY_OAUTH_CLIENT_ID}" >> src/.env fi if ! grep -q "PASSPORT_PROXY_OAUTH_CLIENT_SECRET=" src/.env; then PASSPORT_PROXY_OAUTH_CLIENT_SECRET=$(openssl rand -base64 32); echo "PASSPORT_PROXY_OAUTH_CLIENT_SECRET=${PASSPORT_PROXY_OAUTH_CLIENT_SECRET}" >> src/.env fi + if ! grep -q "PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=" src/.env; then + PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=$(uuidgen); + echo "PASSPORT_SYNAPSE_OAUTH_CLIENT_ID=${PASSPORT_SYNAPSE_OAUTH_CLIENT_ID}" >> src/.env + fi + + if ! grep -q "PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=" src/.env; then + PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=$(openssl rand -base64 32); + echo "PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET=${PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET}" >> src/.env + fi + if ! grep -q "PASSPORT_PUBLIC_KEY=|PASSPORT_PRIVATE_KEY=" src/.env; then PASSPORT_PRIVATE_KEY=$(openssl genrsa 4096); echo "PASSPORT_PRIVATE_KEY=\"${PASSPORT_PRIVATE_KEY}\"" >> src/.env PASSPORT_PUBLIC_KEY=$(echo "$PASSPORT_PRIVATE_KEY" | openssl rsa -pubout 2>/dev/null) echo "PASSPORT_PUBLIC_KEY=\"${PASSPORT_PUBLIC_KEY}\"" >> src/.env fi if ! grep -q "DES_KEY=" src/.env; then DES_KEY=$(openssl rand -base64 24); echo "DES_KEY=${DES_KEY}" >> src/.env fi # Customize configuration sed -i \ -e "s/{{ host }}/${HOST}/g" \ -e "s/{{ openexchangerates_api_key }}/${OPENEXCHANGERATES_API_KEY}/g" \ -e "s/{{ firebase_api_key }}/${FIREBASE_API_KEY}/g" \ -e "s/{{ public_ip }}/${PUBLIC_IP}/g" \ -e "s/{{ admin_password }}/${ADMIN_PASSWORD}/g" \ src/.env if [ -f /etc/letsencrypt/live/${HOST}/cert.pem ]; then echo "Using the available letsencrypt certificate for ${HOST}" cat >> src/.env << EOF KOLAB_SSL_CERTIFICATE=/etc/letsencrypt/live/${HOST}/cert.pem KOLAB_SSL_CERTIFICATE_FULLCHAIN=/etc/letsencrypt/live/${HOST}/fullchain.pem KOLAB_SSL_CERTIFICATE_KEY=/etc/letsencrypt/live/${HOST}/privkey.pem EOF fi } kolab__deploy() { if [[ -z $ADMIN_PASSWORD ]]; then echo "Please enter your new admin password for the admin@$HOST user:" read -r ADMIN_PASSWORD fi echo "Deploying $CONFIG on $HOST" if [[ ! -f src/.env ]]; then echo "Missing src/.env file, run 'kolabctl configure' to generate" exit 1 fi if [[ "$1" == "--reset" ]]; then kolab__reset --force fi __export_env podman volume create $POD-imap-spool --ignore -l=kolab podman volume create $POD-imap-lib --ignore -l=kolab podman volume create $POD-postfix-spool --ignore -l=kolab podman volume create $POD-postfix-lib --ignore -l=kolab podman volume create $POD-synapse-data --ignore -l=kolab podman volume create $POD-mariadb-data --ignore -l=kolab podman volume create $POD-redis-data --ignore -l=kolab podman volume create $POD-minio-data --ignore -l=kolab kolab__build # Create the pod first $PODMAN pod create \ --replace \ --add-host=$HOST:127.0.0.1 \ --publish "443:6443" \ --publish "465:6465" \ --publish "587:6587" \ --publish "143:6143" \ --publish "993:6993" \ --publish "44444:44444/udp" \ --publish "44444:44444/tcp" \ --name $POD podman__run_mariadb podman__run_redis podman__healthcheck $POD-mariadb $POD-redis # Make imap available to the webapp seeder, but don't expect it to be healthy until it can authenticate against the webapp podman__run_imap podman__run_webapp src/.env $CONFIG podman__healthcheck $POD-webapp podman__healthcheck $POD-imap # Ensure all commands are processed echo "Flushing work queue" $PODMAN exec -ti $POD-webapp ./artisan queue:work --stop-when-empty if [[ -n $ADMIN_PASSWORD ]]; then podman exec $POD-webapp ./artisan user:password "admin@$APP_DOMAIN" "$ADMIN_PASSWORD" fi podman__run_synapse podman__run_element podman__run_minio podman__healthcheck $POD-minio podman__run_meet podman__run_roundcube podman__run_postfix podman__run_amavis podman__run_collabora podman__run_proxy } kolab__reset() { if [[ "$1" == "--force" ]]; then REPLY="y" else read -p "Are you sure? This will delete the pod including all data. Type y to confirm." -n 1 -r echo fi if [[ "$REPLY" =~ ^[Yy]$ ]]; then podman pod rm --force $POD volumes=($(podman volume ls -f name=$POD | awk '{if (NR > 1) print $2}')) for v in "${volumes[@]}" do podman volume rm --force $v done fi } kolab__start() { podman pod start $POD } kolab__stop() { podman pod stop $POD } kolab__update() { kolab__stop podman pull quay.io/sclorg/mariadb-105-c9s podman pull minio/minio:latest podman pull almalinux:9 kolab__build kolab__start } kolab__backup() { backup_path="$(pwd)/backup/" mkdir -p "$backup_path" echo "Stopping containers" kolab__stop echo "Backing up volumes" volumes=($(podman volume ls -f name=$POD | awk '{if (NR > 1) print $2}')) for v in "${volumes[@]}" do podman export -o="$backup_path/$v.tar" done echo "Restarting containers" kolab__start } kolab__restore() { backup_path="$(pwd)/backup/" echo "Stopping containers" kolab__stop # We currently expect the volumes to exist. # We could alternatively create volumes form existing tar files # for f in backup/*.tar; do # echo "$(basename $f .tar)" ; # done echo "Restoring volumes" volumes=($(podman volume ls -f name=$POD | awk '{if (NR > 1) print $2}')) for v in "${volumes[@]}" do podman import $v "$backup_path/$v.tar" done echo "Restarting containers" kolab__start } kolab__selfcheck() { set -e APP_DOMAIN=$(grep APP_DOMAIN src/.env | tail -n1 | sed "s/APP_DOMAIN=//") if [ -z "$ADMIN_PASSWORD" ]; then ADMIN_PASSWORD="simple123" fi if [ -z "$ADMIN_USER" ]; then ADMIN_USER="admin@$APP_DOMAIN" fi echo "Checking for containers" podman__is_ready $POD-imap podman__is_ready $POD-mariadb podman__is_ready $POD-redis podman__is_ready $POD-webapp podman__is_ready $POD-minio podman__is_ready $POD-meet podman__is_ready $POD-roundcube podman__is_ready $POD-postfix podman__is_ready $POD-amavis podman__is_ready $POD-collabora podman__is_ready $POD-proxy echo "All containers are available" # We skip mollie and openexchange podman exec $POD-webapp env APP_DEBUG=false ./artisan status:health --check DB --check Redis --check IMAP --check Roundcube --check Meet --check DAV podman exec $POD-postfix testsaslauthd -u "$ADMIN_USER" -p "$ADMIN_PASSWORD" podman exec $POD-imap testsaslauthd -u "$ADMIN_USER" -p "$ADMIN_PASSWORD" # podman run -ti --rm utils ./mailtransporttest.py --sender-username "$ADMIN_USER" --sender-password "$ADMIN_PASSWORD" --sender-host "127.0.0.1" --recipient-username "$ADMIN_USER" --recipient-password "$ADMIN_PASSWORD" --recipient-host "127.0.0.1" --recipient-port "11143" # podman run -ti --rm utils ./kolabendpointtester.py --verbose --host "$APP_DOMAIN" --dav "https://$APP_DOMAIN/dav/" --imap "$APP_DOMAIN" --activesync "$APP_DOMAIN" --user "$ADMIN_USER" --password "$ADMIN_PASSWORD" echo "All tests have passed!" } kolab__ps() { command podman ps } kolab__exec() { container=$1 shift command podman exec -ti $POD-$container $@ } kolab__run() { __export_env podman__run_$1 } kolab__build() { if [[ $1 != "" ]]; then podman__build_$1 else podman__build_base podman__build_webapp podman__build_meet podman__build_imap podman__build docker/mariadb mariadb podman__build docker/redis redis podman__build_proxy podman__build docker/synapse synapse podman__build docker/element element podman__build_roundcube podman__build_utils podman__build_postfix podman__build_amavis podman__build_collabora env CERT_DIR=docker/certs APP_DOMAIN=$HOST bin/regen-certs fi } kolab__cyradm() { # command podman exec -ti $POD-imap cyradm --auth PLAIN -u admin@kolab.local -w simple123 --port 11143 localhost if [[ "$@" ]]; then command podman exec -ti $POD-imap echo "$@" | cyradm --auth PLAIN -u $(grep IMAP_ADMIN_LOGIN src/.env | cut -d '=' -f 2 ) -w $(grep IMAP_ADMIN_PASSWORD src/.env | cut -d '=' -f 2 ) --port 11143 localhost else command podman exec -ti $POD-imap cyradm --auth PLAIN -u $(grep IMAP_ADMIN_LOGIN src/.env | cut -d '=' -f 2 ) -w $(grep IMAP_ADMIN_PASSWORD src/.env | cut -d '=' -f 2 ) --port 11143 localhost fi } kolab__shell() { kolab__exec $1 /bin/bash } kolab__run() { __export_env podman__run_$1 } kolab__logs() { command podman logs -f $POD-$1 } kolab__help() { cat </dev/null 2>&1; then "kolab__$cmdname" "${@:1}" else echo "Function $cmdname not recognized" >&2 kolab__help exit 1 fi diff --git a/src/app/Auth/IdentityEntity.php b/src/app/Auth/IdentityEntity.php new file mode 100644 index 00000000..10f93b8c --- /dev/null +++ b/src/app/Auth/IdentityEntity.php @@ -0,0 +1,42 @@ +identifier = $identifier; + $this->user = User::findOrFail($identifier); + } + + /** + * When building the id_token, this entity's claims are collected + */ + public function getClaims(): array + { + // TODO: Other claims + // TODO: Should we use this in AuthController::oauthUserInfo() for some de-duplicaton? + + return [ + 'email' => $this->user->email, + ]; + } +} diff --git a/src/app/Auth/IdentityRepository.php b/src/app/Auth/IdentityRepository.php new file mode 100644 index 00000000..0a37a287 --- /dev/null +++ b/src/app/Auth/IdentityRepository.php @@ -0,0 +1,17 @@ +setIdentifier($identifier); + + return $identityEntity; + } +} diff --git a/src/app/Auth/PassportClient.php b/src/app/Auth/PassportClient.php index 507d5ee7..5cb208dc 100644 --- a/src/app/Auth/PassportClient.php +++ b/src/app/Auth/PassportClient.php @@ -1,29 +1,27 @@ The attributes that should be cast */ protected $casts = [ 'allowed_scopes' => 'array', ]; /** * The allowed scopes for tokens instantiated by this client - * - * @return Array - * */ + */ public function getAllowedScopes(): array { if ($this->allowed_scopes) { return $this->allowed_scopes; } return []; } } diff --git a/src/app/Console/Kernel.php b/src/app/Console/Kernel.php index 49eaca27..30dc6a18 100644 --- a/src/app/Console/Kernel.php +++ b/src/app/Console/Kernel.php @@ -1,55 +1,58 @@ command('data:import')->dailyAt('05:00'); // This notifies users about coming password expiration $schedule->command('password:retention')->dailyAt('06:00'); // This applies wallet charges $schedule->command('wallet:charge')->everyFourHours(); // This removes deleted storage files/file chunks from the filesystem $schedule->command('fs:expunge')->hourly(); // This cleans up IMAP ACL for deleted users/etc. //$schedule->command('imap:cleanup')->dailyAt('03:00'); // This notifies users about an end of the trial period $schedule->command('wallet:trial-end')->dailyAt('07:00'); // This collects some statistics into the database $schedule->command('data:stats:collector')->dailyAt('23:00'); // https://laravel.com/docs/10.x/upgrade#redis-cache-tags $schedule->command('cache:prune-stale-tags')->hourly(); + + // This removes passport expired/revoked tokens and auth codes from the database + $schedule->command('passport:purge')->dailyAt('06:30'); } /** * Register the commands for the application. */ protected function commands(): void { $this->load(__DIR__ . '/Commands'); if (\app('env') == 'local') { $this->load(__DIR__ . '/Development'); } include base_path('routes/console.php'); } } diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php index 6f23a6c8..9302c6d0 100644 --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -1,197 +1,277 @@ user(); if (!empty(request()->input('refresh'))) { return $this->refreshAndRespond(request(), $user); } $response = V4\UsersController::userResponse($user); return response()->json($response); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object * @param string $password Plain text password * @param string|null $secondFactor Second factor code if available */ public static function logonResponse(User $user, string $password, string $secondFactor = null) { $proxyRequest = Request::create('/oauth/token', 'POST', [ 'username' => $user->email, 'password' => $password, 'grant_type' => 'password', 'client_id' => \config('auth.proxy.client_id'), 'client_secret' => \config('auth.proxy.client_secret'), 'scope' => 'api', 'secondfactor' => $secondFactor ]); $proxyRequest->headers->set('X-Client-IP', request()->ip()); $tokenResponse = app()->handle($proxyRequest); return self::respondWithToken($tokenResponse, $user); } /** * Get an oauth token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { $v = Validator::make( $request->all(), [ 'email' => 'required|min:3', 'password' => 'required|min:1', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $user = \App\User::where('email', $request->email)->first(); if (!$user) { return response()->json(['status' => 'error', 'message' => self::trans('auth.failed')], 401); } return self::logonResponse($user, $request->password, $request->secondfactor); } + /** + * Approval request for the oauth authorization endpoint + * + * * The user is authenticated via the regular login page + * * We assume implicit consent in the Authorization page + * * Ultimately we return an authorization code to the caller via the redirect_uri + * + * The implementation is based on Laravel\Passport\Http\Controllers\AuthorizationController + * + * @param ServerRequestInterface $psrRequest PSR request + * @param \Illuminate\Http\Request $request The API request + * @param AuthorizationServer $server Authorization server + * + * @return \Illuminate\Http\JsonResponse + */ + public function oauthApprove(ServerRequestInterface $psrRequest, Request $request, AuthorizationServer $server) + { + if ($request->response_type != 'code') { + return self::errorResponse(422, self::trans('validation.invalidvalueof', ['attribute' => 'response_type'])); + } + + try { + // league/oauth2-server/src/Grant/ code expects GET parameters, but we're using POST here + $psrRequest = $psrRequest->withQueryParams($request->input()); + + $authRequest = $server->validateAuthorizationRequest($psrRequest); + + $user = Auth::guard()->user(); + + // TODO I'm not sure if we should still execute this to deny the request + $authRequest->setUser(new \Laravel\Passport\Bridge\User($user->getAuthIdentifier())); + $authRequest->setAuthorizationApproved(true); + + // This will generate a 302 redirect to the redirect_uri with the generated authorization code + $response = $server->completeAuthorizationRequest($authRequest, new Psr7Response()); + } catch (\League\OAuth2\Server\Exception\OAuthServerException $e) { + // Note: We don't want 401 or 400 codes here, use 422 which is used in our API + $code = $e->getHttpStatusCode(); + return self::errorResponse($code < 500 ? 422 : 500, $e->getMessage()); + } catch (\Exception $e) { + return self::errorResponse(422, self::trans('auth.error.invalidrequest')); + } + + return response()->json([ + 'status' => 'success', + 'redirectUrl' => $response->getHeader('Location')[0], + ]); + } + + /** + * Get the authenticated User information (using access token claims) + * + * @return \Illuminate\Http\JsonResponse + */ + public function oauthUserInfo() + { + $user = Auth::guard()->user(); + + $response = [ + // Per OIDC spec. 'sub' must be always returned + 'sub' => $user->id, + ]; + + if ($user->tokenCan('email')) { + $response['email'] = $user->email; + $response['email_verified'] = $user->isActive(); + } + + // TODO: Other claims (https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) + // address: address + // phone: phone_number and phone_number_verified + // profile: name, family_name, given_name, middle_name, nickname, preferred_username, + // profile, picture, website, gender, birthdate, zoneinfo, locale, and updated_at + + return response()->json($response); + } + /** * Get the user (geo) location * * @return \Illuminate\Http\JsonResponse */ public function location() { $ip = request()->ip(); $response = [ 'ipAddress' => $ip, 'countryCode' => \App\Utils::countryForIP($ip, ''), ]; return response()->json($response); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $tokenId = Auth::user()->token()->id; $tokenRepository = app(TokenRepository::class); $refreshTokenRepository = app(RefreshTokenRepository::class); // Revoke an access token... $tokenRepository->revokeAccessToken($tokenId); // Revoke all of the token's refresh tokens... $refreshTokenRepository->revokeRefreshTokensByAccessTokenId($tokenId); return response()->json([ 'status' => 'success', 'message' => self::trans('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh(Request $request) { return self::refreshAndRespond($request); } /** * Refresh the token and respond with it. * * @param \Illuminate\Http\Request $request The API request. * @param ?\App\User $user The user being authenticated * * @return \Illuminate\Http\JsonResponse */ protected static function refreshAndRespond(Request $request, $user = null) { $proxyRequest = Request::create('/oauth/token', 'POST', [ 'grant_type' => 'refresh_token', 'refresh_token' => $request->refresh_token, 'client_id' => \config('auth.proxy.client_id'), 'client_secret' => \config('auth.proxy.client_secret'), ]); $tokenResponse = app()->handle($proxyRequest); return self::respondWithToken($tokenResponse, $user); } /** * Get the token array structure. * * @param \Symfony\Component\HttpFoundation\Response $tokenResponse The response containing the token. * @param ?\App\User $user The user being authenticated * * @return \Illuminate\Http\JsonResponse */ protected static function respondWithToken($tokenResponse, $user = null) { $data = json_decode($tokenResponse->getContent()); if ($tokenResponse->getStatusCode() != 200) { if (isset($data->error) && $data->error == 'secondfactor' && isset($data->error_description)) { $errors = ['secondfactor' => $data->error_description]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json(['status' => 'error', 'message' => self::trans('auth.failed')], 401); } if ($user) { $response = V4\UsersController::userResponse($user); } else { $response = []; } $response['status'] = 'success'; $response['access_token'] = $data->access_token; $response['refresh_token'] = $data->refresh_token; $response['token_type'] = 'bearer'; $response['expires_in'] = $data->expires_in; return response()->json($response); } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 00e11703..c366e68d 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,168 +1,168 @@ 'overrideValue1', * 'queue.connections.database.table' => 'overrideValue2', * ]; */ private function applyOverrideConfig(): void { $overrideConfig = (array) \config('override'); foreach (array_keys($overrideConfig) as $key) { \config([$key => $overrideConfig[$key]]); } } /** * Bootstrap any application services. */ public function boot(): void { \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); \App\EventLog::observe(\App\Observers\EventLogObserver::class); \App\Group::observe(\App\Observers\GroupObserver::class); \App\GroupSetting::observe(\App\Observers\GroupSettingObserver::class); \App\Meet\Room::observe(\App\Observers\Meet\RoomObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\Resource::observe(\App\Observers\ResourceObserver::class); \App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class); \App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class); \App\SharedFolderAlias::observe(\App\Observers\SharedFolderAliasObserver::class); \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\SignupToken::observe(\App\Observers\SignupTokenObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); \App\User::observe(\App\Observers\UserObserver::class); \App\UserAlias::observe(\App\Observers\UserAliasObserver::class); \App\UserSetting::observe(\App\Observers\UserSettingObserver::class); \App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class); \App\Wallet::observe(\App\Observers\WalletObserver::class); \App\PowerDNS\Domain::observe(\App\Observers\PowerDNS\DomainObserver::class); \App\PowerDNS\Record::observe(\App\Observers\PowerDNS\RecordObserver::class); Schema::defaultStringLength(191); // Register some template helpers Blade::directive( 'theme_asset', function ($path) { $path = trim($path, '/\'"'); return ""; } ); Builder::macro( 'withEnvTenantContext', function (string $table = null) { $tenantId = \config('app.tenant_id'); if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withObjectTenantContext', function (object $object, string $table = null) { $tenantId = $object->tenant_id; if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withSubjectTenantContext', function (string $table = null) { if ($user = auth()->user()) { $tenantId = $user->tenant_id; } else { $tenantId = \config('app.tenant_id'); } if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); // Query builder 'whereLike' mocro Builder::macro( 'whereLike', function (string $column, string $search, int $mode = 0) { $search = addcslashes($search, '%_'); switch ($mode) { case 2: $search .= '%'; break; case 1: $search = '%' . $search; break; default: $search = '%' . $search . '%'; } /** @var Builder $this */ return $this->where($column, 'like', $search); } ); Http::macro('withSlowLog', function () { return Http::withOptions([ 'on_stats' => function (\GuzzleHttp\TransferStats $stats) { $threshold = \config('logging.slow_log'); if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) { $url = $stats->getEffectiveUri(); $method = $stats->getRequest()->getMethod(); \Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec)); } }, ]); }); $this->applyOverrideConfig(); } } diff --git a/src/app/Providers/PassportServiceProvider.php b/src/app/Providers/PassportServiceProvider.php index 58ea5d91..b01c2d3c 100644 --- a/src/app/Providers/PassportServiceProvider.php +++ b/src/app/Providers/PassportServiceProvider.php @@ -1,73 +1,63 @@ 'Access API', 'mfa' => 'Access MFA API', 'fs' => 'Access Files API', - ]); + ]; + + Passport::tokensCan(array_merge($scopes, \config('openid.passport.tokens_can'))); Passport::tokensExpireIn(now()->addMinutes(\config('auth.token_expiry_minutes'))); Passport::refreshTokensExpireIn(now()->addMinutes(\config('auth.refresh_token_expiry_minutes'))); Passport::personalAccessTokensExpireIn(now()->addMonths(6)); Passport::useClientModel(\App\Auth\PassportClient::class); Passport::tokenModel()::observe(\App\Observers\Passport\TokenObserver::class); } - /** - * Make the authorization service instance. - * - * @return \League\OAuth2\Server\AuthorizationServer - */ - public function makeAuthorizationServer() - { - return new AuthorizationServer( - $this->app->make(Bridge\ClientRepository::class), - $this->app->make(Bridge\AccessTokenRepository::class), - $this->app->make(Bridge\ScopeRepository::class), - $this->makeCryptKey('private'), - $this->makeEncryptionKey(app('encrypter')->getKey()) - ); - } - - /** * Create a Key instance for encrypting the refresh token * * Based on https://github.com/laravel/passport/pull/820 * * @param string $keyBytes - * @return \Defuse\Crypto\Key + * @return \Defuse\Crypto\Key|string */ - private function makeEncryptionKey($keyBytes) + protected function getEncryptionKey($keyBytes) { // First, we will encode Laravel's encryption key into a format that the Defuse\Crypto\Key class can use, // so we can instantiate a new Key object. We need to do this as the Key class has a private constructor method // which means we cannot directly instantiate the class based on our Laravel encryption key. $encryptionKeyAscii = EncryptionEncoding::saveBytesToChecksummedAsciiSafeString( EncryptionKey::KEY_CURRENT_VERSION, $keyBytes ); // Instantiate a Key object so we can take advantage of significantly faster encryption/decryption // from https://github.com/thephpleague/oauth2-server/pull/814. The improvement is 200x-300x faster. return EncryptionKey::loadFromAsciiSafeString($encryptionKeyAscii); } } diff --git a/src/app/Utils.php b/src/app/Utils.php index 02e2fb82..e39417af 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,603 +1,617 @@ country ? $net->country : $fallback; } /** * Return the country ISO code for the current request. */ public static function countryForRequest() { $request = \request(); $ip = $request->ip(); return self::countryForIP($ip); } /** * Return the number of days in the month prior to this one. * * @return int */ public static function daysInLastMonth() { $start = new Carbon('first day of last month'); $end = new Carbon('last day of last month'); return $start->diffInDays($end) + 1; } + /** + * Default route handler + */ + public static function defaultView() + { + // Return 404 for requests to the API end-points that do not exist + if (strpos(request()->path(), 'api/') === 0) { + return \App\Http\Controllers\Controller::errorResponse(404); + } + + $env = self::uiEnv(); + return view($env['view'])->with('env', $env); + } + /** * Download a file from the interwebz and store it locally. * * @param string $source The source location * @param string $target The target location * @param bool $force Force the download (and overwrite target) * * @return void */ public static function downloadFile($source, $target, $force = false) { if (is_file($target) && !$force) { return; } \Log::info("Retrieving {$source}"); $fp = fopen($target, 'w'); $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $source); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_FILE, $fp); curl_exec($curl); if (curl_errno($curl)) { \Log::error("Request error on {$source}: " . curl_error($curl)); curl_close($curl); fclose($fp); unlink($target); return; } curl_close($curl); fclose($fp); } /** * Converts an email address to lower case. Keeps the LMTP shared folder * addresses character case intact. * * @param string $email Email address * * @return string Email address */ public static function emailToLower(string $email): string { // For LMTP shared folder address lower case the domain part only if (str_starts_with($email, 'shared+shared/')) { $pos = strrpos($email, '@'); $domain = substr($email, $pos + 1); $local = substr($email, 0, strlen($email) - strlen($domain) - 1); return $local . '@' . strtolower($domain); } return strtolower($email); } /** * Make sure that IMAP folder access rights contains "anyone: p" permission * * @param array $acl ACL (in form of "user, permission" records) * * @return array ACL list */ public static function ensureAclPostPermission(array $acl): array { foreach ($acl as $idx => $entry) { if (str_starts_with($entry, 'anyone,')) { if (strpos($entry, 'read-only')) { $acl[$idx] = 'anyone, lrsp'; } elseif (strpos($entry, 'read-write')) { $acl[$idx] = 'anyone, lrswitednp'; } return $acl; } } $acl[] = 'anyone, p'; return $acl; } /** * Generate a passphrase. Not intended for use in production, so limited to environments that are not production. * * @return string */ public static function generatePassphrase() { if (\config('app.env') != 'production') { if (\config('app.passphrase')) { return \config('app.passphrase'); } } $alphaLow = 'abcdefghijklmnopqrstuvwxyz'; $alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; $num = '0123456789'; $stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<'; $source = $alphaLow . $alphaUp . $num . $stdSpecial; $result = ''; for ($x = 0; $x < 16; $x++) { $result .= substr($source, rand(0, (strlen($source) - 1)), 1); } return $result; } /** * Find an object that is the recipient for the specified address. * * @param string $address * * @return array */ public static function findObjectsByRecipientAddress($address) { $address = \App\Utils::normalizeAddress($address); list($local, $domainName) = explode('@', $address); $domain = \App\Domain::where('namespace', $domainName)->first(); if (!$domain) { return []; } $user = \App\User::where('email', $address)->first(); if ($user) { return [$user]; } $userAliases = \App\UserAlias::where('alias', $address)->get(); if (count($userAliases) > 0) { $users = []; foreach ($userAliases as $userAlias) { $users[] = $userAlias->user; } return $users; } $userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get(); if (count($userAliases) > 0) { $users = []; foreach ($userAliases as $userAlias) { $users[] = $userAlias->user; } return $users; } return []; } /** * Retrieve the network ID and Type from a client address * * @param string $clientAddress The IPv4 or IPv6 address. * * @return array An array of ID and class or null and null. */ public static function getNetFromAddress($clientAddress) { if (strpos($clientAddress, ':') === false) { $net = \App\IP4Net::getNet($clientAddress); if ($net) { return [$net->id, \App\IP4Net::class]; } } else { $net = \App\IP6Net::getNet($clientAddress); if ($net) { return [$net->id, \App\IP6Net::class]; } } return [null, null]; } /** * Calculate the broadcast address provided a net number and a prefix. * * @param string $net A valid IPv6 network number. * @param int $prefix The network prefix. * * @return string */ public static function ip6Broadcast($net, $prefix) { $netHex = bin2hex(inet_pton($net)); // Overwriting first address string to make sure notation is optimal $net = inet_ntop(hex2bin($netHex)); // Calculate the number of 'flexible' bits $flexbits = 128 - $prefix; // Build the hexadecimal string of the last address $lastAddrHex = $netHex; // We start at the end of the string (which is always 32 characters long) $pos = 31; while ($flexbits > 0) { // Get the character at this position $orig = substr($lastAddrHex, $pos, 1); // Convert it to an integer $origval = hexdec($orig); // OR it with (2^flexbits)-1, with flexbits limited to 4 at a time $newval = $origval | (pow(2, min(4, $flexbits)) - 1); // Convert it back to a hexadecimal character $new = dechex($newval); // And put that character back in the string $lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1); // We processed one nibble, move to previous position $flexbits -= 4; $pos -= 1; } // Convert the hexadecimal string to a binary string $lastaddrbin = hex2bin($lastAddrHex); // And create an IPv6 address from the binary string $lastaddrstr = inet_ntop($lastaddrbin); return $lastaddrstr; } /** * Normalize an email address. * * This means to lowercase and strip components separated with recipient delimiters. * * @param ?string $address The address to normalize * @param bool $asArray Return an array with local and domain part * * @return string|array Normalized email address as string or array */ public static function normalizeAddress(?string $address, bool $asArray = false) { if ($address === null || $address === '') { return $asArray ? ['', ''] : ''; } $address = self::emailToLower($address); if (strpos($address, '@') === false) { return $asArray ? [$address, ''] : $address; } list($local, $domain) = explode('@', $address); if (strpos($local, '+') !== false) { $local = explode('+', $local)[0]; } return $asArray ? [$local, $domain] : "{$local}@{$domain}"; } /** * Returns the current user's email address or null. * * @return string */ public static function userEmailOrNull(): ?string { $user = Auth::user(); if (!$user) { return null; } return $user->email; } /** * Returns a random string consisting of a quantity of segments of a certain length joined. * * Example: * * ```php * $roomName = strtolower(\App\Utils::randStr(3, 3, '-'); * // $roomName == '3qb-7cs-cjj' * ``` * * @param int $length The length of each segment * @param int $qty The quantity of segments * @param string $join The string to use to join the segments * * @return string */ public static function randStr($length, $qty = 1, $join = '') { $chars = env('SHORTCODE_CHARS', self::CHARS); $randStrs = []; for ($x = 0; $x < $qty; $x++) { $string = []; for ($y = 0; $y < $length; $y++) { $string[] = $chars[rand(0, strlen($chars) - 1)]; } shuffle($string); $randStrs[$x] = implode('', $string); } return implode($join, $randStrs); } /** * Returns a UUID in the form of an integer. * * @return int */ public static function uuidInt(): int { $hex = self::uuidStr(); $bin = pack('h*', str_replace('-', '', $hex)); $ids = unpack('L', $bin); $id = array_shift($ids); return $id; } /** * Returns a UUID in the form of a string. * * @return string */ public static function uuidStr(): string { return (string) Str::uuid(); } /** * Create self URL * * @param string $route Route/Path/URL * @param int|null $tenantId Current tenant * * @todo Move this to App\Http\Controllers\Controller * * @return string Full URL */ public static function serviceUrl(string $route, $tenantId = null): string { if (preg_match('|^https?://|i', $route)) { return $route; } $url = \App\Tenant::getConfig($tenantId, 'app.public_url'); if (!$url) { $url = \App\Tenant::getConfig($tenantId, 'app.url'); } return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/'); } /** * Create a configuration/environment data to be passed to * the UI * * @todo Move this to App\Http\Controllers\Controller * * @return array Configuration data */ public static function uiEnv(): array { $countries = include resource_path('countries.php'); $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); $sys_domain = \config('app.domain'); $opts = [ 'app.name', 'app.url', 'app.domain', 'app.theme', 'app.webmail_url', 'app.support_email', 'app.company.copyright', 'app.companion_download_link', 'app.with_signup', 'mail.from.address' ]; $env = \app('config')->getMany($opts); $env['countries'] = $countries ?: []; $env['view'] = 'root'; $env['jsapp'] = 'user.js'; if ($req_domain == "admin.$sys_domain") { $env['jsapp'] = 'admin.js'; } elseif ($req_domain == "reseller.$sys_domain") { $env['jsapp'] = 'reseller.js'; } $env['paymentProvider'] = \config('services.payment_provider'); $env['stripePK'] = \config('services.stripe.public_key'); $env['languages'] = \App\Http\Controllers\ContentController::locales(); $env['menu'] = \App\Http\Controllers\ContentController::menu(); return $env; } /** * Set test exchange rates. * * @param array $rates: Exchange rates */ public static function setTestExchangeRates(array $rates): void { self::$testRates = $rates; } /** * Retrieve an exchange rate. * * @param string $sourceCurrency: Currency from which to convert * @param string $targetCurrency: Currency to convert to * * @return float Exchange rate */ public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float { if (strcasecmp($sourceCurrency, $targetCurrency) == 0) { return 1.0; } if (isset(self::$testRates[$targetCurrency])) { return floatval(self::$testRates[$targetCurrency]); } $currencyFile = resource_path("exchangerates-$sourceCurrency.php"); //Attempt to find the reverse exchange rate, if we don't have the file for the source currency if (!file_exists($currencyFile)) { $rates = include resource_path("exchangerates-$targetCurrency.php"); if (!isset($rates[$sourceCurrency])) { throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency); } return 1.0 / floatval($rates[$sourceCurrency]); } $rates = include $currencyFile; if (!isset($rates[$targetCurrency])) { throw new \Exception("Failed to find exchange rate for " . $targetCurrency); } return floatval($rates[$targetCurrency]); } /** * A helper to display human-readable amount of money using * for specified currency and locale. * * @param int $amount Amount of money (in cents) * @param string $currency Currency code * @param string $locale Output locale * * @return string String representation, e.g. "9.99 CHF" */ public static function money(int $amount, $currency, $locale = 'de_DE'): string { $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); $result = $nf->formatCurrency(round($amount / 100, 2), $currency); // Replace non-breaking space return str_replace("\xC2\xA0", " ", $result); } /** * A helper to display human-readable percent value * for specified currency and locale. * * @param int|float $percent Percent value (0 to 100) * @param string $locale Output locale * * @return string String representation, e.g. "0 %", "7.7 %" */ public static function percent(int|float $percent, $locale = 'de_DE'): string { $nf = new \NumberFormatter($locale, \NumberFormatter::PERCENT); $sep = $nf->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL); $result = sprintf('%.2F', $percent); $result = preg_replace('/\.00/', '', $result); $result = preg_replace('/(\.[0-9])0/', '\\1', $result); $result = str_replace('.', $sep, $result); return $result . ' %'; } } diff --git a/src/composer.json b/src/composer.json index fd5c9d4d..61c1cc4c 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,87 +1,88 @@ { "name": "kolab/kolab4", "type": "project", "description": "Kolab 4", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^8.1", "bacon/bacon-qr-code": "^2.0", "barryvdh/laravel-dompdf": "^2.0.1", "doctrine/dbal": "^3.6", "dyrynda/laravel-nullable-fields": "^4.3.0", "garethp/php-ews": "dev-master", "guzzlehttp/guzzle": "^7.8.0", + "jeremy379/laravel-openid-connect": "^2.3", "kolab/net_ldap3": "dev-master", "laravel/framework": "^10.15.0", "laravel/horizon": "^5.9", "laravel/octane": "^2.0", "laravel/passport": "^12.0", "laravel/tinker": "^2.8", "league/flysystem-aws-s3-v3": "^3.0", "mlocati/spf-lib": "^3.1", "mollie/laravel-mollie": "^2.22", "pear/crypt_gpg": "^1.6.6", "predis/predis": "^2.0", "sabre/vobject": "^4.5", "spatie/laravel-translatable": "^6.5", "spomky-labs/otphp": "~10.0.0", "stripe/stripe-php": "^10.7" }, "require-dev": { "code-lts/doctum": "^5.5.1", - "laravel/dusk": "~7.9.1", + "laravel/dusk": "~8.2.2", "mockery/mockery": "^1.5", "larastan/larastan": "^2.0", "phpstan/phpstan": "^1.4", "phpunit/phpunit": "^9", "squizlabs/php_codesniffer": "^3.6" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "stable", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } } diff --git a/src/config/auth.php b/src/config/auth.php index aa4496a3..83c86ab0 100644 --- a/src/config/auth.php +++ b/src/config/auth.php @@ -1,140 +1,145 @@ [ 'guard' => 'api', 'passwords' => 'users', ], /* |-------------------------------------------------------------------------- | Authentication Guards |-------------------------------------------------------------------------- | | Next, you may define every authentication guard for your application. | Of course, a great default configuration has been defined for you | here which uses session storage and the Eloquent user provider. | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | Supported: "session" | */ 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ 'driver' => 'passport', 'provider' => 'users', ], ], /* |-------------------------------------------------------------------------- | User Providers |-------------------------------------------------------------------------- | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | If you have multiple user tables or models you may configure multiple | sources which represent each model / table. These sources may then | be assigned to any extra authentication guards you have defined. | | Supported: "database", "eloquent" | */ 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => App\User::class, ], // 'users' => [ // 'driver' => 'database', // 'table' => 'users', // ], ], /* |-------------------------------------------------------------------------- | Resetting Passwords |-------------------------------------------------------------------------- | | You may specify multiple password reset configurations if you have more | than one user table or model in the application and you want to have | separate password reset settings based on the specific user types. | | The expire time is the number of minutes that each reset token will be | considered valid. This security feature keeps tokens short-lived so | they have less time to be guessed. You may change this as needed. | | The throttle setting is the number of seconds a user must wait before | generating more password reset tokens. This prevents the user from | quickly generating a very large amount of password reset tokens. | */ 'passwords' => [ 'users' => [ 'provider' => 'users', 'table' => 'password_resets', 'expire' => 60, 'throttle' => 60, ], ], /* |-------------------------------------------------------------------------- | Password Confirmation Timeout |-------------------------------------------------------------------------- | | Here you may define the amount of seconds before a password confirmation | times out and the user is prompted to re-enter their password via the | confirmation screen. By default, the timeout lasts for three hours. */ 'password_timeout' => 10800, /* |-------------------------------------------------------------------------- | OAuth Proxy Authentication |-------------------------------------------------------------------------- | | If you are planning to use your application to self-authenticate as a | proxy, you can define the client and grant type to use here. This is | sometimes the case when a trusted Single Page Application doesn't | use a backend to send the authentication request, but instead | relies on the API to handle proxying the request to itself. | */ 'proxy' => [ 'client_id' => env('PASSPORT_PROXY_OAUTH_CLIENT_ID'), 'client_secret' => env('PASSPORT_PROXY_OAUTH_CLIENT_SECRET'), ], + 'synapse' => [ + 'client_id' => env('PASSPORT_SYNAPSE_OAUTH_CLIENT_ID'), + 'client_secret' => env('PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET'), + ], + 'token_expiry_minutes' => env('OAUTH_TOKEN_EXPIRY', 60), 'refresh_token_expiry_minutes' => env('OAUTH_REFRESH_TOKEN_EXPIRY', 30 * 24 * 60), ]; diff --git a/src/config/openid.php b/src/config/openid.php new file mode 100644 index 00000000..c7659342 --- /dev/null +++ b/src/config/openid.php @@ -0,0 +1,85 @@ + [ + + /** + * Place your Passport and OpenID Connect scopes here. + * To receive an `id_token`, you should at least provide the openid scope. + */ + 'tokens_can' => [ + 'openid' => 'Enable OpenID Connect', + 'email' => 'Information about your email address', + // 'profile' => 'Information about your profile', + // 'phone' => 'Information about your phone numbers', + // 'address' => 'Information about your address', + // 'login' => 'See your login information', + ], + ], + + /** + * Place your custom claim sets here. + */ + 'custom_claim_sets' => [ + // 'login' => [ + // 'last-login', + // ], + // 'company' => [ + // 'company_name', + // 'company_address', + // 'company_phone', + // 'company_email', + // ], + ], + + /** + * You can override the repositories below. + */ + 'repositories' => [ + // 'identity' => \OpenIDConnect\Repositories\IdentityRepository::class, + 'identity' => \App\Auth\IdentityRepository::class, + ], + + 'routes' => [ + /** + * When set to true, this package will expose the OpenID Connect Discovery endpoint. + * - /.well-known/openid-configuration + */ + 'discovery' => true, + /** + * When set to true, this package will expose the JSON Web Key Set endpoint. + */ + 'jwks' => false, + /** + * Optional URL to change the JWKS path to align with your custom Passport routes. + * Defaults to /oauth/jwks + */ + 'jwks_url' => '/oauth/jwks', + ], + + /** + * Settings for the discovery endpoint + */ + 'discovery' => [ + /** + * Hide scopes that aren't from the OpenID Core spec from the Discovery, + * default = false (all scopes are listed) + */ + 'hide_scopes' => false, + ], + + /** + * The signer to be used + */ + 'signer' => \Lcobucci\JWT\Signer\Rsa\Sha256::class, + + /** + * Optional associative array that will be used to set headers on the JWT + */ + 'token_headers' => [], + + /** + * By default, microseconds are included. + */ + 'use_microseconds' => true, +]; diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js index 9bb51389..4421d3e8 100644 --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -1,194 +1,201 @@ import LoginComponent from '../../vue/Login' +import AuthorizeComponent from '../../vue/Authorize' import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' import PasswordResetComponent from '../../vue/PasswordReset' import SignupComponent from '../../vue/Signup' // Here's a list of lazy-loaded components // Note: you can pack multiple components into the same chunk, webpackChunkName // is also used to get a sensible file name instead of numbers const CompanionAppInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp/Info') const CompanionAppListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp/List') const DashboardComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Dashboard') const DistlistInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/Info') const DistlistListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/List') const DomainInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/Info') const DomainListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/List') const FileInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/Info') const FileListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/List') const PaymentStatusComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Payment/Status') const PoliciesComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Policies') const ResourceInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/Info') const ResourceListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/List') const RoomInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/Info') const RoomListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/List') const SharedFolderInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/Info') const SharedFolderListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/List') const UserInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Info') const UserListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/List') const WalletComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Wallet') const MeetComponent = () => import(/* webpackChunkName: "../user/meet" */ '../../vue/Meet/Room.vue') const routes = [ { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/distlist/:list', name: 'distlist', component: DistlistInfoComponent, meta: { requiresAuth: true, perm: 'distlists' } }, { path: '/distlists', name: 'distlists', component: DistlistListComponent, meta: { requiresAuth: true, perm: 'distlists' } }, { path: '/companion/:companion', name: 'companion', component: CompanionAppInfoComponent, meta: { requiresAuth: true, perm: 'companionapps' } }, { path: '/companions', name: 'companions', component: CompanionAppListComponent, meta: { requiresAuth: true, perm: 'companionapps' } }, { path: '/domain/:domain', name: 'domain', component: DomainInfoComponent, meta: { requiresAuth: true, perm: 'domains' } }, { path: '/domains', name: 'domains', component: DomainListComponent, meta: { requiresAuth: true, perm: 'domains' } }, { path: '/file/:file', name: 'file', component: FileInfoComponent, meta: { requiresAuth: true /*, perm: 'files' */ } }, { path: '/files/:parent?', name: 'files', component: FileListComponent, meta: { requiresAuth: true, perm: 'files' } }, + { + path: '/oauth/authorize', + name: 'authorize', + component: AuthorizeComponent, + meta: { requiresAuth: true } + }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { name: 'meet', path: '/meet/:room', component: MeetComponent, meta: { loading: true } }, { path: '/password-reset/:code?', name: 'password-reset', component: PasswordResetComponent }, { path: '/payment/status', name: 'payment-status', component: PaymentStatusComponent, meta: { requiresAuth: true } }, { path: '/resource/:resource', name: 'resource', component: ResourceInfoComponent, meta: { requiresAuth: true, perm: 'resources' } }, { path: '/resources', name: 'resources', component: ResourceListComponent, meta: { requiresAuth: true, perm: 'resources' } }, { path: '/room/:room', name: 'room', component: RoomInfoComponent, meta: { requiresAuth: true, perm: 'rooms' } }, { path: '/rooms', name: 'rooms', component: RoomListComponent, meta: { requiresAuth: true, perm: 'rooms' } }, { path: '/policies', name: 'policies', component: PoliciesComponent, meta: { requiresAuth: true, perm: 'settings' } }, { path: '/settings', name: 'settings', component: UserInfoComponent, meta: { requiresAuth: true } }, { path: '/shared-folder/:folder', name: 'shared-folder', component: SharedFolderInfoComponent, meta: { requiresAuth: true, perm: 'folders' } }, { path: '/shared-folders', name: 'shared-folders', component: SharedFolderListComponent, meta: { requiresAuth: true, perm: 'folders' } }, { path: '/signup', alias: '/signup/*', name: 'signup', component: SignupComponent }, { path: '/user/:user', name: 'user', component: UserInfoComponent, meta: { requiresAuth: true, perm: 'users' } }, { path: '/users', name: 'users', component: UserListComponent, meta: { requiresAuth: true, perm: 'users' } }, { path: '/wallet', name: 'wallet', component: WalletComponent, meta: { requiresAuth: true, perm: 'wallets' } }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php index 3418d0d6..740ebee0 100644 --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -1,27 +1,28 @@ 'Invalid username or password.', 'password' => 'The provided password is incorrect.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'logoutsuccess' => 'Successfully logged out.', 'error.password' => "Invalid password", + 'error.invalidrequest' => "Invalid authorization request.", 'error.geolocation' => "Country code mismatch", 'error.nofound' => "User not found", 'error.2fa' => "Second factor failure", 'error.2fa-generic' => "Second factor failure", ]; diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php index 8df02dda..3d05081a 100644 --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -1,206 +1,207 @@ 'The :attribute must be accepted.', 'accepted_if' => 'The :attribute must be accepted when :other is :value.', 'active_url' => 'The :attribute is not a valid URL.', 'after' => 'The :attribute must be a date after :date.', 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', 'alpha' => 'The :attribute must only contain letters.', 'alpha_dash' => 'The :attribute must only contain letters, numbers, dashes and underscores.', 'alpha_num' => 'The :attribute must only contain letters and numbers.', 'array' => 'The :attribute must be an array.', 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'between' => [ 'numeric' => 'The :attribute must be between :min and :max.', 'file' => 'The :attribute must be between :min and :max kilobytes.', 'string' => 'The :attribute must be between :min and :max characters.', 'array' => 'The :attribute must have between :min and :max items.', ], 'boolean' => 'The :attribute field must be true or false.', 'confirmed' => 'The :attribute confirmation does not match.', 'current_password' => 'The password is incorrect.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', 'declined' => 'The :attribute must be declined.', 'declined_if' => 'The :attribute must be declined when :other is :value.', 'different' => 'The :attribute and :other must be different.', 'digits' => 'The :attribute must be :digits digits.', 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', 'email' => 'The :attribute must be a valid email address.', 'ends_with' => 'The :attribute must end with one of the following: :values', 'enum' => 'The selected :attribute is invalid.', 'exists' => 'The selected :attribute is invalid.', 'file' => 'The :attribute must be a file.', 'filled' => 'The :attribute field must have a value.', 'gt' => [ 'numeric' => 'The :attribute must be greater than :value.', 'file' => 'The :attribute must be greater than :value kilobytes.', 'string' => 'The :attribute must be greater than :value characters.', 'array' => 'The :attribute must have more than :value items.', ], 'gte' => [ 'numeric' => 'The :attribute must be greater than or equal :value.', 'file' => 'The :attribute must be greater than or equal :value kilobytes.', 'string' => 'The :attribute must be greater than or equal :value characters.', 'array' => 'The :attribute must have :value items or more.', ], 'image' => 'The :attribute must be an image.', 'in' => 'The selected :attribute is invalid.', 'in_array' => 'The :attribute field does not exist in :other.', 'integer' => 'The :attribute must be an integer.', 'ip' => 'The :attribute must be a valid IP address.', 'ipv4' => 'The :attribute must be a valid IPv4 address.', 'ipv6' => 'The :attribute must be a valid IPv6 address.', 'json' => 'The :attribute must be a valid JSON string.', 'lt' => [ 'numeric' => 'The :attribute must be less than :value.', 'file' => 'The :attribute must be less than :value kilobytes.', 'string' => 'The :attribute must be less than :value characters.', 'array' => 'The :attribute must have less than :value items.', ], 'lte' => [ 'numeric' => 'The :attribute must be less than or equal :value.', 'file' => 'The :attribute must be less than or equal :value kilobytes.', 'string' => 'The :attribute must be less than or equal :value characters.', 'array' => 'The :attribute must not have more than :value items.', ], 'max' => [ 'numeric' => 'The :attribute may not be greater than :max.', 'file' => 'The :attribute may not be greater than :max kilobytes.', 'string' => 'The :attribute may not be greater than :max characters.', 'array' => 'The :attribute may not have more than :max items.', ], 'mac_address' => 'The :attribute must be a valid MAC address.', 'mimes' => 'The :attribute must be a file of type: :values.', 'mimetypes' => 'The :attribute must be a file of type: :values.', 'min' => [ 'numeric' => 'The :attribute must be at least :min.', 'file' => 'The :attribute must be at least :min kilobytes.', 'string' => 'The :attribute must be at least :min characters.', 'array' => 'The :attribute must have at least :min items.', ], 'multiple_of' => 'The :attribute must be a multiple of :value.', 'not_in' => 'The selected :attribute is invalid.', 'not_regex' => 'The :attribute format is invalid.', 'numeric' => 'The :attribute must be a number.', 'present' => 'The :attribute field must be present.', 'prohibited' => 'The :attribute field is prohibited.', 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', 'prohibits' => 'The :attribute field prohibits :other from being present.', 'regex' => 'The :attribute format is invalid.', 'regex_format' => 'The :attribute does not match the format :format.', 'required' => 'The :attribute field is required.', 'required_array_keys' => 'The :attribute field must contain entries for: :values.', 'required_if' => 'The :attribute field is required when :other is :value.', 'required_unless' => 'The :attribute field is required unless :other is in :values.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_with_all' => 'The :attribute field is required when :values are present.', 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', 'same' => 'The :attribute and :other must match.', 'size' => [ 'numeric' => 'The :attribute must be :size.', 'file' => 'The :attribute must be :size kilobytes.', 'string' => 'The :attribute must be :size characters.', 'array' => 'The :attribute must contain :size items.', ], 'starts_with' => 'The :attribute must start with one of the following: :values', 'string' => 'The :attribute must be a string.', 'timezone' => 'The :attribute must be a valid timezone.', 'unique' => 'The :attribute has already been taken.', 'uploaded' => 'The :attribute failed to upload.', 'url' => 'The :attribute must be a valid URL.', 'uuid' => 'The :attribute must be a valid UUID.', + 'invalidvalueof' => 'Invalid value of request property: :attribute.', '2fareq' => 'Second factor code is required.', '2fainvalid' => 'Second factor code is invalid.', 'emailinvalid' => 'The specified email address is invalid.', 'domaininvalid' => 'The specified domain is invalid.', 'domainnotavailable' => 'The specified domain is not available.', 'logininvalid' => 'The specified login is invalid.', 'loginexists' => 'The specified login is not available.', 'domainexists' => 'The specified domain is not available.', 'noemailorphone' => 'The specified text is neither a valid email address nor a phone number.', 'packageinvalid' => 'Invalid package selected.', 'packagerequired' => 'Package is required.', 'usernotexists' => 'Unable to find user.', 'voucherinvalid' => 'The voucher code is invalid or expired.', 'noextemail' => 'This user has no external email address.', 'entryinvalid' => 'The specified :attribute is invalid.', 'entryexists' => 'The specified :attribute is not available.', 'minamount' => 'Minimum amount for a single payment is :amount.', 'minamountdebt' => 'The specified amount does not cover the balance on the account.', 'notalocaluser' => 'The specified email address does not exist.', 'memberislist' => 'A recipient cannot be the same as the list address.', 'listmembersrequired' => 'At least one recipient is required.', 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.', 'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.', 'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.', 'acl-permission-invalid' => 'The specified permission is invalid.', 'file-perm-exists' => 'File permission already exists.', 'file-perm-invalid' => 'The file permission is invalid.', 'file-name-exists' => 'The file name already exists.', 'file-name-invalid' => 'The file name is invalid.', 'file-name-toolong' => 'The file name is too long.', 'fsparentunknown' => 'Specified parent does not exist.', 'geolockinerror' => 'The request location is not allowed.', 'ipolicy-invalid' => 'The specified invitation policy is invalid.', 'invalid-config-parameter' => 'The requested configuration parameter is not supported.', 'nameexists' => 'The specified name is not available.', 'nameinvalid' => 'The specified name is invalid.', 'password-policy-error' => 'Specified password does not comply with the policy.', 'invalid-limit-geo' => 'Specified configuration is invalid. Expected a list of two-letter country codes.', 'invalid-password-policy' => 'Specified password policy is invalid.', 'password-policy-min-len-error' => 'Minimum password length cannot be less than :min.', 'password-policy-max-len-error' => 'Maximum password length cannot be more than :max.', 'password-policy-last-error' => 'The minimum value for last N passwords is :last.', 'signuptokeninvalid' => 'The signup token is invalid.', /* |-------------------------------------------------------------------------- | Custom Validation Language Lines |-------------------------------------------------------------------------- | | Here you may specify custom validation messages for attributes using the | convention "attribute.rule" to name the lines. This makes it quick to | specify a specific custom language line for a given attribute rule. | */ 'custom' => [ 'attribute-name' => [ 'rule-name' => 'custom-message', ], ], /* |-------------------------------------------------------------------------- | Custom Validation Attributes |-------------------------------------------------------------------------- | | The following language lines are used to swap our attribute placeholder | with something more reader friendly such as "E-Mail Address" instead | of "email". This simply helps us make our message more expressive. | */ 'attributes' => [], ]; diff --git a/src/resources/vue/Authorize.vue b/src/resources/vue/Authorize.vue new file mode 100644 index 00000000..d17eed8f --- /dev/null +++ b/src/resources/vue/Authorize.vue @@ -0,0 +1,31 @@ + + + + diff --git a/src/routes/api.php b/src/routes/api.php index 027f6c0d..74cdf800 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,339 +1,342 @@ middleware(['auth:api']); + Route::group( [ 'middleware' => 'api', 'prefix' => 'auth' ], function () { Route::post('login', [API\AuthController::class, 'login']); Route::group( - ['middleware' => 'auth:api'], + ['middleware' => ['auth:api', 'scope:api']], function () { Route::get('info', [API\AuthController::class, 'info']); Route::post('info', [API\AuthController::class, 'info']); Route::get('location', [API\AuthController::class, 'location']); Route::post('logout', [API\AuthController::class, 'logout']); Route::post('refresh', [API\AuthController::class, 'refresh']); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function () { Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']); Route::post('password-reset/init', [API\PasswordResetController::class, 'init']); Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']); Route::post('password-reset', [API\PasswordResetController::class, 'reset']); } ); if (\config('app.with_signup')) { Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function () { Route::get('signup/domains', [API\SignupController::class, 'domains']); Route::post('signup/init', [API\SignupController::class, 'init']); Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']); Route::get('signup/plans', [API\SignupController::class, 'plans']); Route::post('signup/validate', [API\SignupController::class, 'signupValidate']); Route::post('signup/verify', [API\SignupController::class, 'verify']); Route::post('signup', [API\SignupController::class, 'signup']); } ); } Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => ['auth:api', 'scope:mfa,api'], 'prefix' => 'v4' ], function () { Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']); Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']); Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']); Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']); Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']); } ); if (\config('app.with_files')) { Route::group( [ 'middleware' => ['auth:api', 'scope:fs,api'], 'prefix' => 'v4' ], function () { Route::apiResource('fs', API\V4\FsController::class); Route::get('fs/{itemId}/permissions', [API\V4\FsController::class, 'getPermissions']); Route::post('fs/{itemId}/permissions', [API\V4\FsController::class, 'createPermission']); Route::put('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'updatePermission']); Route::delete('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'deletePermission']); } ); Route::group( [ 'middleware' => [], 'prefix' => 'v4' ], function () { Route::post('fs/uploads/{id}', [API\V4\FsController::class, 'upload']) ->middleware(['api']); Route::get('fs/downloads/{id}', [API\V4\FsController::class, 'download']); } ); } Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => ['auth:api', 'scope:api'], 'prefix' => 'v4' ], function () { Route::apiResource('companions', API\V4\CompanionAppsController::class); // This must not be accessible with the 2fa token, // to prevent an attacker from pairing a new device with a stolen token. Route::get('companions/{id}/pairing', [API\V4\CompanionAppsController::class, 'pairing']); Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']); Route::get('domains/{id}/skus', [API\V4\DomainsController::class, 'skus']); Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']); Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/skus', [API\V4\GroupsController::class, 'skus']); Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']); Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('rooms', API\V4\RoomsController::class); Route::post('rooms/{id}/config', [API\V4\RoomsController::class, 'setConfig']); Route::get('rooms/{id}/skus', [API\V4\RoomsController::class, 'skus']); Route::post('meet/rooms/{id}', [API\V4\MeetController::class, 'joinRoom']) ->withoutMiddleware(['auth:api', 'scope:api']); Route::apiResource('resources', API\V4\ResourcesController::class); Route::get('resources/{id}/skus', [API\V4\ResourcesController::class, 'skus']); Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']); Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']); Route::apiResource('shared-folders', API\V4\SharedFoldersController::class); Route::get('shared-folders/{id}/skus', [API\V4\SharedFoldersController::class, 'skus']); Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']); Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']); Route::get('users/{id}/skus', [API\V4\UsersController::class, 'skus']); Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']); Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']); Route::get('password-policy', [API\PasswordPolicyController::class, 'index']); Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']); Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']); Route::post('payments', [API\V4\PaymentsController::class, 'store']); //Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']); Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']); Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']); Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']); Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']); Route::post('payments/mandate/reset', [API\V4\PaymentsController::class, 'mandateReset']); Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']); Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']); Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']); Route::get('payments/status', [API\V4\PaymentsController::class, 'paymentStatus']); Route::get('search/self', [API\V4\SearchController::class, 'searchSelf']); if (\config('app.with_user_search')) { Route::get('search/user', [API\V4\SearchController::class, 'searchUser']); } Route::post('support/request', [API\V4\SupportController::class, 'request']) ->withoutMiddleware(['auth:api', 'scope:api']) ->middleware(['api']); Route::get('vpn/token', [API\V4\VPNController::class, 'token']); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => 'webhooks' ], function () { Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']); Route::post('meet', [API\V4\MeetController::class, 'webhook']); } ); if (\config('app.with_services')) { Route::group( [ 'middleware' => ['allowedHosts'], 'prefix' => 'webhooks' ], function () { Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']); Route::get('nginx-roundcube', [API\V4\NGINXController::class, 'authenticateRoundcube']); Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']); Route::post('cyrus-sasl', [API\V4\NGINXController::class, 'cyrussasl']); Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']); Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']); Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']); } ); } Route::get('health/readiness', [API\V4\HealthController::class, 'readiness']); Route::get('health/liveness', [API\V4\HealthController::class, 'liveness']); if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/skus', [API\V4\Admin\DomainsController::class, 'skus']); Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']); Route::get('eventlog/{type}/{id}', [API\V4\Admin\EventLogController::class, 'index']); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']); Route::apiResource('resources', API\V4\Admin\ResourcesController::class); Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::get('users/{id}/discounts', [API\V4\Admin\DiscountsController::class, 'userDiscounts']); Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']); Route::post('users/{id}/resetGeoLock', [API\V4\Admin\UsersController::class, 'resetGeoLock']); Route::post('users/{id}/resync', [API\V4\Admin\UsersController::class, 'resync']); Route::get('users/{id}/skus', [API\V4\Admin\UsersController::class, 'skus']); Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']); Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']); Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']); Route::get('wallets/{id}/receipts', [API\V4\Admin\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Admin\WalletsController::class, 'receiptDownload']); Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']); Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']); } ); Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'prefix' => 'v4', ], function () { Route::get('inspect-request', [API\V4\Admin\UsersController::class, 'inspectRequest']); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::get('domains/{id}/skus', [API\V4\Reseller\DomainsController::class, 'skus']); Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']); Route::get('eventlog/{type}/{id}', [API\V4\Reseller\EventLogController::class, 'index']); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']); Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']); Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']); Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']); Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']); Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']); Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']); Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']); Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, 'hasPayments']); Route::apiResource('resources', API\V4\Reseller\ResourcesController::class); Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']); Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']); Route::post('users/{id}/resetGeoLock', [API\V4\Reseller\UsersController::class, 'resetGeoLock']); Route::post('users/{id}/resync', [API\V4\Reseller\UsersController::class, 'resync']); Route::get('users/{id}/skus', [API\V4\Reseller\UsersController::class, 'skus']); Route::post('users/{id}/skus/{sku}', [API\V4\Reseller\UsersController::class, 'setSku']); Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']); Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']); Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']); Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']); Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']); } ); } diff --git a/src/routes/web.php b/src/routes/web.php index e27a07c5..35582e53 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -1,71 +1,66 @@ noContent(); }); // We can handle every URL with the default action because // we have client-side router (including 404 error handler). // This way we don't have to define any "deep link" routes here. Route::group( [ //'domain' => \config('app.website_domain'), ], function () { - Route::get('content/page/{page}', Controllers\ContentController::class . '@pageContent') + Route::get('content/page/{page}', [Controllers\ContentController::class. 'pageContent']) ->where('page', '(.*)'); - Route::get('content/faq/{page}', Controllers\ContentController::class . '@faqContent') + Route::get('content/faq/{page}', [Controllers\ContentController::class, 'faqContent']) ->where('page', '(.*)'); - Route::fallback( - function () { - // Return 404 for requests to the API end-points that do not exist - if (strpos(request()->path(), 'api/') === 0) { - return Controllers\Controller::errorResponse(404); - } - - $env = \App\Utils::uiEnv(); - return view($env['view'])->with('env', $env); - } - ); + Route::fallback([\App\Utils::class, 'defaultView']); } ); Route::group( [ 'prefix' => 'oauth' ], function () { // We manually specify a subset of endpoints from https://github.com/laravel/passport/blob/11.x/routes/web.php // after having disabled automatic routes via Passport::ignoreRoutes() - Route::post('/token', [ - 'uses' => '\Laravel\Passport\Http\Controllers\AccessTokenController@issueToken', - 'as' => 'token', - // 'middleware' => 'throttle', - ]); + Route::post('/token', [PassportControllers\AccessTokenController::class, 'issueToken']) + ->name('passport.token'); // needed for .well-known/openid-configuration handler Route::middleware(['web', 'auth'])->group(function () { - Route::get('/tokens', [ - 'uses' => '\Laravel\Passport\Http\Controllers\AuthorizedAccessTokenController@forUser', - 'as' => 'tokens.index', - ]); + Route::get('/tokens', [PassportControllers\AuthorizedAccessTokenController::class, 'forUser']) + ->name('passport.tokens.index'); - Route::delete('/tokens/{token_id}', [ - 'uses' => '\Laravel\Passport\Http\Controllers\AuthorizedAccessTokenController@destroy', - 'as' => 'tokens.destroy', - ]); + Route::delete('/tokens/{token_id}', [PassportControllers\AuthorizedAccessTokenController::class, 'destroy']) + ->name('passport.tokens.destroy'); }); + + // TODO: Enable CORS on this endpoint, it is "SHOULD" in OIDC spec. + // TODO: More scopes e.g. profile + // TODO: This should be both GET and POST per OIDC spec. GET is recommended though. + Route::get('/userinfo', [Controllers\API\AuthController::class, 'oauthUserInfo']) + ->middleware(['auth:api', 'scope:email']) + ->name('openid.userinfo'); // needed for .well-known/openid-configuration handler + + Route::get('/authorize', [\App\Utils::class, 'defaultView']) + ->name('passport.authorizations.authorize'); // needed for .well-known/openid-configuration handler } ); Route::group( [ 'prefix' => '.well-known' ], function () { - Route::get('/mta-sts.txt', [Controllers\WellKnownController::class, "mtaSts"]); + // .well-known/openid-configuration is handled by an external package (see config/openid.php) + Route::get('/mta-sts.txt', [Controllers\WellKnownController::class, 'mtaSts']); } ); diff --git a/src/tests/Browser/AuthorizeTest.php b/src/tests/Browser/AuthorizeTest.php new file mode 100644 index 00000000..5c898343 --- /dev/null +++ b/src/tests/Browser/AuthorizeTest.php @@ -0,0 +1,80 @@ +client = \App\Auth\PassportClient::firstOrCreate( + ['id' => 'test'], + [ + 'user_id' => null, + 'name' => 'Test', + 'secret' => '123', + 'provider' => 'users', + 'redirect' => 'https://kolab.org', + 'personal_access_client' => 0, + 'password_client' => 0, + 'revoked' => false, + 'allowed_scopes' => ['email'], + ] + ); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->client->delete(); + + parent::tearDown(); + } + + /** + * Test /oauth/authorize page + */ + public function testAuthorize(): void + { + $url = '/oauth/authorize?' . http_build_query([ + 'client_id' => $this->client->id, + 'response_type' => 'code', + 'scope' => 'email', + 'state' => 'state', + ]); + + $this->browse(function (Browser $browser) use ($url) { + $redirect_check = "window.location.host == 'kolab.org'" + . " && window.location.search.match(/^\?code=[a-f0-9]+&state=state/)"; + + // Unauthenticated user + $browser->visit($url) + ->on(new Home()) + ->submitLogon('john@kolab.org', 'simple123') + ->waitUntil($redirect_check); + + // Authenticated user + $browser->visit($url) + ->waitUntil($redirect_check); + + // Error handling (invalid response_type) + $browser->visit('oauth/authorize?response_type=invalid') + ->assertErrorPage(422) + ->assertToast(Toast::TYPE_ERROR, 'Invalid value of request property: response_type.'); + }); + } +} diff --git a/src/tests/Browser/Components/Error.php b/src/tests/Browser/Components/Error.php index 4214906d..cc9d3947 100644 --- a/src/tests/Browser/Components/Error.php +++ b/src/tests/Browser/Components/Error.php @@ -1,76 +1,77 @@ "Bad request", 401 => "Unauthorized", 403 => "Access denied", 404 => "Not found", 405 => "Method not allowed", + 422 => "Unprocessable Content", 500 => "Internal server error", ]; public function __construct($code, $hint = '') { $this->code = $code; $this->hint = $hint; $this->message = $this->messages_map[$code]; } /** * Get the root selector for the component. * * @return string */ public function selector() { return '#error-page'; } /** * Assert that the browser page contains the component. * * @param \Laravel\Dusk\Browser $browser * * @return void */ public function assert($browser) { $browser->waitFor($this->selector()) ->assertSeeIn('@code', $this->code); if ($this->hint) { $browser->assertSeeIn('@hint', $this->hint); } else { $browser->assertMissing('@hint'); } $message = $browser->text('@message'); PHPUnit::assertSame(strtolower($message), strtolower($this->message)); } /** * Get the element shortcuts for the component. * * @return array */ public function elements() { $selector = $this->selector(); return [ '@code' => "$selector .code", '@message' => "$selector .message", '@hint' => "$selector .hint", ]; } } diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php index 0c04e7a0..075b274f 100644 --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -1,329 +1,584 @@ app['auth']->guard($guard); - - if ($guard instanceof \Illuminate\Auth\SessionGuard) { - $guard->logout(); - } - } - - $protectedProperty = new \ReflectionProperty($this->app['auth'], 'guards'); - $protectedProperty->setAccessible(true); - $protectedProperty->setValue($this->app['auth'], []); + $this->app['auth']->forgetGuards(); } /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); $this->expectedExpiry = \config('auth.token_expiry_minutes') * 60; \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $user = $this->getTestUser('john@kolab.org'); $user->setSetting('limit_geo', null); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $user = $this->getTestUser('john@kolab.org'); $user->setSetting('limit_geo', null); parent::tearDown(); } /** * Test fetching current user info (/api/auth/info) */ public function testInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com', ['status' => User::STATUS_NEW]); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $response = $this->get("api/auth/info"); $response->assertStatus(401); $response = $this->actingAs($user)->get("api/auth/info"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(!isset($json['access_token'])); // Note: Details of the content are tested in testUserResponse() // Test token refresh via the info request // First we log in to get the refresh token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $user = $this->getTestUser('john@kolab.org'); $response = $this->post("api/auth/login", $post); $json = $response->json(); $response = $this->actingAs($user) ->post("api/auth/info?refresh=1", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('john@kolab.org', $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue(!empty($json['expires_in'])); } /** * Test fetching current user location (/api/auth/location) */ public function testLocation(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); // Authentication required $response = $this->get("api/auth/location"); $response->assertStatus(401); $headers = ['X-Client-IP' => '127.0.0.2']; $response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('127.0.0.2', $json['ipAddress']); $this->assertSame('', $json['countryCode']); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('127.0.0.2', $json['ipAddress']); $this->assertSame('US', $json['countryCode']); } /** * Test /api/auth/login */ public function testLogin(): string { $user = $this->getTestUser('john@kolab.org'); // Request with no data $response = $this->post("api/auth/login", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Request with invalid password $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; $response = $this->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid username or password.', $json['message']); // Valid user+password $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); // Valid user+password (upper-case) $post = ['email' => 'John@Kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); // TODO: We have browser tests for 2FA but we should probably also test it here return $json['access_token']; } /** * Test /api/auth/login with geo-lockin */ public function testLoginGeoLock(): void { $user = $this->getTestUser('john@kolab.org'); $user->setConfig(['limit_geo' => ['US']]); $headers['X-Client-IP'] = '127.0.0.2'; $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->withHeaders($headers)->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame("Invalid username or password.", $json['message']); $this->assertSame('error', $json['status']); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->withHeaders($headers)->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertEquals($user->id, $json['id']); } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); // Request with invalid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . "foobar"])->post("api/auth/logout"); $response->assertStatus(401); // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Successfully logged out.', $json['message']); $this->resetAuth(); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } /** * Test /api/auth/refresh */ public function testRefresh(): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/refresh"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/refresh", []); $response->assertStatus(401); // Login the user to get a valid token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $token = $json['access_token']; $user = $this->getTestUser('john@kolab.org'); // Request with a valid token $response = $this->actingAs($user)->post("api/auth/refresh", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue($json['access_token'] != $token); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); $new_token = $json['access_token']; // TODO: Shall we invalidate the old token? // And if the new token is working $response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info"); $response->assertStatus(200); } + + /** + * Test OAuth2 Authorization Code Flow + */ + public function testOAuthAuthorizationCodeFlow(): void + { + $user = $this->getTestUser('john@kolab.org'); + + // Request unauthenticated, testing that it requires auth + $response = $this->post("api/oauth/approve"); + $response->assertStatus(401); + + // Request authenticated, invalid POST data + $post = ['response_type' => 'unknown']; + $response = $this->actingAs($user)->post("api/oauth/approve", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame('Invalid value of request property: response_type.', $json['message']); + + // Request authenticated, invalid POST data + $post = [ + 'client_id' => 'unknown', + 'response_type' => 'code', + 'scope' => 'email', // space-separated + 'state' => 'state', // optional + 'nonce' => 'nonce', // optional + ]; + $response = $this->actingAs($user)->post("api/oauth/approve", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame('Client authentication failed', $json['message']); + + $client = \App\Auth\PassportClient::find(\config('auth.synapse.client_id')); + + $post['client_id'] = $client->id; + + // Request authenticated, invalid scope + $post['scope'] = 'unknown'; + $response = $this->actingAs($user)->post("api/oauth/approve", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame('The requested scope is invalid, unknown, or malformed', $json['message']); + + // Request authenticated, valid POST data + $post['scope'] = 'email'; + $response = $this->actingAs($user)->post("api/oauth/approve", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $url = $json['redirectUrl']; + parse_str(parse_url($url, \PHP_URL_QUERY), $params); + + $this->assertTrue(str_starts_with($url, $client->redirect . '?')); + $this->assertCount(2, $params); + $this->assertSame('state', $params['state']); + $this->assertMatchesRegularExpression('/^[a-f0-9]{50,}$/', $params['code']); + $this->assertSame('success', $json['status']); + + // Note: We do not validate the code trusting Passport to do the right thing. Should we not? + + // Token endpoint tests + + // Valid authorization code, but invalid secret + $post = [ + 'grant_type' => 'authorization_code', + 'client_id' => $client->id, + 'client_secret' => 'invalid', + // 'redirect_uri' => '', + 'code' => $params['code'], + ]; + + // Note: This is a 'web' route, not 'api' + $this->resetAuth(); // reset guards + $response = $this->post("/oauth/token", $post); + $response->assertStatus(401); + + $json = $response->json(); + + $this->assertSame('invalid_client', $json['error']); + $this->assertTrue(!empty($json['error_description'])); + + // Valid authorization code + $post['client_secret'] = \config('auth.synapse.client_secret'); + $response = $this->post("/oauth/token", $post); + $response->assertStatus(200); + + $params = $response->json(); + + $this->assertSame('Bearer', $params['token_type']); + $this->assertTrue(!empty($params['access_token'])); + $this->assertTrue(!empty($params['refresh_token'])); + $this->assertTrue(!empty($params['expires_in'])); + $this->assertTrue(empty($params['id_token'])); + + // Invalid authorization code + // Note: The code is being revoked on use, so we expect it does not work anymore + $response = $this->post("/oauth/token", $post); + $response->assertStatus(400); + + $json = $response->json(); + + $this->assertSame('invalid_request', $json['error']); + $this->assertTrue(!empty($json['error_description'])); + + // Token refresh + unset($post['code']); + $post['grant_type'] = 'refresh_token'; + $post['refresh_token'] = $params['refresh_token']; + + $response = $this->post("/oauth/token", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('Bearer', $json['token_type']); + $this->assertTrue(!empty($json['access_token'])); + $this->assertTrue(!empty($json['refresh_token'])); + $this->assertTrue(!empty($json['expires_in'])); + $this->assertTrue(empty($json['id_token'])); + $this->assertNotEquals($json['access_token'], $params['access_token']); + $this->assertNotEquals($json['refresh_token'], $params['refresh_token']); + + $token = $json['access_token']; + + // Validate the access token works on /oauth/userinfo endpoint + $this->resetAuth(); // reset guards + $headers = ['Authorization' => 'Bearer ' . $token]; + $response = $this->withHeaders($headers)->get("/oauth/userinfo"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals($user->id, $json['sub']); + $this->assertEquals($user->email, $json['email']); + + // Validate that the access token does not give access to API other than /oauth/userinfo + $this->resetAuth(); // reset guards + $response = $this->withHeaders($headers)->get("/api/auth/location"); + $response->assertStatus(403); + } + + /** + * Test OpenID-Connect Authorization Code Flow + */ + public function testOIDCAuthorizationCodeFlow(): void + { + $user = $this->getTestUser('john@kolab.org'); + $client = \App\Auth\PassportClient::find(\config('auth.synapse.client_id')); + + // Note: Invalid input cases were tested above, we omit them here + + // This is essentially the same as for OAuth2, but with extended scope + $post = [ + 'client_id' => $client->id, + 'response_type' => 'code', + 'scope' => 'openid email', + 'state' => 'state', + 'nonce' => 'nonce', + ]; + + $response = $this->actingAs($user)->post("api/oauth/approve", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $url = $json['redirectUrl']; + parse_str(parse_url($url, \PHP_URL_QUERY), $params); + + $this->assertTrue(str_starts_with($url, $client->redirect . '?')); + $this->assertCount(2, $params); + $this->assertSame('state', $params['state']); + $this->assertMatchesRegularExpression('/^[a-f0-9]{50,}$/', $params['code']); + $this->assertSame('success', $json['status']); + + // Token endpoint tests + $post = [ + 'grant_type' => 'authorization_code', + 'client_id' => $client->id, + 'client_secret' => \config('auth.synapse.client_secret'), + 'code' => $params['code'], + ]; + + $this->resetAuth(); // reset guards state + $response = $this->post("/oauth/token", $post); + $response->assertStatus(200); + + $params = $response->json(); + + $this->assertSame('Bearer', $params['token_type']); + $this->assertTrue(!empty($params['access_token'])); + $this->assertTrue(!empty($params['refresh_token'])); + $this->assertTrue(!empty($params['id_token'])); + $this->assertTrue(!empty($params['expires_in'])); + + $token = $this->parseIdToken($params['id_token']); + + $this->assertSame('JWT', $token['typ']); + $this->assertSame('RS256', $token['alg']); + $this->assertSame(url('/'), $token['iss']); + $this->assertSame($user->email, $token['email']); + + // TODO: Validate JWT token properly + + // Token refresh + unset($post['code']); + $post['grant_type'] = 'refresh_token'; + $post['refresh_token'] = $params['refresh_token']; + + $response = $this->post("/oauth/token", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('Bearer', $json['token_type']); + $this->assertTrue(!empty($json['access_token'])); + $this->assertTrue(!empty($json['refresh_token'])); + $this->assertTrue(!empty($json['id_token'])); + $this->assertTrue(!empty($json['expires_in'])); + + // Validate the access token works on /oauth/userinfo endpoint + $this->resetAuth(); // reset guards state + $headers = ['Authorization' => 'Bearer ' . $json['access_token']]; + $response = $this->withHeaders($headers)->get("/oauth/userinfo"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals($user->id, $json['sub']); + $this->assertEquals($user->email, $json['email']); + + // Validate that the access token does not give access to API other than /oauth/userinfo + $this->resetAuth(); // reset guards state + $response = $this->withHeaders($headers)->get("/api/auth/location"); + $response->assertStatus(403); + } + + /** + * Test to make sure Passport routes are disabled + */ + public function testPassportDisabledRoutes(): void + { + $this->post("/oauth/authorize", [])->assertStatus(405); + $this->post("/oauth/token/refresh", [])->assertStatus(405); + } + + /** + * Parse JWT token into an array + */ + private function parseIdToken($token): array + { + [$headb64, $bodyb64, $cryptob64] = explode('.', $token); + + $header = json_decode(base64_decode(strtr($headb64, '-_', '+/'), true), true); + $body = json_decode(base64_decode(strtr($bodyb64, '-_', '+/'), true), true); + + return array_merge($header, $body); + } } diff --git a/src/tests/Feature/Controller/WellKnownTest.php b/src/tests/Feature/Controller/WellKnownTest.php new file mode 100644 index 00000000..f8a384e2 --- /dev/null +++ b/src/tests/Feature/Controller/WellKnownTest.php @@ -0,0 +1,54 @@ +get('.well-known/openid-configuration'); + $response->assertStatus(200) + ->assertJson([ + 'issuer' => $href, + 'authorization_endpoint' => $href . '/oauth/authorize', + 'token_endpoint' => $href . '/oauth/token', + 'userinfo_endpoint' => $href . '/oauth/userinfo', + 'grant_types_supported' => [ + 'authorization_code', + 'client_credentials', + 'refresh_token', + 'password', + ], + 'response_types_supported' => [ + 'code' + ], + 'id_token_signing_alg_values_supported' => [ + 'RS256' + ], + 'scopes_supported' => [ + 'openid', + 'email', + ], + ]); + } + + /** + * Test ./well-known/mta-sts.txt + */ + public function testMtaSts(): void + { + $domain = \config('app.domain'); + + $response = $this->get('.well-known/mta-sts.txt'); + $response->assertStatus(200) + ->assertHeader('Content-Type', 'text/plain; charset=UTF-8') + ->assertContent("version: STSv1\nmode: enforce\nmx: {$domain}\nmax_age: 604800"); + } +}