From 59806edc102e0b3704c2647185bfb1fb71b3ea6f Mon Sep 17 00:00:00 2001 From: William Petit Date: Thu, 18 Jun 2020 09:34:22 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Int=C3=A9gration=20d'hydra/hydra-passwordle?= =?UTF-8?q?ss/fake-smtp=20dans=20l'infra=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 7 +- README.md | 7 ++ docker-compose.yml | 68 +++++++++++++++++-- misc/containers/hydra/Dockerfile | 18 +++++ misc/containers/hydra/docker-entrypoint.sh | 14 ++++ misc/containers/hydra/first-run.sh | 8 +++ .../hydra/hydra-init.d/create-client | 8 +++ misc/containers/postgres/Dockerfile | 3 + .../postgres/initdb.d/init-databases.sh | 15 ++++ 9 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 misc/containers/hydra/Dockerfile create mode 100644 misc/containers/hydra/docker-entrypoint.sh create mode 100644 misc/containers/hydra/first-run.sh create mode 100755 misc/containers/hydra/hydra-init.d/create-client create mode 100644 misc/containers/postgres/Dockerfile create mode 100644 misc/containers/postgres/initdb.d/init-databases.sh diff --git a/Makefile b/Makefile index 06c50ad..20566c0 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ deps: cd frontend && npm install up: build - ( cd frontend && npm run server ) & USER_ID=$(shell id -u) docker-compose up && wait + ( cd frontend && NODE_ENV=development npm run server ) & USER_ID=$(shell id -u) docker-compose up && wait sg: docker-compose exec -u $(shell id -u) super-graph sh @@ -17,4 +17,7 @@ down: docker-compose down -v --remove-orphans db-shell: - docker-compose exec postgres psql -Usupergraph \ No newline at end of file + docker-compose exec postgres psql -Usupergraph + +hydra-shell: + docker-compose exec hydra /bin/sh \ No newline at end of file diff --git a/README.md b/README.md index e4c4816..8620f09 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ Les services suivants devraient être disponibles après démarrage de l'environ |Application React|HTTP (UI)|http://localhost:8081/|Page d'accueil de l'application React (serveur Webpack)| |Interface Web GraphQL|HTTP (UI)|http://localhost:8080/|Interface Web de développement de l'API GraphQL| |Serveur GraphQL|HTTP (GraphQL)|http://localhost:8080/api/v1/graphql|Point d'entrée de l'API GraphQL| +|Serveur Hydra|HTTP (ReST)|http://localhost:4444|Point d'entrée pour l'API OAuth2 d'[Hydra](https://www.ory.sh/hydra/docs/)| +|Serveur Hydra Passwordless|HTTP|http://localhost:3000|Point d'entrée pour la ["Login/Consent App"](https://www.ory.sh/hydra/docs/implementing-consent) [hydra-passwordless](https://forge.cadoles.com/wpetit/hydra-passwordless)| +|Serveur FakeSMTP|HTTP|http://localhost:8082|Interface web du serveur [FakeSMTP](https://forge.cadoles.com/wpetit/fake-smtp) |Serveur PostgreSQL|TCP/IP (PostgreSQL)|`127.0.0.1:5432`|Port de connexion à la base de données PostgreSQL de développement| #### Fichiers/répertoires notables @@ -46,6 +49,10 @@ Les services suivants devraient être disponibles après démarrage de l'environ |`make down`|Stopper et supprimer l'environnement de développement.| |`make db-shell`|Ouvrir une console `psql` sur la base de données de développement.| +#### Ressources + +- [Execute an Authorization Code Grant Flow with PKCE](https://auth0.com/docs/api-auth/tutorials/authorization-code-grant-pkce) + ## Licence AGPL-3.0 diff --git a/docker-compose.yml b/docker-compose.yml index bbad58e..e6e3855 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,11 +20,69 @@ services: - postgres ports: - 8080:8080 + postgres: - image: postgres:12-alpine + build: + context: ./misc/containers/postgres + args: + - HTTP_PROXY=${HTTP_PROXY} + - HTTPS_PROXY=${HTTPS_PROXY} + - http_proxy=${http_proxy} + - https_proxy=${https_proxy} environment: - - POSTGRES_PASSWORD=daddy - - POSTGRES_USER=daddy - - POSTGRES_DB=daddy + - POSTGRES_PASSWORD=postgres ports: - - 5432:5432 \ No newline at end of file + - 5432:5432 + volumes: + - postgres_data:/var/lib/postgresql/data + + hydra: + build: + context: ./misc/containers/hydra + environment: + DSN: postgres://hydra:hydra@postgres:5432/hydra + URLS_LOGIN: http://localhost:3000/login + URLS_CONSENT: http://localhost:3000/consent + URLS_LOGOUT: http://localhost:3000/logout + SUPPORTED_SCOPES: email + SUPPORTED_CLAIMS: email,email_verified + SECRETS_SYSTEM: fAAya66yXNib52lbXpo16bxy1jD4NZrX + HYDRA_ADMIN_URL: http://localhost:4445 + ports: + - 4444:4444 + command: hydra serve all --dangerous-force-http + + hydra-passwordless: + image: bornholm/hydra-passwordless + ports: + - 3000:3000 + environment: + - HTTP_COOKIE_AUTHENTICATION_KEY=XNFEWQwYB9WiVSnkHoFnMtNDL6X88apR4DmDBwh7gVgdJ3LTdLRLwGZAALnVN2yg + - HTTP_COOKIE_ENCRYPTION_KEY=xtHEd36Uo4DFeS2JgPPm94fPBfinY3xi + - HTTP_TOKEN_AUTHENTICATION_KEY=sGToi4yiP5yWrZzKdKaDA3XNpkcg9CRAaycuhr5gy2XnPKzUS7N6wGEFhMq9WPuf + - HTTP_TOKEN_ENCRYPTION_KEY=LAbuEWUeNDCLniRcyjiBCZ8ecgwN9Van + - SMTP_HOST=smtp + - SMTP_PORT=2525 + - SMTP_USE_START_TLS=false + - SMTP_USER= + - SMTP_PASSWORD= + - SMTP_INSECURE_SKIP_VERIFY=true + - HYDRA_BASE_URL=http://hydra:4445 + - HYDRA_FAKE_SSL_TERMINATION=false + + smtp: + image: bornholm/fake-smtp + ports: + - 8082:8080 + - 2525:2525 + environment: + - FAKESMTP_SMTP_ADDRESS=:2525 + - FAKESMTP_SMTP_DEBUG=true + - FAKESMTP_SMTP_USERNAME= + - FAKESMTP_SMTP_PASSWORD= + - FAKESMTP_SMTP_ALLOWINSECUREAUTH=true + volumes: + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro +volumes: + postgres_data: \ No newline at end of file diff --git a/misc/containers/hydra/Dockerfile b/misc/containers/hydra/Dockerfile new file mode 100644 index 0000000..f63eb44 --- /dev/null +++ b/misc/containers/hydra/Dockerfile @@ -0,0 +1,18 @@ +FROM oryd/hydra:v1.4.2-alpine + +USER root + +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint +RUN chmod a+x /usr/local/bin/docker-entrypoint + +COPY first-run.sh /usr/local/bin/docker-first-run +RUN chmod a+x /usr/local/bin/docker-first-run + +COPY hydra-init.d /hydra-init.d + +RUN mkdir -p /home/ory && chown -R ory: /home/ory +USER ory + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint"] + +CMD ["hydra", "serve", "all"] \ No newline at end of file diff --git a/misc/containers/hydra/docker-entrypoint.sh b/misc/containers/hydra/docker-entrypoint.sh new file mode 100644 index 0000000..86526e6 --- /dev/null +++ b/misc/containers/hydra/docker-entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +set -xeo pipefail + +LIFECYCLEFLAGS_DIR="$HOME/.container-lifecycle" + +mkdir -p "$LIFECYCLEFLAGS_DIR" + +if [ ! -f "$LIFECYCLEFLAGS_DIR/first-run" ]; then + /usr/local/bin/docker-first-run + touch "$LIFECYCLEFLAGS_DIR/first-run" +fi + +exec "$@" \ No newline at end of file diff --git a/misc/containers/hydra/first-run.sh b/misc/containers/hydra/first-run.sh new file mode 100644 index 0000000..99f8de5 --- /dev/null +++ b/misc/containers/hydra/first-run.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +hydra migrate sql -e -y + +hydra serve all --dangerous-force-http & +HYDRA_PID=$! +run-parts --exit-on-error /hydra-init.d +kill $HYDRA_PID \ No newline at end of file diff --git a/misc/containers/hydra/hydra-init.d/create-client b/misc/containers/hydra/hydra-init.d/create-client new file mode 100755 index 0000000..70e294c --- /dev/null +++ b/misc/containers/hydra/hydra-init.d/create-client @@ -0,0 +1,8 @@ +#!/bin/sh + +hydra clients create \ + --id daddy \ + -n Daddy \ + --secret 'KE9wOXR-~7qCXNKWzw23EpNroq' \ + -a email,email_verified \ + -c http://localhost:8081/oauth2/callback \ No newline at end of file diff --git a/misc/containers/postgres/Dockerfile b/misc/containers/postgres/Dockerfile new file mode 100644 index 0000000..f4ea954 --- /dev/null +++ b/misc/containers/postgres/Dockerfile @@ -0,0 +1,3 @@ +FROM postgres:12-alpine + +COPY ./initdb.d /docker-entrypoint-initdb.d \ No newline at end of file diff --git a/misc/containers/postgres/initdb.d/init-databases.sh b/misc/containers/postgres/initdb.d/init-databases.sh new file mode 100644 index 0000000..ddbbb85 --- /dev/null +++ b/misc/containers/postgres/initdb.d/init-databases.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE USER hydra WITH ENCRYPTED PASSWORD 'hydra'; + CREATE DATABASE hydra; + GRANT ALL PRIVILEGES ON DATABASE hydra TO hydra; +EOSQL + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE USER daddy WITH ENCRYPTED PASSWORD 'daddy'; + CREATE DATABASE daddy; + GRANT ALL PRIVILEGES ON DATABASE daddy TO daddy; +EOSQL \ No newline at end of file From 713b8cc3ea9b8bf9ee4925956066dbb2787bb0c9 Mon Sep 17 00:00:00 2001 From: William Petit Date: Thu, 18 Jun 2020 09:48:45 +0200 Subject: [PATCH 2/2] Authentification OpenID Connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémentation du modèle d'authentification "Authorization code with PKCE [1]" [1] https://auth0.com/docs/api-auth/tutorials/authorization-code-grant-pkce --- .editorconfig | 4 + docker-compose.yml | 2 + frontend/package-lock.json | 201 ++++++++++-------- frontend/package.json | 5 +- frontend/src/components/App.tsx | 17 +- frontend/src/components/HomePage/HomePage.tsx | 18 +- frontend/src/components/Navbar.tsx | 64 +++--- .../src/components/OAuth2Page/OAuth2Page.tsx | 31 +++ frontend/src/config.ts | 20 ++ frontend/src/index.html | 8 +- frontend/src/index.tsx | 1 + frontend/src/store/actions/auth.ts | 69 ++++++ frontend/src/store/actions/logout.ts | 7 - frontend/src/store/reducers/auth.ts | 41 ++++ frontend/src/store/reducers/flags.ts | 12 +- frontend/src/store/reducers/root.ts | 9 +- frontend/src/store/sagas/auth.ts | 98 +++++++++ frontend/src/store/sagas/failure.ts | 16 +- frontend/src/store/sagas/logout.ts | 17 -- frontend/src/store/sagas/root.ts | 18 +- frontend/src/types/idToken.ts | 3 + frontend/src/types/user.ts | 3 + frontend/src/util/auth.ts | 126 +++++++++++ frontend/webpack.config.js | 17 +- .../hydra/hydra-init.d/create-client | 5 +- 25 files changed, 628 insertions(+), 184 deletions(-) create mode 100644 .editorconfig create mode 100644 frontend/src/components/OAuth2Page/OAuth2Page.tsx create mode 100644 frontend/src/config.ts create mode 100644 frontend/src/store/actions/auth.ts delete mode 100644 frontend/src/store/actions/logout.ts create mode 100644 frontend/src/store/reducers/auth.ts create mode 100644 frontend/src/store/sagas/auth.ts delete mode 100644 frontend/src/store/sagas/logout.ts create mode 100644 frontend/src/types/idToken.ts create mode 100644 frontend/src/types/user.ts create mode 100644 frontend/src/util/auth.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1e874e7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +[*.{ts,tsx,js,jsx}] +charset = utf-8 +indent_size = 2 +indent_style = space \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e6e3855..1c75906 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,8 @@ services: SUPPORTED_CLAIMS: email,email_verified SECRETS_SYSTEM: fAAya66yXNib52lbXpo16bxy1jD4NZrX HYDRA_ADMIN_URL: http://localhost:4445 + SERVE_PUBLIC_CORS_ENABLED: "true" + SERVE_PUBLIC_CORS_ALLOWED_ORIGINS: http://localhost:8081 ports: - 4444:4444 command: hydra serve all --dangerous-force-http diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e14c9f2..5979b02 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1033,15 +1033,6 @@ "regenerator-runtime": "^0.13.4" } }, - "@babel/runtime-corejs2": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.10.2.tgz", - "integrity": "sha512-ZLwsFnNm3WpIARU1aLFtufjMHsmEnc8TjtrfAjmbgMbeoyR+LuQoyESoNdTfeDhL6IdY12SpeycXMgSgl8XGXA==", - "requires": { - "core-js": "^2.6.5", - "regenerator-runtime": "^0.13.4" - } - }, "@babel/template": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.1.tgz", @@ -1102,14 +1093,6 @@ "integrity": "sha512-xKOeQEl5O47GPZYIMToj6uuA2syyFlq9EMSl2ui0uytjY9xbe8XS0pexNWmxrdcCyNGyDmLyYw5FtKsalBUeOg==", "dev": true }, - "@lourenci/react-kanban": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@lourenci/react-kanban/-/react-kanban-0.15.0.tgz", - "integrity": "sha512-/2XjB26iXcvpwDwlT3sz8/ptQ7QyTpMGlrPf1f02+V1Z4jdbVMo6Luz1sGlHe/TP68N8yz69/YT9qwqHZ6YYmQ==", - "requires": { - "react-beautiful-dnd": "^11.0.0" - } - }, "@redux-saga/core": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.1.3.tgz", @@ -1157,6 +1140,12 @@ "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.1.0.tgz", "integrity": "sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg==" }, + "@types/anymatch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz", + "integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==", + "dev": true + }, "@types/glob": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.2.tgz", @@ -1207,6 +1196,11 @@ "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==", "dev": true }, + "@types/qs": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.3.tgz", + "integrity": "sha512-7s9EQWupR1fTc2pSMtXRQ9w9gLOcrJn+h7HOXw4evxyvVqMi4f+q7d2tnFe3ng3SNHjtK+0EzGMGFUQX4/AQRA==" + }, "@types/react": { "version": "16.9.36", "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.36.tgz", @@ -1259,12 +1253,82 @@ "@types/react-router": "*" } }, + "@types/source-list-map": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz", + "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==", + "dev": true + }, + "@types/tapable": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.6.tgz", + "integrity": "sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA==", + "dev": true + }, + "@types/uglify-js": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.9.2.tgz", + "integrity": "sha512-d6dIfpPbF+8B7WiCi2ELY7m0w1joD8cRW4ms88Emdb2w062NeEpbNCeWwVCgzLRpVG+5e74VFSg4rgJ2xXjEiQ==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "@types/uuid": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-7.0.4.tgz", "integrity": "sha512-WGZCqBZZ0mXN2RxvLHL6/7RCu+OWs28jgQMP04LWfpyJlQUMTR6YU9CNJAKDgbw+EV/u687INXuLUc7FuML/4g==", "dev": true }, + "@types/webpack": { + "version": "4.41.17", + "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.17.tgz", + "integrity": "sha512-6FfeCidTSHozwKI67gIVQQ5Mp0g4X96c2IXxX75hYEQJwST/i6NyZexP//zzMOBb+wG9jJ7oO8fk9yObP2HWAw==", + "dev": true, + "requires": { + "@types/anymatch": "*", + "@types/node": "*", + "@types/tapable": "*", + "@types/uglify-js": "*", + "@types/webpack-sources": "*", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "@types/webpack-sources": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-1.4.0.tgz", + "integrity": "sha512-c88dKrpSle9BtTqR6ifdaxu1Lvjsl3C5OsfvuUbUwdXymshv1TkufUAXBajCCUM/f/TmnkZC/Esb03MinzSiXQ==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/source-list-map": "*", + "source-map": "^0.7.3" + }, + "dependencies": { + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, "@webassemblyjs/ast": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", @@ -3208,6 +3272,16 @@ } } }, + "clean-webpack-plugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-3.0.0.tgz", + "integrity": "sha512-MciirUH5r+cYLGCOL5JX/ZLzOZbVr1ot3Fw+KcvbhUb6PM+yycqd9ZhIlcigQ5gl+XhppNmw3bEFuaaMNyLj3A==", + "dev": true, + "requires": { + "@types/webpack": "^4.4.31", + "del": "^4.1.1" + } + }, "cliui": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", @@ -3471,7 +3545,8 @@ "core-js": { "version": "2.6.11", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", - "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==" + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==", + "dev": true }, "core-js-compat": { "version": "3.6.5", @@ -3591,14 +3666,6 @@ } } }, - "css-box-model": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", - "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", - "requires": { - "tiny-invariant": "^1.0.6" - } - }, "css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -5881,6 +5948,11 @@ "verror": "1.10.0" } }, + "jwt-decode": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz", + "integrity": "sha1-fYa9VmefWM5qhHBKZX3TkruoGnk=" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -5981,12 +6053,6 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, - "lodash.union": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", - "integrity": "sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=", - "dev": true - }, "loglevel": { "version": "1.6.8", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.6.8.tgz", @@ -7348,10 +7414,9 @@ "dev": true }, "qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", - "dev": true + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", + "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==" }, "querystring": { "version": "0.2.0", @@ -7371,11 +7436,6 @@ "integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA==", "dev": true }, - "raf-schd": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz", - "integrity": "sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ==" - }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -7431,21 +7491,6 @@ "prop-types": "^15.6.2" } }, - "react-beautiful-dnd": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-11.0.5.tgz", - "integrity": "sha512-7llby9U+jIfkINcyxPHVWU0HFYzqxMemUYgGHsFsbx4fZo1n/pW6sYKYzhxGxR3Ap5HxqswcQkKUZX4uEUWhlw==", - "requires": { - "@babel/runtime-corejs2": "^7.4.5", - "css-box-model": "^1.1.2", - "memoize-one": "^5.0.4", - "raf-schd": "^4.0.0", - "react-redux": "^7.0.3", - "redux": "^4.0.1", - "tiny-invariant": "^1.0.4", - "use-memo-one": "^1.1.0" - } - }, "react-dom": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz", @@ -7593,12 +7638,6 @@ "source-map": "~0.5.0" } }, - "recursive-readdir-sync": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/recursive-readdir-sync/-/recursive-readdir-sync-1.0.6.tgz", - "integrity": "sha1-Hb9tMvPFu4083pemxYjVR6nhPVY=", - "dev": true - }, "redent": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", @@ -7800,6 +7839,14 @@ "tough-cookie": "~2.5.0", "tunnel-agent": "^0.6.0", "uuid": "^3.3.2" + }, + "dependencies": { + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "dev": true + } } }, "require-directory": { @@ -9432,11 +9479,6 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, - "use-memo-one": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.1.tgz", - "integrity": "sha512-oFfsyun+bP7RX8X2AskHNTxu+R3QdE/RC5IefMbqptmACAA/gfol1KDD5KRzPsGMa62sWxGZw+Ui43u6x4ddoQ==" - }, "util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", @@ -9485,7 +9527,8 @@ "uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true }, "v8-compile-cache": { "version": "2.0.3", @@ -9727,28 +9770,6 @@ } } }, - "webpack-cleanup-plugin": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/webpack-cleanup-plugin/-/webpack-cleanup-plugin-0.5.1.tgz", - "integrity": "sha1-3y1wa9dTZMBuZbBRGGMW1nTrlq8=", - "dev": true, - "requires": { - "lodash.union": "4.6.0", - "minimatch": "3.0.3", - "recursive-readdir-sync": "1.0.6" - }, - "dependencies": { - "minimatch": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz", - "integrity": "sha1-Kk5AkLlrLbBqnX3wEFWmKnfJt3Q=", - "dev": true, - "requires": { - "brace-expansion": "^1.0.0" - } - } - } - }, "webpack-cli": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-3.3.11.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0a69375..f744221 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "@types/react-router-dom": "^5.1.5", "@types/uuid": "^7.0.3", "babel-loader": "^8.0.6", + "clean-webpack-plugin": "^3.0.0", "css-loader": "^1.0.1", "extract-loader": "^3.1.0", "file-loader": "^2.0.0", @@ -45,13 +46,15 @@ "style-loader": "^0.23.1", "ts-loader": "^7.0.2", "webpack": "^4.25.0", - "webpack-cleanup-plugin": "^0.5.1", "webpack-cli": "^3.1.2", "webpack-dev-server": "^3.11.0" }, "dependencies": { + "@types/qs": "^6.9.3", "bulma": "^0.7.2", "bulma-switch": "^2.0.0", + "jwt-decode": "^2.2.0", + "qs": "^6.9.4", "react": "^16.12.0", "react-dom": "^16.12.0", "react-redux": "^7.1.3", diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 00f2376..53df347 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -1,29 +1,22 @@ import React from 'react'; -import { HashRouter as Router, Route, Redirect, Switch } from "react-router-dom"; +import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom"; import { HomePage } from './HomePage/HomePage'; import { store } from '../store/store'; import { Provider } from 'react-redux'; -import { logout } from '../store/actions/logout'; +import { OAuth2Page } from './OAuth2Page/OAuth2Page'; export class App extends React.Component { render() { return ( - + - { - this.logout(); - return ; - }} /> + } /> - + ); } - - logout() { - store.dispatch(logout()); - } } \ No newline at end of file diff --git a/frontend/src/components/HomePage/HomePage.tsx b/frontend/src/components/HomePage/HomePage.tsx index 842dc63..150726a 100644 --- a/frontend/src/components/HomePage/HomePage.tsx +++ b/frontend/src/components/HomePage/HomePage.tsx @@ -1,11 +1,27 @@ import React from 'react'; import { Page } from '../Page'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../store/reducers/root'; export function HomePage() { + const currentUser = useSelector((state: RootState) => state.auth.currentUser); + return (
- +
+
+
+
+ { + currentUser ? +

Bonjour {currentUser.email} !

: +

Veuillez vous authentifier.

+ } +
+
+
+
); diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 0dcb81d..d603547 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,36 +1,48 @@ import React from 'react'; import logo from '../resources/logo.svg'; +import { useSelector } from 'react-redux'; +import { RootState } from '../store/reducers/root'; +import { Link } from 'react-router-dom'; -export class Navbar extends React.PureComponent { - render() { - return ( - + ); +}; \ No newline at end of file diff --git a/frontend/src/components/OAuth2Page/OAuth2Page.tsx b/frontend/src/components/OAuth2Page/OAuth2Page.tsx new file mode 100644 index 0000000..7ae1daa --- /dev/null +++ b/frontend/src/components/OAuth2Page/OAuth2Page.tsx @@ -0,0 +1,31 @@ +import React, { useEffect } from 'react'; +import { Page } from '../Page'; +import { useDispatch } from 'react-redux'; +import { logout, login, handleOAuth2Callback } from '../../store/actions/auth'; + +export function OAuth2Page({ match, location, history }) { + const dispatch = useDispatch(); + const { action } = match.params; + + useEffect(() => { + switch(action) { + case 'logout': + dispatch(logout()); + history.push("/"); + break; + case 'login': + dispatch(login()); + break; + case 'callback': + dispatch(handleOAuth2Callback(location.search)); + history.push("/"); + break; + } + }, [action]); + + return ( + + + + ); +} diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..9b59e24 --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,20 @@ +export const Config = { + // The OpenID Connect client_id + oauth2ClientId: get("oauth2ClientId", "daddy"), + oauth2Scope: get("oauth2Scope", "email email_verified openid offline_access"), + oauth2RedirectURI: get("oauth2RedirectURI", "http://localhost:8081/oauth2/callback"), + oauth2Audience: get("oauth2Audience", ""), + oauth2AuthorizeURL: get("oauth2AuthorizeURL", "http://localhost:4444/oauth2/auth"), + oauth2TokenURL: get("oauth2TokenURL", "http://localhost:4444/oauth2/token"), + oauth2LogoutURL: get("oauth2LogoutURL", "http://localhost:4444/oauth2/sessions/logout"), + oauth2PostLogoutRedirectURI: get("oauth2PostLogoutRedirectURI", "http://localhost:8081") +}; + +function get(key: string, defaultValue: T):T { + const config = window['__CONFIG__'] || {}; + if (config && config.hasOwnProperty(key)) { + return config[key] as T; + } else { + return defaultValue; + } +} \ No newline at end of file diff --git a/frontend/src/index.html b/frontend/src/index.html index 234eb2a..9089b6a 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -5,16 +5,18 @@ Daddy <% for (var css in htmlWebpackPlugin.files.css) { %> - + <% } %> <% if (htmlWebpackPlugin.files.favicon) { %> - + <% } %> +
+ <% for (var chunk in htmlWebpackPlugin.files.chunks) { %> - + <% } %> \ No newline at end of file diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 11d88f4..9dde128 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -2,6 +2,7 @@ import './sass/_all.scss'; import React from 'react'; import ReactDOM from 'react-dom'; import { App } from './components/App'; +import { Config } from './config'; import '@fortawesome/fontawesome-free/js/fontawesome' import '@fortawesome/fontawesome-free/js/solid' diff --git a/frontend/src/store/actions/auth.ts b/frontend/src/store/actions/auth.ts new file mode 100644 index 0000000..ee55183 --- /dev/null +++ b/frontend/src/store/actions/auth.ts @@ -0,0 +1,69 @@ +import { Action } from "redux"; +import { AccessGrant } from "../../util/auth"; +import { IdToken } from "../../types/idToken"; + +export const LOGOUT = "LOGOUT_REQUEST"; + +export function logout() { + return { type: LOGOUT }; +}; + +export const LOGIN_REQUEST = "LOGIN_REQUEST"; +export const LOGIN_SUCCESS = "LOGIN_SUCCESS"; +export const LOGIN_FAILURE = "LOGIN_FAILURE"; + +export function login() { + return { type: LOGIN_REQUEST }; +}; + +export const HANDLE_OAUTH2_CALLBACK_REQUEST = "HANDLE_OAUTH2_CALLBACK_REQUEST"; +export const HANDLE_OAUTH2_CALLBACK_SUCCESS = "HANDLE_OAUTH2_CALLBACK_SUCCESS"; +export const HANDLE_OAUTH2_CALLBACK_FAILURE = "HANDLE_OAUTH2_CALLBACK_FAILURE"; + +export interface handleOAuth2CallbackAction extends Action { + search: string +} + +export function handleOAuth2Callback(search: string): handleOAuth2CallbackAction { + return { type: HANDLE_OAUTH2_CALLBACK_REQUEST, search }; +}; + +export interface handleOAuth2CallbackSuccessAction extends Action { + grant: AccessGrant +} + +export function handleOAuth2CallbackSuccess(grant: AccessGrant): handleOAuth2CallbackSuccessAction { + return { type: HANDLE_OAUTH2_CALLBACK_SUCCESS, grant }; +}; + +export const PARSE_ID_TOKEN_REQUEST = "PARSE_ID_TOKEN_REQUEST"; +export const PARSE_ID_TOKEN_SUCCESS = "PARSE_ID_TOKEN_SUCCESS"; +export const PARSE_ID_TOKEN_FAILURE = "PARSE_ID_TOKEN_FAILURE"; + +export interface parseIdTokenAction extends Action { + rawIdToken: string +}; + +export function parseIdToken(rawIdToken: string): parseIdTokenAction { + return { type: PARSE_ID_TOKEN_REQUEST, rawIdToken }; +}; + + +export interface parseIdTokenSuccessAction extends Action { + idToken: IdToken +} + +export function parseIdTokenSuccess(idToken: IdToken): parseIdTokenSuccessAction { + return { type: PARSE_ID_TOKEN_SUCCESS, idToken }; +}; + + +export const SET_CURRENT_USER = 'SET_CURRENT_USER'; + +export interface setCurrentUserAction extends Action { + email: string +} + +export function setCurrentUser(email: string): setCurrentUserAction { + return { type: SET_CURRENT_USER, email }; +} \ No newline at end of file diff --git a/frontend/src/store/actions/logout.ts b/frontend/src/store/actions/logout.ts deleted file mode 100644 index 3537185..0000000 --- a/frontend/src/store/actions/logout.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const LOGOUT_REQUEST = "LOGOUT_REQUEST"; -export const LOGOUT_SUCCESS = "LOGOUT_SUCCESS"; -export const LOGOUT_FAILURE = "LOGOUT_FAILURE"; - -export function logout() { - return { type: LOGOUT_REQUEST }; -}; \ No newline at end of file diff --git a/frontend/src/store/reducers/auth.ts b/frontend/src/store/reducers/auth.ts new file mode 100644 index 0000000..a0bd507 --- /dev/null +++ b/frontend/src/store/reducers/auth.ts @@ -0,0 +1,41 @@ +import { Action } from "redux"; +import { User } from "../../types/user"; +import { SET_CURRENT_USER, setCurrentUserAction, LOGOUT } from "../actions/auth"; + +export interface AuthState { + isAuthenticated: boolean + currentUser: User +} + +const defaultState = { + isAuthenticated: false, + currentUser: null, +}; + +export function authReducer(state = defaultState, action: Action): AuthState { + switch (action.type) { + case SET_CURRENT_USER: + return handleSetCurrentUser(state, action as setCurrentUserAction); + case LOGOUT: + return handleLogout(state); + } + return state; +} + +function handleSetCurrentUser(state: AuthState, { email }: setCurrentUserAction): AuthState { + return { + ...state, + isAuthenticated: true, + currentUser: { + email + } + }; +}; + +function handleLogout(state: AuthState): AuthState { + return { + ...state, + isAuthenticated: false, + currentUser: null, + }; +} \ No newline at end of file diff --git a/frontend/src/store/reducers/flags.ts b/frontend/src/store/reducers/flags.ts index d0d22b8..f42701b 100644 --- a/frontend/src/store/reducers/flags.ts +++ b/frontend/src/store/reducers/flags.ts @@ -1,8 +1,18 @@ +import { Action } from "redux"; + +export interface FlagsState { + actions: { [actionName: string]: ActionState } +} + +export interface ActionState { + isLoading: boolean +} + const defaultState = { actions: {} }; -export function flagsReducer(state = defaultState, action: any) { +export function flagsReducer(state = defaultState, action: Action): FlagsState { const matches = (/^(.*)_((SUCCESS)|(FAILURE)|(REQUEST))$/).exec(action.type); if(!matches) return state; diff --git a/frontend/src/store/reducers/root.ts b/frontend/src/store/reducers/root.ts index 9203f3e..5835800 100644 --- a/frontend/src/store/reducers/root.ts +++ b/frontend/src/store/reducers/root.ts @@ -1,6 +1,13 @@ import { combineReducers } from 'redux'; -import { flagsReducer } from './flags'; +import { flagsReducer, FlagsState } from './flags'; +import { authReducer, AuthState } from './auth'; + +export interface RootState { + auth: AuthState, + flags: FlagsState, +} export const rootReducer = combineReducers({ flags: flagsReducer, + auth: authReducer, }); \ No newline at end of file diff --git a/frontend/src/store/sagas/auth.ts b/frontend/src/store/sagas/auth.ts new file mode 100644 index 0000000..7c91d14 --- /dev/null +++ b/frontend/src/store/sagas/auth.ts @@ -0,0 +1,98 @@ +import { put, takeLatest, all } from 'redux-saga/effects'; +import { + LOGOUT, LOGIN_REQUEST, + HANDLE_OAUTH2_CALLBACK_REQUEST, handleOAuth2CallbackAction, + HANDLE_OAUTH2_CALLBACK_FAILURE, handleOAuth2CallbackSuccess, + parseIdTokenAction, parseIdToken, + PARSE_ID_TOKEN_REQUEST, PARSE_ID_TOKEN_FAILURE, parseIdTokenSuccess, + setCurrentUser, LOGIN_FAILURE, +} from '../actions/auth'; +import { + createLoginSession, LoginSession, + createAccessTokenRequest, saveAccessGrant, + saveLoginSessionState, getSavedLoginSessionState, + getLogoutURL, getSavedAccessGrant, clearAccessGrant +} from '../../util/auth'; +import qs from 'qs'; +import { UnauthorizedError } from '../../util/daddy'; +import jwtDecode from 'jwt-decode'; +import { IdToken } from '../../types/idToken'; + +export function* authRootSaga() { + yield all([ + takeLatest(LOGIN_REQUEST, loginSaga), + takeLatest(LOGOUT, logoutSaga), + takeLatest(HANDLE_OAUTH2_CALLBACK_REQUEST, handleOAuth2CallbackSaga), + takeLatest(PARSE_ID_TOKEN_REQUEST, parseIDTokenSaga), + ]); +} + +export function* loginSaga() { + try { + const loginSession: LoginSession = yield createLoginSession(); + console.log('Code verifier is ', loginSession.verifier); + console.log('State is ', loginSession.state); + saveLoginSessionState(loginSession.verifier, loginSession.state); + window.location.replace(loginSession.redirectUrl); + } catch(err) { + yield put({ type: LOGIN_FAILURE, err }); + } +} + +export function* logoutSaga() { + const accessGrant = getSavedAccessGrant(); + const logoutURL = getLogoutURL(accessGrant.id_token); + clearAccessGrant(); + window.location.replace(logoutURL); +} + +export function* handleOAuth2CallbackSaga({ search }: handleOAuth2CallbackAction) { + const query = search.substring(1); + const params = qs.parse(query); + + const loginSession = getSavedLoginSessionState(); + + console.log('Stored state verifier is', loginSession.state); + if (loginSession.state !== params.state) { + yield put({ type: HANDLE_OAUTH2_CALLBACK_FAILURE, err: new Error("Invalid state") }); + return; + } + + console.log('Stored code verifier is', loginSession.verifier); + console.log('Authorization code is', params.code); + + const req = createAccessTokenRequest(params.code as string, loginSession.verifier); + + let grant; + try { + grant = yield fetch(req.url, { method: "POST", body: req.data }) + .then(res => { + if (res.status === 401) return Promise.reject(new UnauthorizedError()); + return res; + }) + .then(res => res.json()); + } catch(err) { + yield put({ type: HANDLE_OAUTH2_CALLBACK_FAILURE, err }); + return; + } + + console.log("Access grant is", grant); + saveAccessGrant(grant); + + yield put(handleOAuth2CallbackSuccess(grant)); + yield put(parseIdToken(grant.id_token)); +}; + + +export function* parseIDTokenSaga({ rawIdToken }: parseIdTokenAction) { + let idToken: IdToken; + try { + idToken = jwtDecode(rawIdToken); + } catch(err) { + yield put({ type: PARSE_ID_TOKEN_FAILURE, err }); + return; + } + + yield put(parseIdTokenSuccess(idToken)); + yield put(setCurrentUser(idToken.email)); +}; \ No newline at end of file diff --git a/frontend/src/store/sagas/failure.ts b/frontend/src/store/sagas/failure.ts index 3bfc784..97bc18c 100644 --- a/frontend/src/store/sagas/failure.ts +++ b/frontend/src/store/sagas/failure.ts @@ -1,9 +1,21 @@ import { UnauthorizedError } from "../../util/daddy"; -import { put } from 'redux-saga/effects'; -import { logout } from '../actions/logout'; +import { put, all, takeEvery } from 'redux-saga/effects'; +import { logout } from '../actions/auth'; + +export function* failureRootSaga() { + yield all([ + takeEvery(patternFromRegExp(/^.*_FAILURE/), failuresSaga), + ]); +} export function* failuresSaga(action) { if (action.error instanceof UnauthorizedError) { yield put(logout()); } } + +export function patternFromRegExp(re: any) { + return (action: any) => { + return re.test(action.type); + }; +} \ No newline at end of file diff --git a/frontend/src/store/sagas/logout.ts b/frontend/src/store/sagas/logout.ts deleted file mode 100644 index 26e3fe0..0000000 --- a/frontend/src/store/sagas/logout.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { call, put } from 'redux-saga/effects'; -import { LOGOUT_FAILURE, LOGOUT_SUCCESS } from '../actions/logout'; - -export function* logoutSaga() { - try { - yield call(fetch, '/logout', { mode: 'no-cors', credentials: 'include' }); - } catch(err) { - yield put({ type: LOGOUT_FAILURE, error: err }); - return; - } - - yield put({ type: LOGOUT_SUCCESS }); -} - -export function* logoutSuccessSaga() { - window.location.reload(); -} \ No newline at end of file diff --git a/frontend/src/store/sagas/root.ts b/frontend/src/store/sagas/root.ts index 47bb19d..dfa853a 100644 --- a/frontend/src/store/sagas/root.ts +++ b/frontend/src/store/sagas/root.ts @@ -1,18 +1,10 @@ -import { all, takeEvery, takeLatest } from 'redux-saga/effects'; -import { failuresSaga } from './failure'; -import { LOGOUT_REQUEST, LOGOUT_SUCCESS } from '../actions/logout'; -import { logoutSaga, logoutSuccessSaga } from './logout'; +import { all } from 'redux-saga/effects'; +import { failureRootSaga } from './failure'; +import { authRootSaga } from './auth'; export function* rootSaga() { yield all([ - takeEvery(patternFromRegExp(/^.*_FAILURE/), failuresSaga), - takeLatest(LOGOUT_REQUEST, logoutSaga), - takeLatest(LOGOUT_SUCCESS, logoutSuccessSaga) + failureRootSaga(), + authRootSaga(), ]); } - -export function patternFromRegExp(re: any) { - return (action: any) => { - return re.test(action.type); - }; -} \ No newline at end of file diff --git a/frontend/src/types/idToken.ts b/frontend/src/types/idToken.ts new file mode 100644 index 0000000..2ef7808 --- /dev/null +++ b/frontend/src/types/idToken.ts @@ -0,0 +1,3 @@ +export interface IdToken { + email: string +} \ No newline at end of file diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts new file mode 100644 index 0000000..70d7e8b --- /dev/null +++ b/frontend/src/types/user.ts @@ -0,0 +1,3 @@ +export interface User { + email: string +} \ No newline at end of file diff --git a/frontend/src/util/auth.ts b/frontend/src/util/auth.ts new file mode 100644 index 0000000..adcff7d --- /dev/null +++ b/frontend/src/util/auth.ts @@ -0,0 +1,126 @@ +import { Config } from '../config'; + +export interface LoginSession { + state: string + redirectUrl: string + verifier: string +} + +export function generateRandomString() { + var array = new Uint32Array(28); + window.crypto.getRandomValues(array); + return Array.from(array, dec => ('0' + dec.toString(16)).substr(-2)).join(''); +} + +export function sha256(plain): PromiseLike { + const encoder = new TextEncoder(); + const data = encoder.encode(plain); + return window.crypto.subtle.digest('SHA-256', data); +} + +export function pkceChallengeFromVerifier(v): PromiseLike { + return sha256(v) + .then(hashed => base64urlencode(hashed)); +} + +export function base64urlencode(str) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(str))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function createLoginSession(): Promise { + // Based on https://auth0.com/docs/api-auth/tutorials/authorization-code-grant-pkce + const state = generateRandomString(); + const verifier = generateRandomString(); + + return new Promise((resolve, reject) => { + try { + pkceChallengeFromVerifier(verifier).then(challenge => { + console.log('Code challenge is', challenge); + + let redirectUrl=`${Config.oauth2AuthorizeURL}`; + redirectUrl += `?audience=${encodeURIComponent(Config.oauth2Audience)}`; + redirectUrl += `&scope=${encodeURIComponent(Config.oauth2Scope)}`; + redirectUrl += `&response_type=code`; + redirectUrl += `&client_id=${encodeURIComponent(Config.oauth2ClientId)}` + redirectUrl += `&code_challenge=${encodeURIComponent(challenge)}`; + redirectUrl += `&code_challenge_method=S256` + redirectUrl += `&redirect_uri=${encodeURIComponent(Config.oauth2RedirectURI)}`; + redirectUrl += `&state=${encodeURIComponent(state)}`; + + return resolve({ + state, + redirectUrl, + verifier, + }); + }); + } catch(err) { + return reject(err); + } + }); +}; + +export interface AccessTokenRequest { + data: FormData, + url: string +} + +export function createAccessTokenRequest(code: string, verifier: string): AccessTokenRequest { + const data = new FormData(); + data.append('grant_type', 'authorization_code'); + data.append('client_id', Config.oauth2ClientId); + data.append('code_verifier', verifier); + data.append('code', code); + data.append('redirect_uri', Config.oauth2RedirectURI); + return { + url: Config.oauth2TokenURL, + data, + }; +}; + +export function getLogoutURL(rawIdToken: string): string { + let logoutURL = Config.oauth2LogoutURL; + logoutURL += `?post_logout_redirect_uri=${encodeURIComponent(Config.oauth2PostLogoutRedirectURI)}`; + logoutURL += `&id_token_hint=${encodeURIComponent(rawIdToken)}`; + return logoutURL; +} + +export interface AccessGrant { + access_token: string + expires_in: number + id_token: string + refresh_token: string + scope: string + token_type: string +} + +export function saveLoginSessionState(verifier: string, state: string) { + window.localStorage.setItem('login_verifier', verifier); + window.localStorage.setItem('login_state', state); +} + +export function getSavedLoginSessionState(cleanup = true) { + const loginSession = { + verifier: window.localStorage.getItem('login_verifier'), + state: window.localStorage.getItem('login_state') + }; + if (cleanup) { + window.localStorage.removeItem('login_verifier'); + window.localStorage.removeItem('login_state'); + } + return loginSession; +} + +export function saveAccessGrant(grant: AccessGrant) { + window.localStorage.setItem('access_grant', JSON.stringify(grant)); +} + +export function getSavedAccessGrant(): AccessGrant { + const raw = window.localStorage.getItem('access_grant'); + if (raw === "") return null; + return JSON.parse(raw) as AccessGrant; +} + +export function clearAccessGrant() { + window.localStorage.removeItem('access_grant'); +} \ No newline at end of file diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 7db40c0..ce20669 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -3,7 +3,7 @@ const path = require('path'); // Plugins const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const HtmlWebpackPlugin = require('html-webpack-plugin'); -const WebpackCleanupPlugin = require('webpack-cleanup-plugin'); +const { CleanWebpackPlugin } = require('clean-webpack-plugin'); const env = process.env; @@ -12,6 +12,7 @@ module.exports = { entry: './src/index.tsx', devtool: 'inline-source-map', output: { + filename: '[name].[contenthash].js', path: path.join(__dirname, 'dist') }, resolve: { @@ -20,7 +21,9 @@ module.exports = { devServer: { contentBase: path.join(__dirname, 'dist'), compress: true, - port: 8081 + port: 8081, + historyApiFallback: true, + writeToDisk: true, }, module: { rules: [{ @@ -48,7 +51,7 @@ module.exports = { use: [{ loader: 'file-loader', options: { - name: '[name].[ext]', + name: '[name].[contenthash].[ext]', outputPath: '/resources/' } }] @@ -59,17 +62,15 @@ module.exports = { }] }, plugins: [ + new CleanWebpackPlugin(), new MiniCssExtractPlugin({ - filename: "css/[name].css", + filename: "css/[name].[contenthash].css", chunkFilename: "css/[id].css" }), new HtmlWebpackPlugin({ template: './src/index.html', inject: false, - favicon: "./src/resources/favicon.png" + favicon: "./src/resources/favicon.png", }), - new WebpackCleanupPlugin({ - exclude: ['resources/logo.svg'] - }) ] } \ No newline at end of file diff --git a/misc/containers/hydra/hydra-init.d/create-client b/misc/containers/hydra/hydra-init.d/create-client index 70e294c..85d085b 100755 --- a/misc/containers/hydra/hydra-init.d/create-client +++ b/misc/containers/hydra/hydra-init.d/create-client @@ -3,6 +3,7 @@ hydra clients create \ --id daddy \ -n Daddy \ - --secret 'KE9wOXR-~7qCXNKWzw23EpNroq' \ - -a email,email_verified \ + -a email,email_verified,offline_access,openid \ + --token-endpoint-auth-method none \ + --post-logout-callbacks http://localhost:8081 \ -c http://localhost:8081/oauth2/callback \ No newline at end of file