├── LICENSE ├── README.md ├── client ├── .dockerignore ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── nginx.conf ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.js │ ├── App.test.js │ ├── api │ ├── apiClient.js │ ├── baseClient.js │ └── index.js │ ├── components │ ├── Modal.js │ ├── ProtectedRoute.js │ └── index.js │ ├── index.js │ ├── pages │ ├── landing │ │ ├── LoginForm.js │ │ ├── SignupForm.js │ │ └── index.js │ └── sso │ │ ├── Service.js │ │ ├── index.js │ │ └── providers │ │ ├── apple │ │ ├── index.js │ │ └── logo.svg │ │ ├── chase │ │ ├── index.js │ │ └── logo.svg │ │ ├── facebook │ │ ├── index.js │ │ └── logo.svg │ │ ├── github │ │ ├── index.js │ │ └── logo.svg │ │ ├── index.js │ │ ├── reddit │ │ ├── index.js │ │ └── logo.svg │ │ └── twitter │ │ ├── index.js │ │ └── logo.svg │ ├── serviceWorker.js │ ├── setupTests.js │ ├── storage │ ├── TokenManager.js │ └── index.js │ └── theme.js ├── docker-compose.yml └── server ├── .gitignore ├── Dockerfile ├── README.md ├── app ├── __init__.py ├── config.py ├── constants.py ├── database.py ├── errors │ ├── __init__.py │ ├── handlers.py │ └── messages.py ├── extensions.py ├── models.py ├── routes │ ├── __init__.py │ ├── oauth.py │ ├── sso.py │ └── users.py ├── services │ ├── __init__.py │ ├── facebook.py │ └── oauth2.py ├── spec.py ├── templates │ └── authorize.html └── utils │ ├── __init__.py │ ├── permissions.py │ └── spec.py ├── docs └── api │ ├── oauth │ ├── oauth_authorize.yaml │ ├── oauth_revoke.yaml │ └── oauth_token.yaml │ └── users │ ├── users_login.yaml │ ├── users_profile.yaml │ └── users_register.yaml ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 8b4e8a2a43bd_added_oauth2_tables.py │ └── eb7f82a4d3c8_first_migration.py ├── requirements-dev.txt ├── requirements.txt ├── run.py └── tasks ├── __init__.py ├── create.py └── db.py /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Private Identity Server 2 | 3 | - `/client` - Create React App front-end for identity service 4 | - `/server` - Flask private identity server 5 | 6 | ## Running the Application 7 | 8 | Make sure you have `docker` installed on your system. 9 | 10 | 1. run `docker-compose build` 11 | 2. run `docker-compose up` 12 | 13 | This will start both the client/server application, and both will reload automatically as you make changes. 14 | 15 | ## Further Documentation 16 | 17 | See the Readmes within the `client/` and `server/` directories for more information. 18 | -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | BASE_URL=http://localhost:5000 -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 0, "build-stage", based on Node.js, to build and compile the frontend 2 | FROM node:10 as build-stage 3 | 4 | WORKDIR /app 5 | 6 | # Copy & install packages first to preserve layers 7 | COPY package*.json /app/ 8 | 9 | RUN npm install 10 | 11 | COPY ./ /app/ 12 | 13 | RUN npm run build 14 | 15 | # Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx 16 | FROM nginx:1.15 17 | 18 | COPY --from=build-stage /app/build/ /usr/share/nginx/html 19 | COPY --from=build-stage /app/nginx.conf /etc/nginx/conf.d/default.conf 20 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `yarn build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /client/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | location / { 5 | root /usr/share/nginx/html; 6 | index index.html index.htm; 7 | try_files $uri $uri/ /index.html =404; 8 | } 9 | 10 | include /etc/nginx/extra-conf.d/*.conf; 11 | } 12 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "NODE_ENV=local node -r dotenv/config ./node_modules/.bin/react-scripts start", 7 | "build": "NODE_ENV=production node -r dotenv/config ./node_modules/.bin/react-scripts build", 8 | "test": "NODE_ENV=test node -r dotenv/config ./node_modules/.bin/react-scripts test", 9 | "eject": "./node_modules/.bin/react-scripts eject" 10 | }, 11 | "dependencies": { 12 | "@chakra-ui/core": "^0.6.1", 13 | "@emotion/core": "^10.0.28", 14 | "@emotion/styled": "^10.0.27", 15 | "emotion-theming": "^10.0.27", 16 | "lodash": "^4.17.15", 17 | "node-fetch": "^2.6.0", 18 | "react": "^16.13.0", 19 | "react-dom": "^16.13.0", 20 | "react-hook-form": "^5.1.1", 21 | "react-router-dom": "^5.1.2", 22 | "react-scripts": "3.4.0", 23 | "xss": "^1.0.6" 24 | }, 25 | "devDependencies": { 26 | "@testing-library/jest-dom": "^4.2.4", 27 | "@testing-library/react": "^9.3.2", 28 | "@testing-library/user-event": "^7.1.2", 29 | "eslint": "^6.8.0", 30 | "husky": "^4.2.3", 31 | "lint-staged": "^10.0.9", 32 | "prettier": "^2.0.2" 33 | }, 34 | "lint-staged": { 35 | "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [ 36 | "prettier --write" 37 | ], 38 | "src/**/*.{js,jsx,ts,tsx,json}": [ 39 | "eslint" 40 | ] 41 | }, 42 | "husky": { 43 | "hooks": { 44 | "pre-commit": "lint-staged" 45 | } 46 | }, 47 | "eslintConfig": { 48 | "extends": "react-app" 49 | }, 50 | "browserslist": { 51 | "production": [ 52 | ">0.2%", 53 | "not dead", 54 | "not op_mini all" 55 | ], 56 | "development": [ 57 | "last 1 chrome version", 58 | "last 1 firefox version", 59 | "last 1 safari version" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenMined/opus/0995ec03640ab23081ab83f015cc62d99649238a/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenMined/opus/0995ec03640ab23081ab83f015cc62d99649238a/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenMined/opus/0995ec03640ab23081ab83f015cc62d99649238a/client/public/logo512.png -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, useToast } from "@chakra-ui/core"; 3 | import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; 4 | import ProtectedRoute from "./components/ProtectedRoute"; 5 | import { Services } from "./pages/sso"; 6 | import { Landing } from "./pages/landing"; 7 | 8 | export default () => { 9 | const toast = useToast(); 10 | 11 | const onError = ({ message }) => { 12 | return toast({ 13 | title: "An error occurred", 14 | description: message, 15 | status: "error", 16 | position: "top-right", 17 | duration: 3000, 18 | isClosable: true, 19 | }); 20 | }; 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /client/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "@testing-library/react"; 3 | import App from "./App"; 4 | 5 | test("renders learn react link", () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /client/src/api/apiClient.js: -------------------------------------------------------------------------------- 1 | import BaseClient from "./baseClient"; 2 | import { TokenManager } from "../storage"; 3 | class ApiClient extends BaseClient { 4 | getAuthorizationHeaders() { 5 | return { Authorization: `Bearer ${TokenManager.getToken()}` }; 6 | } 7 | 8 | async login(body) { 9 | return this.post("/users/login", { body }); 10 | } 11 | 12 | async register(body) { 13 | return this.post("/users/register", { body }); 14 | } 15 | 16 | async providers() { 17 | return this.get("/sso/providers", { noAuthHeader: false }); 18 | } 19 | 20 | async revokeGithubToken() { 21 | return this.post("/sso/github/revoke", { noAuthHeader: false }); 22 | } 23 | } 24 | 25 | export default ApiClient; 26 | -------------------------------------------------------------------------------- /client/src/api/baseClient.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import fetch from "node-fetch"; 3 | 4 | const rxOne = /^[\],:{}\s]*$/; 5 | const rxTwo = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g; 6 | const rxThree = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g; 7 | const rxFour = /(?:^|:|,)(?:\s*\[)+/g; 8 | const isJSON = (input) => 9 | input.length && 10 | rxOne.test( 11 | input.replace(rxTwo, "@").replace(rxThree, "]").replace(rxFour, "") 12 | ); 13 | 14 | class BaseClient { 15 | constructor({ headers, baseUrl }) { 16 | this.baseUrl = baseUrl; 17 | this.headers = { 18 | ...{ 19 | Accept: "application/json", 20 | "Content-Type": "application/json", 21 | }, 22 | ...headers, 23 | }; 24 | } 25 | 26 | queryParams(params) { 27 | return Object.keys(params) 28 | .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) 29 | .join("&"); 30 | } 31 | 32 | getAuthorizationHeaders() { 33 | return {}; 34 | } 35 | 36 | async generateRequest(options) { 37 | const { 38 | timeout = 10000, 39 | body = {}, 40 | headers = {}, 41 | path = "", 42 | method = "GET", 43 | noAuthHeader = false, 44 | } = options; 45 | 46 | let requestBody = null; 47 | let url = `${this.baseUrl}${path}`; 48 | 49 | if ("GET" === method && !_.isEmpty(body)) { 50 | const params = _.isString(body) ? JSON.parse(body) : body; 51 | url += (url.indexOf("?") === -1 ? "?" : "&") + this.queryParams(params); 52 | } else { 53 | requestBody = _.isEmpty(body) 54 | ? null 55 | : _.isString(body) 56 | ? body 57 | : JSON.stringify(body); 58 | } 59 | 60 | return { 61 | url, 62 | body: requestBody, 63 | cors: true, 64 | headers: { 65 | Accept: "application/json", 66 | "Content-Type": "application/json", 67 | ...headers, 68 | ...(noAuthHeader 69 | ? {} 70 | : await this.getAuthorizationHeaders(requestBody)), 71 | }, 72 | method, 73 | timeout, 74 | }; 75 | } 76 | 77 | async fetch(path, props) { 78 | const { url, ...params } = await this.generateRequest({ path, ...props }); 79 | const response = await fetch(url, params); 80 | const responseData = await response.text(); 81 | return isJSON(responseData) 82 | ? { status: response.status, data: JSON.parse(responseData) } 83 | : { status: response.status, data: responseData }; 84 | } 85 | 86 | get(path, options) { 87 | return this.fetch(path, { ...options, ...{ method: "GET" } }); 88 | } 89 | 90 | post(path, options) { 91 | return this.fetch(path, { ...options, ...{ method: "POST" } }); 92 | } 93 | 94 | put(path, options) { 95 | return this.fetch(path, { ...options, ...{ method: "PUT" } }); 96 | } 97 | 98 | delete(path, options) { 99 | return this.fetch(path, { ...options, ...{ method: "DELETE" } }); 100 | } 101 | } 102 | 103 | export default BaseClient; 104 | -------------------------------------------------------------------------------- /client/src/api/index.js: -------------------------------------------------------------------------------- 1 | import ApiClient from "./apiClient"; 2 | 3 | const apiClient = new ApiClient({ 4 | baseUrl: process.env.BASE_URL || "http://localhost:5000", 5 | }); 6 | const triggerSideEffect = async ({ 7 | apiCall, 8 | resultSuccess = (res) => res.status === 200, 9 | onSuccess = (data) => data, 10 | onError = () => ({}), 11 | }) => { 12 | return apiCall() 13 | .then((response) => { 14 | if (resultSuccess(response)) { 15 | return onSuccess(response.data); 16 | } else { 17 | return onError(response.data); 18 | } 19 | }) 20 | .catch((response) => { 21 | return onError(response); 22 | }); 23 | }; 24 | export { apiClient, triggerSideEffect }; 25 | -------------------------------------------------------------------------------- /client/src/components/Modal.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Modal, 4 | ModalOverlay, 5 | ModalContent, 6 | ModalHeader, 7 | ModalCloseButton, 8 | ModalBody, 9 | } from "@chakra-ui/core"; 10 | 11 | export default ({ isOpen, onClose, title, children }) => ( 12 | 13 | 14 | 15 | {title} 16 | 17 | {children} 18 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /client/src/components/ProtectedRoute.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Redirect } from "react-router-dom"; 3 | import { TokenManager } from "../storage"; 4 | 5 | const ProtectedRoute = ({ component: RenderComponent, ...rest }) => 6 | TokenManager.isAuthenticated() ? ( 7 | } 10 | /> 11 | ) : ( 12 | 13 | ); 14 | 15 | export default ProtectedRoute; 16 | -------------------------------------------------------------------------------- /client/src/components/index.js: -------------------------------------------------------------------------------- 1 | import Modal from "./Modal"; 2 | import ProtectedRoute from "./ProtectedRoute"; 3 | 4 | export { Modal, ProtectedRoute }; 5 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | import * as serviceWorker from "./serviceWorker"; 5 | import { ThemeProvider, CSSReset } from "@chakra-ui/core"; 6 | import theme from "./theme"; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | 12 | , 13 | document.getElementById("root") 14 | ); 15 | 16 | serviceWorker.unregister(); 17 | -------------------------------------------------------------------------------- /client/src/pages/landing/LoginForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useForm } from "react-hook-form"; 3 | import { 4 | FormErrorMessage, 5 | FormLabel, 6 | FormControl, 7 | Input, 8 | Button, 9 | } from "@chakra-ui/core"; 10 | 11 | export default ({ onSubmit }) => { 12 | const { handleSubmit, errors, register, formState } = useForm(); 13 | 14 | return ( 15 |
16 | 17 | Email address 18 | 29 | 30 | {errors.email && errors.email.message} 31 | 32 | 33 | 34 | Password 35 | 43 | 44 | {errors.password && errors.password.message} 45 | 46 | 47 | 55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /client/src/pages/landing/SignupForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useForm } from "react-hook-form"; 3 | import { 4 | FormErrorMessage, 5 | FormLabel, 6 | FormControl, 7 | Input, 8 | Button, 9 | } from "@chakra-ui/core"; 10 | 11 | export default ({ onSubmit }) => { 12 | const { handleSubmit, errors, register, formState, watch } = useForm(); 13 | 14 | return ( 15 |
16 | 17 | Email address 18 | 29 | 30 | {errors.email && errors.email.message} 31 | 32 | 33 | 34 | Password 35 | 47 | 48 | {errors.password && errors.password.message} 49 | 50 | 51 | 52 | Password (again) 53 | 60 | val === watch("password") || "Passwords do not match", 61 | })} 62 | /> 63 | 64 | {errors.passwordMatch && errors.passwordMatch.message} 65 | 66 | 67 | 75 |
76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /client/src/pages/landing/index.js: -------------------------------------------------------------------------------- 1 | import { Box, Button, Stack, useDisclosure } from "@chakra-ui/core"; 2 | import Modal from "../../components/Modal"; 3 | import React from "react"; 4 | import { useHistory, useLocation } from "react-router-dom"; 5 | import { apiClient, triggerSideEffect } from "../../api"; 6 | 7 | import { TokenManager } from "../../storage"; 8 | import SignupForm from "./SignupForm"; 9 | import LoginForm from "./LoginForm"; 10 | 11 | export function Landing({ onError }) { 12 | const signupDisclosure = useDisclosure(); 13 | const loginDisclosure = useDisclosure(); 14 | let history = useHistory(); 15 | let location = useLocation(); 16 | let { from } = location.state || { from: { pathname: "/services" } }; 17 | 18 | const signup = async (values) => { 19 | triggerSideEffect({ 20 | apiCall: () => apiClient.register(values), 21 | onError, 22 | onSuccess: () => { 23 | signupDisclosure.onClose(); 24 | loginDisclosure.onOpen(); 25 | }, 26 | }); 27 | }; 28 | 29 | const login = async (values) => 30 | triggerSideEffect({ 31 | apiCall: () => apiClient.login(values), 32 | onError, 33 | onSuccess: async (responseData) => { 34 | TokenManager.setSession(responseData); 35 | loginDisclosure.onClose(); 36 | history.replace(from); 37 | }, 38 | }); 39 | 40 | return ( 41 | <> 42 | 43 | 44 | 47 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /client/src/pages/sso/Service.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Heading, Image, Text, Button, Flex } from "@chakra-ui/core"; 3 | 4 | export default ({ 5 | name, 6 | icon, 7 | description, 8 | onConnect, 9 | onDisconnect, 10 | isConnected, 11 | }) => ( 12 | 19 | 20 | {name} 21 | 22 | {name} 23 | 24 | 25 | 26 | {description} 27 | 28 | 39 | 40 | ); 41 | -------------------------------------------------------------------------------- /client/src/pages/sso/index.js: -------------------------------------------------------------------------------- 1 | import { Box, Button, SimpleGrid } from "@chakra-ui/core"; 2 | import Service from "./Service"; 3 | import xss from "xss"; 4 | import React, { useEffect, useState } from "react"; 5 | import { TokenManager } from "../../storage"; 6 | import { apiClient, triggerSideEffect } from "../../api"; 7 | import services from "./providers"; 8 | import { useHistory, useLocation } from "react-router-dom"; 9 | 10 | export function Services({ onError }) { 11 | const [currentServices, setCurrentServices] = useState([]); 12 | let history = useHistory(); 13 | let location = useLocation(); 14 | 15 | const prepServices = () => 16 | services.map((s) => { 17 | s.isConnected = false; 18 | return s; 19 | }); 20 | 21 | const displayErrorIfPresent = () => { 22 | const error = new URLSearchParams(location.search).get("error"); 23 | if (error) { 24 | onError({ message: xss(error.replace(/"+/g, "").replace(/'+/g, "")) }); 25 | } 26 | }; 27 | const onLoad = async () => { 28 | await getUserServices(); 29 | await displayErrorIfPresent(); 30 | }; 31 | const getUserServices = async () => { 32 | if (!TokenManager.isAuthenticated()) { 33 | setCurrentServices(prepServices()); 34 | return; 35 | } 36 | 37 | let providers = 38 | (await triggerSideEffect({ 39 | apiCall: () => apiClient.providers(), 40 | onError, 41 | })) || []; 42 | setCurrentServices( 43 | services.map((s) => { 44 | s.isConnected = providers.includes(s.name.toLowerCase()); 45 | return s; 46 | }) 47 | ); 48 | }; 49 | 50 | const logout = async () => { 51 | TokenManager.clearTokenStorage(); 52 | history.replace("/"); 53 | }; 54 | 55 | useEffect(() => { 56 | onLoad(); 57 | }, []); 58 | 59 | return ( 60 | <> 61 | 62 | 63 | 64 | 65 | {currentServices.map((service) => ( 66 | 70 | service.onDisconnect(() => getUserServices(), onError) 71 | } 72 | /> 73 | ))} 74 | 75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/apple/index.js: -------------------------------------------------------------------------------- 1 | import icon from "./logo.svg"; 2 | 3 | export default { 4 | name: "Apple", 5 | icon, 6 | description: "Maker of the world's best computers", 7 | onConnect: () => console.log("Do Apple login"), 8 | onDisconnect: () => console.log("Do Apple logout"), 9 | }; 10 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/apple/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 29 | 31 | 51 | 55 | 56 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/chase/index.js: -------------------------------------------------------------------------------- 1 | import icon from "./logo.svg"; 2 | 3 | export default { 4 | name: "Chase", 5 | icon, 6 | description: "A major banking network", 7 | onConnect: () => console.log("Do Chase login"), 8 | onDisconnect: () => console.log("Do Chase logout"), 9 | }; 10 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/chase/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Artboard 5 | Created with Sketch. 6 | 7 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/facebook/index.js: -------------------------------------------------------------------------------- 1 | import icon from "./logo.svg"; 2 | 3 | export default { 4 | name: "Facebook", 5 | icon, 6 | description: "The most popular social media networking website in the world.", 7 | onConnect: () => console.log("Do Facebook login"), 8 | onDisconnect: () => console.log("Do Facebook logout"), 9 | }; 10 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/facebook/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/github/index.js: -------------------------------------------------------------------------------- 1 | import icon from "./logo.svg"; 2 | import { apiClient, triggerSideEffect } from "../../../../api"; 3 | 4 | export default { 5 | name: "Github", 6 | icon, 7 | description: "Where the world hosts its code", 8 | onConnect: () => { 9 | const url = `${process.env.BASE_URL || "http://localhost:5000"}/sso/github`; 10 | window.location.replace(url); 11 | }, 12 | onDisconnect: async (onSuccess, onError) => { 13 | triggerSideEffect({ 14 | apiCall: () => apiClient.revokeGithubToken(), 15 | onSuccess, 16 | onError, 17 | }); 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/github/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/index.js: -------------------------------------------------------------------------------- 1 | import facebook from "./facebook"; 2 | import twitter from "./twitter"; 3 | import github from "./github"; 4 | import reddit from "./reddit"; 5 | import apple from "./apple"; 6 | import chase from "./chase"; 7 | 8 | export default [facebook, twitter, github, reddit, apple, chase]; 9 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/reddit/index.js: -------------------------------------------------------------------------------- 1 | import icon from "./logo.svg"; 2 | 3 | export default { 4 | name: "Reddit", 5 | icon, 6 | description: "Where the world has conversations about esoteric stuff", 7 | onConnect: () => console.log("Do Reddit login"), 8 | onDisconnect: () => console.log("Do Reddit logout"), 9 | }; 10 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/reddit/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/twitter/index.js: -------------------------------------------------------------------------------- 1 | import icon from "./logo.svg"; 2 | 3 | export default { 4 | name: "Twitter", 5 | icon, 6 | description: "Where the world has conversations.", 7 | onConnect: () => console.log("Do Twitter login"), 8 | onDisconnect: () => console.log("Do Twitter logout"), 9 | }; 10 | -------------------------------------------------------------------------------- /client/src/pages/sso/providers/twitter/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 42 | 44 | 45 | 47 | image/svg+xml 48 | 50 | 51 | 52 | 53 | 58 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /client/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === "localhost" || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === "[::1]" || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener("load", () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | "This web app is being served cache-first by a service " + 46 | "worker. To learn more, visit https://bit.ly/CRA-PWA" 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then((registration) => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === "installed") { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | "New content is available and will be used when all " + 74 | "tabs for this page are closed. See https://bit.ly/CRA-PWA." 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log("Content is cached for offline use."); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch((error) => { 97 | console.error("Error during service worker registration:", error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { "Service-Worker": "script" }, 105 | }) 106 | .then((response) => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get("content-type"); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf("javascript") === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then((registration) => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | "No internet connection found. App is running in offline mode." 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ("serviceWorker" in navigator) { 133 | navigator.serviceWorker.ready 134 | .then((registration) => { 135 | registration.unregister(); 136 | }) 137 | .catch((error) => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /client/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom/extend-expect"; 6 | -------------------------------------------------------------------------------- /client/src/storage/TokenManager.js: -------------------------------------------------------------------------------- 1 | const STORAGE_KEYS = { 2 | EXPIRES_AT: "expires_at", 3 | REFRESH_TOKEN: "refresh_token", 4 | ACCESS_TOKEN: "access_token", 5 | }; 6 | 7 | function jwtDecode(token) { 8 | return JSON.parse(window.atob(token.split(".")[1])); 9 | } 10 | 11 | export default class TokenManager { 12 | static getToken() { 13 | return localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN); 14 | } 15 | 16 | static getRefreshToken() { 17 | return localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN); 18 | } 19 | 20 | static isAuthenticated() { 21 | const expiresAt = Number(localStorage.getItem(STORAGE_KEYS.EXPIRES_AT)); 22 | return ( 23 | TokenManager.getToken() && expiresAt && new Date().getTime() < expiresAt 24 | ); 25 | } 26 | 27 | static setSession = ({ access_token, refresh_token }) => { 28 | const { exp } = jwtDecode(access_token); 29 | localStorage.setItem(STORAGE_KEYS.EXPIRES_AT, (exp * 1000).toString()); 30 | localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh_token); 31 | localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access_token); 32 | }; 33 | 34 | static clearTokenStorage = () => { 35 | localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN); 36 | localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN); 37 | localStorage.removeItem(STORAGE_KEYS.EXPIRES_AT); 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /client/src/storage/index.js: -------------------------------------------------------------------------------- 1 | import TokenManager from "./TokenManager"; 2 | export { TokenManager }; 3 | -------------------------------------------------------------------------------- /client/src/theme.js: -------------------------------------------------------------------------------- 1 | import { theme } from "@chakra-ui/core"; 2 | 3 | export default { 4 | ...theme, 5 | // Any custom styling goes here! 6 | }; 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | client: 4 | image: openmind/opus-client:latest 5 | build: ./client/ 6 | ports: 7 | - 80:80 8 | server: 9 | image: openmined/opus-server:latest 10 | build: ./server/ 11 | volumes: ["./server:/server"] 12 | restart: always 13 | ports: 14 | - 5000:5000 15 | depends_on: 16 | - "db" 17 | environment: 18 | PORT: 5000 19 | FLASK_CONFIGURATION: development 20 | FRONTEND_HOST: http://localhost 21 | SQLALCHEMY_DATABASE_URI: postgresql://postgres:opus_local_5432@db:5432 22 | db: 23 | image: postgres:11.7-alpine 24 | environment: 25 | POSTGRES_PASSWORD: opus_local_5432 26 | ports: 27 | - 5432:5432 28 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | .DS_Store 3 | build/ 4 | _build/ 5 | dist/ 6 | .cache/ 7 | .coverage 8 | .eggs/ 9 | .idea/ 10 | .python-version 11 | *__pycache__* 12 | *.pyc 13 | *.swp 14 | *.swo 15 | *.ipynb_checkpoints* 16 | venv/ 17 | **.pytest_cache/ 18 | docs/_modules 19 | .coverage 20 | .vscode/ 21 | **/pip-wheel-metadata 22 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-slim 2 | RUN apt-get update 3 | # libpq-dev gcc are added here to ensure psychopg2 installs correctly 4 | RUN apt-get install -y git python3-pip python3-dev libpq-dev gcc 5 | COPY requirements*.txt /server/ 6 | 7 | WORKDIR /server 8 | RUN pip3 install -r requirements-dev.txt 9 | 10 | # remove gcc when done with installing psycopg2 11 | RUN apt-get autoremove -y gcc 12 | COPY . /server 13 | 14 | CMD inv db.wait && inv db.up && python run.py 15 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Running Commands 2 | 3 | To easily interact with the application via the `flask` CLI, simply run `docker-compose exec server bash` from the root of this project. 4 | 5 | To interact with the postgres DB via the CLI, you can use the following command: `docker exec -it private-identity-server_db_1 psql -U postgres`. 6 | 7 | # Database migrations 8 | ## Generating a new migration ## 9 | ``` 10 | $ inv db.create -m "Awesome migration message" 11 | ``` 12 | ## Getting migration to head version ## 13 | ``` 14 | $ inv db.up --revision head 15 | ``` 16 | ## Downgrade one version ## 17 | To downgrade once 18 | ``` 19 | $ inv db.down 20 | ``` 21 | 22 | To remove all migrations 23 | ``` 24 | $ inv db.down --revision base 25 | ``` 26 | 27 | # Testing the API 28 | ## Generating test data ## 29 | ``` 30 | $ inv create.test-data 31 | ``` -------------------------------------------------------------------------------- /server/app/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from flask import Flask, Request 5 | from werkzeug.exceptions import BadRequest 6 | 7 | from app.errors.messages import MESSAGES 8 | from .constants import INTERNAL_SERVER_ERROR, NOT_FOUND 9 | from .database import db 10 | from .errors import all_error_handlers, handlers 11 | from .extensions import migrate, password_hasher, config_oauth_server, config_oauth_client, cors 12 | from .routes import all_blueprints 13 | from .spec import configure_spec 14 | 15 | PACKAGE_VERSION = "0.0.0" 16 | APP_NAME = 'Private Identity Server' 17 | 18 | 19 | class CustomRequest(Request): 20 | def on_json_loading_failed(self, e): 21 | if isinstance(e, json.JSONDecodeError): 22 | raise json.JSONDecodeError(e.msg, e.doc, e.pos) 23 | raise BadRequest('Failed to decode JSON object: {0}'.format(e)) 24 | 25 | 26 | def create_app(): 27 | app = Flask(__name__) 28 | app.request_class = CustomRequest 29 | 30 | load_config(app) 31 | register_blueprints(app) 32 | register_extensions(app) 33 | return app 34 | 35 | 36 | def load_config(app): 37 | from werkzeug.utils import import_string 38 | config = { 39 | "development": "app.config.DevelopmentConfig", 40 | "production": "app.config.ProductionConfig", 41 | "test": "app.config.TestConfig", 42 | "default": "app.config.DevelopmentConfig" 43 | } 44 | config_name = os.getenv('FLASK_CONFIGURATION', 'default') 45 | config_obj = import_string(config[config_name]) 46 | config_obj.apply_config(app) 47 | 48 | 49 | def register_extensions(app): 50 | """ 51 | Register extensions with the Flask application. 52 | Order is important! 53 | """ 54 | password_hasher(app) 55 | db.init_app(app) 56 | migrate.init_app(app, db) 57 | cors.init_app(app) 58 | config_oauth_client(app) 59 | config_oauth_server(app) 60 | configure_spec(app) 61 | register_error_handlers(app) 62 | 63 | 64 | def register_blueprints(app): 65 | """register all needed flask blueprints with the current app""" 66 | for bp in all_blueprints: 67 | app.register_blueprint(bp) 68 | 69 | 70 | def register_error_handlers(app): 71 | """ 72 | Registers all error error handlers exported in .errors.__init__.py 73 | """ 74 | for func_name, exceptions in all_error_handlers.items(): 75 | handler = getattr(handlers, func_name) 76 | for excep in exceptions: 77 | app.register_error_handler(excep, handler) 78 | -------------------------------------------------------------------------------- /server/app/config.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | from flask import Config 5 | 6 | 7 | class DevelopmentConfig(Config): 8 | @staticmethod 9 | def apply_config(app): 10 | mapping = { 11 | 'OAUTH_JWT_CONFIG': { 12 | 'key': '-----BEGIN EC PRIVATE KEY-----\nMIHcAgEBBEIA3YrYysvfsUvosUMxCpAtqnmwYFVpAlrgl3oTQsQPuLoPuCyAkzbV\n90nIPd6R/9xtXkehGuv5DvRiWuLsWXBB/X+gBwYFK4EEACOhgYkDgYYABAAIeRlB\nGyaNMbzzd7PHPpVDxzFdqDhlCn2dLXtRb7pYMvr5VE59E/nQKVXbRMKZapcJvBhg\nV00wms62S5RzCm2HdwCsYsrDd5tVhLHbjQSzrqbud38zuRsFXoOltvW2uLnIAU2q\nrhh1S6o7i61BGOdfg+9YuiwHS/UCRT6VFOyHydapKw==\n-----END EC PRIVATE KEY-----\n', 13 | 'pub_key': '-----BEGIN PUBLIC KEY-----\nMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQACHkZQRsmjTG883ezxz6VQ8cxXag4\nZQp9nS17UW+6WDL6+VROfRP50ClV20TCmWqXCbwYYFdNMJrOtkuUcwpth3cArGLK\nw3ebVYSx240Es66m7nd/M7kbBV6Dpbb1tri5yAFNqq4YdUuqO4utQRjnX4PvWLos\nB0v1AkU+lRTsh8nWqSs=\n-----END PUBLIC KEY-----\n', 14 | 'alg': 'ES512', 15 | 'iss': 'http://localhost:500', 16 | 'exp': datetime.timedelta(minutes=15).seconds, 17 | }, 18 | 'JWT_PRIVATE_KEY': '-----BEGIN EC PRIVATE KEY-----\nMIHcAgEBBEIA3YrYysvfsUvosUMxCpAtqnmwYFVpAlrgl3oTQsQPuLoPuCyAkzbV\n90nIPd6R/9xtXkehGuv5DvRiWuLsWXBB/X+gBwYFK4EEACOhgYkDgYYABAAIeRlB\nGyaNMbzzd7PHPpVDxzFdqDhlCn2dLXtRb7pYMvr5VE59E/nQKVXbRMKZapcJvBhg\nV00wms62S5RzCm2HdwCsYsrDd5tVhLHbjQSzrqbud38zuRsFXoOltvW2uLnIAU2q\nrhh1S6o7i61BGOdfg+9YuiwHS/UCRT6VFOyHydapKw==\n-----END EC PRIVATE KEY-----\n', 19 | 'JWT_PUBLIC_KEY': '-----BEGIN PUBLIC KEY-----\nMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQACHkZQRsmjTG883ezxz6VQ8cxXag4\nZQp9nS17UW+6WDL6+VROfRP50ClV20TCmWqXCbwYYFdNMJrOtkuUcwpth3cArGLK\nw3ebVYSx240Es66m7nd/M7kbBV6Dpbb1tri5yAFNqq4YdUuqO4utQRjnX4PvWLos\nB0v1AkU+lRTsh8nWqSs=\n-----END PUBLIC KEY-----\n', 20 | 'JWT_ALGORITHM': 'ES512', 21 | 'FRONTEND_HOST': os.getenv('FRONTEND_HOST', 'http://localhost:3000'), 22 | 'JWT_DECODE_ISSUER': os.getenv('JWT_DECODE_ISSUER', 'http://localhost:5000'), 23 | 'JWT_ACCESS_TOKEN_EXPIRES': datetime.timedelta(minutes=15), 24 | 'JWT_REFRESH_TOKEN_EXPIRES': datetime.timedelta(days=30), 25 | 'PORT': 5000, 26 | 'FLASK_DEBUG': True, 27 | 'SECRET_KEY': '7d88acba72577d7ba964710bab26af4d', 28 | 'SQLALCHEMY_DATABASE_URI': os.getenv( 29 | 'SQLALCHEMY_DATABASE_URI', 30 | 'postgresql://postgres:opus_local_5432@localhost:5432' 31 | ), 32 | 'SQLALCHEMY_TRACK_MODIFICATIONS': os.getenv('SQLALCHEMY_TRACK_MODIFICATIONS', True), 33 | 'DEBUG': True, 34 | 'SQLALCHEMY_ECHO': True, 35 | 'AUTHLIB_INSECURE_TRANSPORT': True 36 | } 37 | app.config.from_mapping(mapping) 38 | 39 | 40 | class TestConfig(DevelopmentConfig): 41 | pass 42 | 43 | 44 | class ProductionConfig(Config): 45 | @staticmethod 46 | def apply_config(app): 47 | mapping = { 48 | 'PORT': 5000, 49 | 'JWT_PRIVATE_KEY': os.environ["JWT_PRIVATE_KEY"], 50 | 'JWT_PUBLIC_KEY': os.environ["JWT_PUBLIC_KEY"], 51 | 'JWT_ALGORITHM': 'ES512', 52 | 'JWT_DECODE_ISSUER': os.environ["JWT_DECODE_ISSUER"], 53 | 'JWT_ACCESS_TOKEN_EXPIRES': os.environ.get("JWT_ACCESS_TOKEN_EXPIRES", datetime.timedelta(minutes=15)), 54 | 'JWT_REFRESH_TOKEN_EXPIRES': os.environ.get("JWT_REFRESH_TOKEN_EXPIRES", datetime.timedelta(days=30)), 55 | 'FLASK_DEBUG': False, 56 | 'SECRET_KEY': os.environ['SECRET_KEY'], 57 | 'SQLALCHEMY_DATABASE_URI': os.environ['SQLALCHEMY_DATABASE_URI'], 58 | 'SQLALCHEMY_TRACK_MODIFICATIONS': os.getenv('SQLALCHEMY_TRACK_MODIFICATIONS', True), 59 | 'DEBUG': False, 60 | 'SQLALCHEMY_ECHO': False, 61 | 'AUTHLIB_INSECURE_TRANSPORT': False 62 | } 63 | app.config.from_mapping(mapping) 64 | -------------------------------------------------------------------------------- /server/app/constants.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | CASCADE = "all" 4 | KEEP_PARENTS = "save-update, merge, refresh-expire, expunge" 5 | 6 | SUCCESS = 200 7 | CREATED = 201 8 | NO_CONTENT = 204 9 | BAD_REQUEST = 400 10 | UNAUTHORIZED = 401 11 | FORBIDDEN = 403 12 | NOT_FOUND = 404 13 | INTERNAL_SERVER_ERROR = 500 14 | 15 | 16 | class Extensions(Enum): 17 | PASSWORD_HASHER = 'password_hasher' 18 | REQUIRE_OAUTH = 'require_oauth' 19 | AUTHORIZATION = 'authorization' 20 | AUTHLIB_FLASK_CLIENT = 'authlib.integrations.flask_client' 21 | -------------------------------------------------------------------------------- /server/app/database.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID, uuid4 2 | 3 | from flask_sqlalchemy import SQLAlchemy 4 | from sqlalchemy.dialects.postgresql import UUID as postgres_UUID 5 | from sqlalchemy.types import CHAR, TypeDecorator 6 | 7 | db = SQLAlchemy() 8 | 9 | 10 | class GUID(TypeDecorator): 11 | """ 12 | Platform-independent GUID type. 13 | 14 | Uses Postgresql's UUID type, otherwise uses 15 | CHAR(32), storing as stringified hex values. 16 | """ 17 | impl = CHAR 18 | 19 | def load_dialect_impl(self, dialect): 20 | if dialect.name == "postgresql": 21 | return dialect.type_descriptor(postgres_UUID()) 22 | 23 | return dialect.type_descriptor(CHAR(32)) 24 | 25 | def process_bind_param(self, value, dialect): 26 | if not value: 27 | return value 28 | elif dialect.name == "postgresql": 29 | return str(value) 30 | 31 | if not isinstance(value, UUID): 32 | return "%.32x" % int(UUID(value)) 33 | 34 | return "%.32x" % value.int # hexstring 35 | 36 | def process_result_value(self, value, dialect): 37 | if not value: 38 | return value 39 | 40 | return UUID(value) 41 | 42 | 43 | class CRUDMixin(object): 44 | id = db.Column(GUID, primary_key=True, nullable=False, default=lambda: str(uuid4())) 45 | 46 | @classmethod 47 | def create(cls, **kwargs): 48 | instance = cls(**kwargs) 49 | return instance.save() 50 | 51 | def update(self, commit=True, **kwargs): 52 | for attr, value in kwargs.items(): 53 | setattr(self, attr, value) 54 | return commit and self.save() or self 55 | 56 | def flush(self): 57 | db.session.flush() 58 | return self 59 | 60 | def save(self, commit=True): 61 | db.session.add(self) 62 | if commit: 63 | db.session.commit() 64 | return self 65 | 66 | def delete(self, commit=True): 67 | db.session.delete(self) 68 | return commit and db.session.commit() 69 | -------------------------------------------------------------------------------- /server/app/errors/__init__.py: -------------------------------------------------------------------------------- 1 | from .handlers import handle_password_mismatch 2 | from argon2.exceptions import VerifyMismatchError, VerificationError 3 | 4 | all_error_handlers = { 5 | 'handle_password_mismatch': [VerifyMismatchError, VerificationError] 6 | } 7 | -------------------------------------------------------------------------------- /server/app/errors/handlers.py: -------------------------------------------------------------------------------- 1 | from .messages import error_response, INCORRECT_PASSWORD 2 | 3 | 4 | def handle_password_mismatch(): 5 | return error_response(INCORRECT_PASSWORD) 6 | -------------------------------------------------------------------------------- /server/app/errors/messages.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | 3 | from app.constants import UNAUTHORIZED, FORBIDDEN, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND 4 | 5 | MESSAGE_KEY = 'message' 6 | BAD_PERMISSIONS_MSG = 'bad_permissions' 7 | BAD_REQUEST_MSG = 'bad_request' 8 | NO_TOKEN_MSG = 'no_token' 9 | BAD_TOKEN_MSG = 'bad_token' 10 | INCORRECT_PASSWORD = 'password_not_matching' 11 | USER_NOT_FOUND_MSG = 'user_not_found' 12 | INVALID_CREDENTIAL_MSG = 'invalid_credentials' 13 | SERVER_ERROR_MSG = 'server_error' 14 | MESSAGES = { 15 | NO_TOKEN_MSG: { 16 | "message": {MESSAGE_KEY: "Missing Authorization Header"}, 17 | "status_code": UNAUTHORIZED, 18 | }, 19 | BAD_TOKEN_MSG: { 20 | "message": {MESSAGE_KEY: "Please provide a valid token"}, 21 | "status_code": UNAUTHORIZED, 22 | }, 23 | BAD_PERMISSIONS_MSG: { 24 | "message": {MESSAGE_KEY: "Bad permissions"}, 25 | "status_code": FORBIDDEN, 26 | }, 27 | BAD_REQUEST_MSG: { 28 | "message": {MESSAGE_KEY: "Bad request"}, 29 | "status_code": BAD_REQUEST, 30 | }, 31 | INCORRECT_PASSWORD: { 32 | "message": {MESSAGE_KEY: "Incorrect password"}, 33 | "status_code": BAD_REQUEST, 34 | }, 35 | USER_NOT_FOUND_MSG: { 36 | "message": {MESSAGE_KEY: "User not found"}, 37 | "status_code": NOT_FOUND, 38 | }, 39 | INVALID_CREDENTIAL_MSG: { 40 | "message": {MESSAGE_KEY: "Invalid credentials"}, 41 | "status_code": UNAUTHORIZED, 42 | }, 43 | SERVER_ERROR_MSG: { 44 | "message": {MESSAGE_KEY: "Something went wrong. Please try again later"}, 45 | "status_code": INTERNAL_SERVER_ERROR, 46 | } 47 | } 48 | 49 | 50 | def error_response(message_type, message=None): 51 | """ 52 | Formats the error message and send the correct error code 53 | :param message_type: the message type for the particular error 54 | :param message: optional custom message for the given error type 55 | :return: json response, status code 56 | """ 57 | response_message = dict(MESSAGES[message_type]['message']) if not message else {MESSAGE_KEY: message} 58 | status_code = MESSAGES[message_type]['status_code'] 59 | return jsonify(response_message), status_code 60 | -------------------------------------------------------------------------------- /server/app/extensions.py: -------------------------------------------------------------------------------- 1 | import argon2 2 | from authlib.integrations.flask_client import OAuth 3 | from authlib.integrations.flask_oauth2 import ( 4 | AuthorizationServer, ResourceProtector) 5 | from authlib.integrations.sqla_oauth2 import ( 6 | create_query_client_func, 7 | create_save_token_func, 8 | create_bearer_token_validator, 9 | ) 10 | from flask_cors import CORS 11 | from flask_migrate import Migrate 12 | 13 | from .constants import Extensions 14 | from .database import db 15 | from .models import OAuth2Token, OAuth2Client 16 | from .services.oauth2 import AuthorizationCodeGrant, OpenIDCode, HybridGrant 17 | 18 | migrate = Migrate() 19 | cors = CORS() 20 | 21 | 22 | def register_as_extension(app, name, object_instance): 23 | if not hasattr(app, 'extensions'): 24 | app.extensions = {} 25 | 26 | if name in app.extensions: 27 | raise RuntimeError("Flask extension already initialized") 28 | 29 | app.extensions[name] = object_instance 30 | 31 | 32 | def config_oauth_client(app): 33 | oauth = OAuth(app) 34 | oauth.register( 35 | name='github', 36 | client_id='7026c5fe3eaf27646dc4', 37 | client_secret='5801cd4af16af69d45472f1a6b344b4cd53642aa', 38 | access_token_url='https://github.com/login/oauth/access_token', 39 | authorize_url='https://github.com/login/oauth/authorize', 40 | api_base_url='https://api.github.com/', 41 | client_kwargs={'scope': 'user:email'}, 42 | ) 43 | 44 | 45 | def config_oauth_server(app): 46 | require_oauth = ResourceProtector() 47 | authorization = AuthorizationServer() 48 | query_client = create_query_client_func(db.session, OAuth2Client) 49 | save_token = create_save_token_func(db.session, OAuth2Token) 50 | authorization.init_app( 51 | app, 52 | query_client=query_client, 53 | save_token=save_token 54 | ) 55 | # support all openid grants 56 | authorization.register_grant(AuthorizationCodeGrant, [ 57 | OpenIDCode(require_nonce=True, **app.config['OAUTH_JWT_CONFIG']), 58 | ]) 59 | authorization.register_grant(HybridGrant) 60 | 61 | # protect resource 62 | bearer_cls = create_bearer_token_validator(db.session, OAuth2Token) 63 | require_oauth.register_token_validator(bearer_cls()) 64 | 65 | register_as_extension(app, Extensions.AUTHORIZATION, authorization) 66 | register_as_extension(app, Extensions.REQUIRE_OAUTH, require_oauth) 67 | 68 | 69 | def password_hasher(app): 70 | password_hasher = argon2.PasswordHasher( 71 | time_cost=app.config.get('ARGON2_TIME_COST', argon2.DEFAULT_TIME_COST), 72 | memory_cost=app.config.get('ARGON2_MEMORY_COST', argon2.DEFAULT_MEMORY_COST), 73 | parallelism=app.config.get('ARGON2_PARALLELISM', argon2.DEFAULT_PARALLELISM), 74 | hash_len=app.config.get('ARGON2_HASH_LENGTH', argon2.DEFAULT_HASH_LENGTH), 75 | salt_len=app.config.get('ARGON2_SALT_LENGTH', argon2.DEFAULT_RANDOM_SALT_LENGTH), 76 | encoding=app.config.get('ARGON2_ENCODING', 'utf-8') 77 | ) 78 | register_as_extension(app, Extensions.PASSWORD_HASHER, password_hasher) 79 | -------------------------------------------------------------------------------- /server/app/models.py: -------------------------------------------------------------------------------- 1 | from authlib.integrations.sqla_oauth2 import ( 2 | OAuth2ClientMixin, 3 | OAuth2AuthorizationCodeMixin, 4 | OAuth2TokenMixin, 5 | ) 6 | from flask import current_app 7 | 8 | from app.constants import CASCADE, KEEP_PARENTS, Extensions 9 | from app.database import CRUDMixin, db, GUID 10 | 11 | 12 | class Users(CRUDMixin, db.Model): 13 | __tablename__ = 'users' 14 | username = db.Column(db.String(255)) 15 | email = db.Column(db.String(255), unique=True) 16 | password_hash = db.Column(db.String(128)) 17 | 18 | oauth2_client = db.relationship("OAuth2Client", uselist=True, back_populates="users", cascade=CASCADE) 19 | oauth2_code = db.relationship("OAuth2AuthorizationCode", uselist=True, back_populates="users", cascade=CASCADE) 20 | oauth2_token = db.relationship("OAuth2Token", uselist=True, back_populates="users", cascade=CASCADE) 21 | 22 | # required by authlib/integrations/sqla_oauth2/functions.py 23 | def get_user_id(self): 24 | return self.id 25 | 26 | @property 27 | def password(self): 28 | raise AttributeError('password not readable') 29 | 30 | @property 31 | def brief(self): 32 | return { 33 | "id": self.id, 34 | "email": self.email, 35 | "username": self.username 36 | } 37 | 38 | @password.setter 39 | def password(self, password): 40 | self.password_hash = current_app.extensions[Extensions.PASSWORD_HASHER].hash(password) 41 | 42 | def password_correct(self, password): 43 | return current_app.extensions[Extensions.PASSWORD_HASHER].verify(self.password_hash, password) 44 | 45 | 46 | class OAuth2Client(CRUDMixin, db.Model, OAuth2ClientMixin): 47 | __tablename__ = 'oauth2_client' 48 | 49 | user_id = db.Column('user_id', GUID(), db.ForeignKey('users.id', name="oauth2_client_user_id_fkey"), nullable=False) 50 | users = db.relationship("Users", uselist=False, back_populates="oauth2_client", cascade=KEEP_PARENTS) 51 | 52 | 53 | class OAuth2AuthorizationCode(CRUDMixin, db.Model, OAuth2AuthorizationCodeMixin): 54 | __tablename__ = 'oauth2_code' 55 | 56 | user_id = db.Column('user_id', GUID(), db.ForeignKey('users.id', name="oauth2_code_user_id_fkey"), nullable=False) 57 | users = db.relationship("Users", uselist=False, back_populates="oauth2_code", cascade=KEEP_PARENTS) 58 | 59 | 60 | class OAuth2Token(CRUDMixin, db.Model, OAuth2TokenMixin): 61 | __tablename__ = 'oauth2_token' 62 | 63 | provider = db.Column('provider', db.String(255), nullable=False, default='opus') 64 | 65 | user_id = db.Column('user_id', GUID(), db.ForeignKey('users.id', name="oauth2_token_user_id_fkey"), nullable=False) 66 | users = db.relationship("Users", uselist=False, back_populates="oauth2_token", cascade=KEEP_PARENTS) 67 | -------------------------------------------------------------------------------- /server/app/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from .users import users 2 | from .sso import sso 3 | from .oauth import oauth 4 | 5 | all_blueprints = [ 6 | users, 7 | sso, 8 | oauth, 9 | ] 10 | -------------------------------------------------------------------------------- /server/app/routes/oauth.py: -------------------------------------------------------------------------------- 1 | from app.constants import Extensions 2 | from app.models import Users 3 | from app.utils.spec import docs_path 4 | from authlib.oauth2 import OAuth2Error 5 | from flasgger.utils import swag_from 6 | from flask import Blueprint, request, session, current_app, jsonify, render_template 7 | 8 | BASE_URL = '/oauth' 9 | OAUTH_AUTHORIZE = {'rule': '/authorize', 'methods': ['POST', 'GET'], 'endpoint': 'authorize'} 10 | OAUTH_TOKEN = {'rule': '/token', 'methods': ['POST'], 'endpoint': 'token'} 11 | OAUTH_REVOKE = {'rule': '/revoke', 'methods': ['POST'], 'endpoint': 'revoke'} 12 | 13 | oauth = Blueprint(name='oauth', import_name=__name__, url_prefix=BASE_URL) 14 | 15 | 16 | def current_user(): 17 | if 'id' in session: 18 | uid = session['id'] 19 | return Users.query.get(uid) 20 | return None 21 | 22 | 23 | @oauth.route(**OAUTH_AUTHORIZE) 24 | @swag_from(docs_path('api', 'oauth', 'oauth_authorize.yaml'), methods=['POST', 'GET'], endpoint='oauth.authorize') 25 | def oauth_authorize(): 26 | user = current_user() 27 | if request.method == 'GET': 28 | try: 29 | grant = current_app.extensions[Extensions.AUTHORIZATION].validate_consent_request(end_user=user) 30 | except OAuth2Error as error: 31 | return jsonify(dict(error.get_body())) 32 | return render_template('authorize.html', user=user, grant=grant) 33 | if not user and 'username' in request.form: 34 | username = request.form.get('username') 35 | user = Users.query.filter_by(username=username).first() 36 | if request.form['confirm']: 37 | grant_user = user 38 | else: 39 | grant_user = None 40 | return current_app.extensions[Extensions.AUTHORIZATION].create_authorization_response(grant_user=grant_user) 41 | 42 | 43 | @oauth.route(**OAUTH_TOKEN) 44 | @swag_from(docs_path('api', 'oauth', 'oauth_token.yaml'), methods=['POST'], endpoint='oauth.token') 45 | def oauth_token(): 46 | return current_app.extensions[Extensions.AUTHORIZATION].create_token_response() 47 | 48 | 49 | @oauth.route(**OAUTH_REVOKE) 50 | @swag_from(docs_path('api', 'oauth', 'oauth_revoke.yaml'), methods=['GET'], endpoint='oauth.revoke') 51 | def oauth_revoke(): 52 | return current_app.extensions[Extensions.AUTHORIZATION].create_endpoint_response('revocation') 53 | -------------------------------------------------------------------------------- /server/app/routes/sso.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from flasgger.utils import swag_from 4 | from flask import Blueprint, current_app, redirect, jsonify 5 | from sqlalchemy import and_ 6 | from app.constants import BAD_REQUEST 7 | from app.constants import SUCCESS, Extensions 8 | from app.models import Users, OAuth2Token 9 | from app.utils.permissions import with_identity 10 | from app.utils.spec import docs_path 11 | 12 | BASE_URL = '/sso' 13 | SSO_PROVIDERS = {'rule': '/providers', 'methods': ['GET'], 'endpoint': 'providers'} 14 | SSO_GITHUB = {'rule': '/github', 'methods': ['GET'], 'endpoint': 'github'} 15 | SSO_GITHUB_AUTHORIZE = {'rule': '/github/authorize', 'methods': ['GET'], 'endpoint': 'github_authorize'} 16 | SSO_GITHUB_REVOKE = {'rule': '/github/revoke', 'methods': ['POST'], 'endpoint': 'github_revoke'} 17 | 18 | sso = Blueprint(name='sso', import_name=__name__, url_prefix=BASE_URL) 19 | 20 | 21 | @sso.route(**SSO_PROVIDERS) 22 | @swag_from(docs_path('api', 'sso', 'sso_providers.yaml'), methods=['GET'], endpoint='sso.providers') 23 | @with_identity 24 | def sso_providers(identity): 25 | tokens = OAuth2Token.query \ 26 | .join(Users, Users.id == OAuth2Token.user_id) \ 27 | .filter(Users.email == identity).all() 28 | result = [] 29 | if tokens: 30 | result = [token.provider for token in tokens] 31 | return jsonify(result), SUCCESS 32 | 33 | 34 | @sso.route(**SSO_GITHUB) 35 | @swag_from(docs_path('api', 'sso', 'sso_github.yaml'), methods=['GET'], endpoint='sso.github') 36 | def sso_github(): 37 | oauth = current_app.extensions[Extensions.AUTHLIB_FLASK_CLIENT.value] 38 | return oauth.github.authorize_redirect( 39 | redirect_uri=f"{current_app.config['JWT_DECODE_ISSUER']}/sso/github/authorize" 40 | ) 41 | 42 | 43 | @sso.route(**SSO_GITHUB_AUTHORIZE) 44 | @swag_from(docs_path('api', 'sso', 'sso_github_authorize.yaml'), methods=['GET'], endpoint='sso.github_authorize') 45 | def sso_github_authorize(): 46 | oauth = current_app.extensions[Extensions.AUTHLIB_FLASK_CLIENT.value] 47 | token = oauth.github.authorize_access_token() 48 | resp = oauth.github.get('user') 49 | profile = resp.json() 50 | email = profile['email'] 51 | user = Users.query.filter_by(email=email).first() 52 | if not user: 53 | return redirect(f'{current_app.config["FRONTEND_HOST"]}/services?error="{email} not found on server"') 54 | 55 | OAuth2Token.create( 56 | client_id=oauth.github.client_id, 57 | provider='github', 58 | token_type=token['token_type'], 59 | access_token=token['access_token'], 60 | scope=token['scope'], 61 | issued_at=datetime.datetime.now().timestamp(), 62 | user_id=user.id 63 | ) 64 | return redirect(current_app.config['FRONTEND_HOST']), SUCCESS 65 | 66 | 67 | @sso.route(**SSO_GITHUB_REVOKE) 68 | @swag_from(docs_path('api', 'sso', 'sso_github_revoke.yaml'), methods=['POST'], endpoint='sso.github_revoke') 69 | @with_identity 70 | def sso_github_revoke(identity): 71 | tokens = OAuth2Token.query \ 72 | .join(Users, Users.id == OAuth2Token.user_id) \ 73 | .filter(and_(Users.email == identity, OAuth2Token.provider == 'github')) \ 74 | .all() 75 | [token.delete() for token in tokens] 76 | return jsonify({'message': 'success'}), SUCCESS 77 | -------------------------------------------------------------------------------- /server/app/routes/users.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from authlib.integrations.flask_oauth2 import current_token 4 | from flasgger.utils import swag_from 5 | from flask import Blueprint, jsonify, current_app, request 6 | 7 | from app.constants import SUCCESS, Extensions 8 | from app.errors.messages import error_response, INCORRECT_PASSWORD, USER_NOT_FOUND_MSG, INVALID_CREDENTIAL_MSG 9 | from app.models import Users 10 | from app.utils.permissions import encode_refresh_token, encode_access_token 11 | from app.utils.spec import docs_path 12 | 13 | BASE_URL = '/users' 14 | USERS_LOGIN = {'rule': '/login', 'methods': ['POST'], 'endpoint': 'login'} 15 | USERS_REGISTER = {'rule': '/register', 'methods': ['POST'], 'endpoint': 'register'} 16 | USERS_PROFILE = {'rule': '/profile', 'methods': ['GET'], 'endpoint': 'profile'} 17 | 18 | users = Blueprint(name='users', import_name=__name__, url_prefix=BASE_URL) 19 | 20 | 21 | @users.route(**USERS_REGISTER) 22 | @swag_from( 23 | docs_path('api', 'users', 'users_register.yaml'), methods=['POST'], endpoint='users.register' 24 | ) 25 | def users_register(): 26 | request_data = request.get_json() 27 | email = request_data['email'] 28 | user = Users.query.filter_by(email=email).first() 29 | if user: 30 | return jsonify(user.brief), SUCCESS 31 | 32 | password = request_data['password'] 33 | if not password or password != request_data['passwordMatch']: 34 | return error_response(INCORRECT_PASSWORD) 35 | user = Users.create(email=email, password=password, username=email.split('@')[0]) 36 | return jsonify(user.brief), SUCCESS 37 | 38 | 39 | @users.route(**USERS_LOGIN) 40 | @swag_from( 41 | docs_path('api', 'users', 'users_login.yaml'), methods=['POST'], endpoint='users.login' 42 | ) 43 | def users_login(): 44 | request_data = request.get_json() 45 | user = Users.query.filter_by(email=request_data['email']).first() 46 | if user is None: 47 | return error_response(USER_NOT_FOUND_MSG) 48 | elif not user.password_correct(request_data['password']): 49 | return error_response(INVALID_CREDENTIAL_MSG) 50 | 51 | return jsonify({ 52 | 'access_token': encode_access_token(request_data['email']), 53 | 'refresh_token': encode_refresh_token(request_data['email']) 54 | }), SUCCESS 55 | 56 | 57 | def require_oauth(scope): 58 | def wrapper(f): 59 | @wraps(f) 60 | def decorated(*args, **kwargs): 61 | return current_app.extensions[Extensions.REQUIRE_OAUTH](scope)(f)(*args, **kwargs) 62 | 63 | return decorated 64 | 65 | return wrapper 66 | 67 | 68 | @users.route(**USERS_PROFILE) 69 | @swag_from(docs_path('api', 'users', 'users_profile.yaml'), methods=['GET'], endpoint='users.profile') 70 | @require_oauth('profile') 71 | def api_me(): 72 | user = current_token.users 73 | return jsonify(id=user.id, username=user.username), SUCCESS 74 | -------------------------------------------------------------------------------- /server/app/services/__init__.py: -------------------------------------------------------------------------------- 1 | def init_app(app): 2 | pass 3 | -------------------------------------------------------------------------------- /server/app/services/facebook.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenMined/opus/0995ec03640ab23081ab83f015cc62d99649238a/server/app/services/facebook.py -------------------------------------------------------------------------------- /server/app/services/oauth2.py: -------------------------------------------------------------------------------- 1 | from app.models import Users, OAuth2AuthorizationCode 2 | from authlib.oauth2.rfc6749.grants import ( 3 | AuthorizationCodeGrant as _AuthorizationCodeGrant, 4 | ) 5 | from authlib.oidc.core import UserInfo 6 | from authlib.oidc.core.grants import ( 7 | OpenIDCode as _OpenIDCode, 8 | OpenIDImplicitGrant as _OpenIDImplicitGrant, 9 | OpenIDHybridGrant as _OpenIDHybridGrant, 10 | ) 11 | from flask import current_app 12 | from werkzeug.security import gen_salt 13 | 14 | 15 | def exists_nonce(nonce, req): 16 | exists = OAuth2AuthorizationCode.query.filter_by( 17 | client_id=req.client_id, nonce=nonce 18 | ).first() 19 | return bool(exists) 20 | 21 | 22 | def generate_user_info(user, scope): 23 | return UserInfo(sub=str(user.id), name=user.username) 24 | 25 | 26 | def create_authorization_code(client, grant_user, request): 27 | code = gen_salt(48) 28 | nonce = request.data.get('nonce') 29 | OAuth2AuthorizationCode.create( 30 | code=code, 31 | client_id=client.client_id, 32 | redirect_uri=request.redirect_uri, 33 | scope=request.scope, 34 | user_id=grant_user.id, 35 | nonce=nonce, 36 | ) 37 | return code 38 | 39 | 40 | class AuthorizationCodeGrant(_AuthorizationCodeGrant): 41 | def create_authorization_code(self, client, grant_user, request): 42 | return create_authorization_code(client, grant_user, request) 43 | 44 | def parse_authorization_code(self, code, client): 45 | item = OAuth2AuthorizationCode.query.filter_by( 46 | code=code, client_id=client.client_id).first() 47 | if item and not item.is_expired(): 48 | return item 49 | 50 | def delete_authorization_code(self, authorization_code): 51 | authorization_code.delete() 52 | 53 | def authenticate_user(self, authorization_code): 54 | return Users.query.get(authorization_code.user_id) 55 | 56 | 57 | class OpenIDCode(_OpenIDCode): 58 | def exists_nonce(self, nonce, request): 59 | return exists_nonce(nonce, request) 60 | 61 | def generate_user_info(self, user, scope): 62 | return generate_user_info(user, scope) 63 | 64 | 65 | class ImplicitGrant(_OpenIDImplicitGrant): 66 | def exists_nonce(self, nonce, request): 67 | return exists_nonce(nonce, request) 68 | 69 | def get_jwt_config(self, grant): 70 | return current_app.config['OAUTH_JWT_CONFIG'] 71 | 72 | def generate_user_info(self, user, scope): 73 | return generate_user_info(user, scope) 74 | 75 | 76 | class HybridGrant(_OpenIDHybridGrant): 77 | def create_authorization_code(self, client, grant_user, request): 78 | return create_authorization_code(client, grant_user, request) 79 | 80 | def exists_nonce(self, nonce, request): 81 | return exists_nonce(nonce, request) 82 | 83 | def get_jwt_config(self): 84 | return current_app.config['OAUTH_JWT_CONFIG'] 85 | 86 | def generate_user_info(self, user, scope): 87 | return generate_user_info(user, scope) 88 | -------------------------------------------------------------------------------- /server/app/spec.py: -------------------------------------------------------------------------------- 1 | from apispec.ext.marshmallow import MarshmallowPlugin 2 | from apispec_webframeworks.flask import FlaskPlugin 3 | from flasgger import Swagger, APISpec, fields 4 | from flask_marshmallow import Marshmallow 5 | 6 | from .models import Users 7 | 8 | ma = Marshmallow() 9 | 10 | 11 | class ErrorSchema(ma.Schema): 12 | message = fields.Str(required=True) 13 | 14 | 15 | class UserSchema(ma.ModelSchema): 16 | class Meta: 17 | model = Users 18 | 19 | 20 | class TokenSchema(ma.Schema): 21 | access_token = fields.Str() 22 | token_type = fields.Str() 23 | expires_in = fields.Integer() 24 | 25 | 26 | definitions = [ErrorSchema, UserSchema, TokenSchema] 27 | 28 | 29 | def configure_spec(app): 30 | ma.init_app(app) 31 | app.config['SWAGGER'] = {'uiversion': 3} 32 | spec = APISpec( 33 | title='Private Identity Server', 34 | version='0.0.0', 35 | openapi_version='2.0', 36 | plugins=[ 37 | FlaskPlugin(), 38 | MarshmallowPlugin(), 39 | ], 40 | ) 41 | template = spec.to_flasgger( 42 | app, 43 | definitions=definitions 44 | ) 45 | template['securityDefinitions'] = { 46 | 'basicAuth': { 47 | 'type': 'basic' 48 | }, 49 | 'Bearer': { 50 | 'type': 'apiKey', 51 | 'name': 'Authorization', 52 | 'in': 'header' 53 | } 54 | } 55 | Swagger(app, template=template) 56 | -------------------------------------------------------------------------------- /server/app/templates/authorize.html: -------------------------------------------------------------------------------- 1 |

{{grant.client.client_name}} is requesting: 2 | {{ grant.request.scope }} 3 |

4 | 5 |
6 | 10 | {% if not user %} 11 |

You haven't logged in. Log in with:

12 |
13 | 14 |
15 | {% endif %} 16 |
17 | 18 |
19 | -------------------------------------------------------------------------------- /server/app/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenMined/opus/0995ec03640ab23081ab83f015cc62d99649238a/server/app/utils/__init__.py -------------------------------------------------------------------------------- /server/app/utils/permissions.py: -------------------------------------------------------------------------------- 1 | """ICO service utility functions""" 2 | import datetime 3 | import uuid 4 | from functools import wraps 5 | 6 | import jwt 7 | from flask import current_app, request 8 | 9 | from app.errors.messages import error_response, NO_TOKEN_MSG, BAD_TOKEN_MSG 10 | 11 | 12 | def _encode_jwt(additional_token_data, expires_delta): 13 | now = datetime.datetime.utcnow() 14 | token_data = { 15 | 'iat': now, 16 | 'nbf': now, 17 | 'iss': current_app.config['JWT_DECODE_ISSUER'], 18 | 'aud': current_app.config['JWT_DECODE_ISSUER'], 19 | 'jti': str(uuid.uuid4()), 20 | 'exp': now + expires_delta 21 | } 22 | token_data.update(additional_token_data) 23 | encoded_token = jwt.encode( 24 | payload=token_data, 25 | key=current_app.config['JWT_PRIVATE_KEY'], 26 | algorithm=current_app.config['JWT_ALGORITHM'] 27 | ).decode('utf-8') 28 | return encoded_token 29 | 30 | 31 | def encode_access_token(identity): 32 | token_data = { 33 | 'email': identity, 34 | 'type': 'access', 35 | } 36 | return _encode_jwt(token_data, current_app.config['JWT_ACCESS_TOKEN_EXPIRES']) 37 | 38 | 39 | def encode_refresh_token(identity): 40 | token_data = { 41 | 'email': identity, 42 | 'type': 'refresh', 43 | } 44 | 45 | return _encode_jwt(token_data, current_app.config['JWT_REFRESH_TOKEN_EXPIRES']) 46 | 47 | 48 | def decode_jwt(encoded_token): 49 | data = jwt.decode( 50 | jwt=encoded_token, 51 | key=current_app.config['JWT_PUBLIC_KEY'], 52 | algorithms=current_app.config['JWT_ALGORITHM'], 53 | audience=current_app.config['JWT_DECODE_ISSUER'], 54 | issuer=current_app.config['JWT_DECODE_ISSUER'], 55 | leeway=0 56 | ) 57 | 58 | # Make sure that any custom claims we expect in the token are present 59 | if 'jti' not in data: 60 | data['jti'] = None 61 | if 'email' not in data: 62 | return error_response(BAD_TOKEN_MSG) 63 | if 'type' not in data: 64 | data['type'] = 'access' 65 | if data['type'] not in ('refresh', 'access'): 66 | return error_response(BAD_TOKEN_MSG) 67 | if data['type'] == 'access': 68 | if 'fresh' not in data: 69 | data['fresh'] = False 70 | return data 71 | 72 | 73 | def get_jwt_identity(): 74 | auth_header = request.headers.get('Authorization', None) 75 | if not auth_header: 76 | return error_response(NO_TOKEN_MSG) 77 | 78 | parts = auth_header.strip().split() 79 | if len(parts) != 2: 80 | return error_response(BAD_TOKEN_MSG) 81 | 82 | encoded_token = parts[1] 83 | token = decode_jwt(encoded_token) 84 | return token['email'] 85 | 86 | 87 | def with_identity(api_method): 88 | """create JWT identity object for request""" 89 | 90 | @wraps(api_method) 91 | def with_identity_func(*args, **kwargs): 92 | identity = get_jwt_identity() 93 | if not identity: 94 | return error_response(BAD_TOKEN_MSG) 95 | 96 | kwargs["identity"] = identity 97 | result = api_method(*args, **kwargs) 98 | return result 99 | 100 | return with_identity_func 101 | -------------------------------------------------------------------------------- /server/app/utils/spec.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | docs_path = lambda *args: os.path.abspath( 4 | os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'docs', *args)) 5 | 6 | -------------------------------------------------------------------------------- /server/docs/api/oauth/oauth_authorize.yaml: -------------------------------------------------------------------------------- 1 | summary: Authentication Endpoint 2 | operationId: authorize 3 | parameters: 4 | - name: response_type 5 | in: query 6 | required: true 7 | type: string 8 | enum: 9 | - code 10 | - id_token 11 | - token 12 | - name: client_id 13 | in: query 14 | required: true 15 | type: string 16 | - name: redirect_uri 17 | in: query 18 | required: true 19 | type: string 20 | - name: nonce 21 | in: query 22 | required: true 23 | type: string 24 | - name: scope 25 | in: query 26 | required: true 27 | type: string 28 | - name: grant_type 29 | in: query 30 | required: true 31 | type: string 32 | enum: 33 | - authorization_code 34 | 35 | produces: 36 | - application/json 37 | responses: 38 | 302: 39 | description: Redirect to OAuth provider 40 | 400: 41 | description: Responds with 400 when the grant type is invalid 42 | schema: 43 | $ref: '#/definitions/Error' 44 | examples: 45 | application/json: |- 46 | { 47 | "error":"invalid_grant_type" 48 | } 49 | tags: 50 | - 'oauth' -------------------------------------------------------------------------------- /server/docs/api/oauth/oauth_revoke.yaml: -------------------------------------------------------------------------------- 1 | tags: 2 | - 'oauth' 3 | summary: Revoke access token 4 | operationId: revoke 5 | responses: 6 | 200: 7 | description: Empty body with 200 code 8 | 403: 9 | description: Access Token already invalidated or not found 10 | 500: 11 | description: Internal server error -------------------------------------------------------------------------------- /server/docs/api/oauth/oauth_token.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | - basicAuth: [] 3 | summary: Request Access Token 4 | operationId: token 5 | description: | 6 | Partner makes a request to the token endpoint by adding the 7 | following parameters described below 8 | consumes: 9 | - application/x-www-form-urlencoded 10 | produces: 11 | - application/json 12 | parameters: 13 | - name: grant_type 14 | in: formData 15 | description: Value MUST be set to "authorization_code" as per RFC 16 | required: true 17 | type: string 18 | enum: 19 | - authorization_code 20 | - name: code 21 | in: formData 22 | description: | 23 | The code received in the query string when redirected from authorization 24 | page 25 | required: true 26 | type: string 27 | - name: redirect_uri 28 | in: query 29 | required: true 30 | type: string 31 | responses: 32 | 200: 33 | description: Authorisation token (Bearer) 34 | schema: 35 | $ref: '#/definitions/Token' 36 | examples: 37 | application/json: |- 38 | { 39 | "access_token": "ACCESS_TOKEN", 40 | "token_type": "Bearer", 41 | "expires_in": 3600, 42 | } 43 | 400: 44 | description: As per RFC authorisation server responds with 400 in case of error 45 | schema: 46 | $ref: '#/definitions/Error' 47 | examples: 48 | application/json: |- 49 | { 50 | "error":"invalid_request" 51 | } 52 | tags: 53 | - oauth -------------------------------------------------------------------------------- /server/docs/api/users/users_login.yaml: -------------------------------------------------------------------------------- 1 | tags: 2 | - 'users' 3 | summary: User login endpoint 4 | consumes: 5 | - application/json 6 | parameters: 7 | - in: body 8 | name: user 9 | description: The user to create. 10 | schema: 11 | type: object 12 | required: 13 | - username 14 | - password 15 | properties: 16 | username: 17 | type: string 18 | password: 19 | type: string 20 | responses: 21 | '200': 22 | description: OK 23 | content: 24 | application/json: 25 | schema: 26 | $ref: '#/definitions/User' 27 | '400': 28 | description: Access Denied 29 | content: 30 | application/json: 31 | schema: 32 | $ref: '#/definitions/Error' -------------------------------------------------------------------------------- /server/docs/api/users/users_profile.yaml: -------------------------------------------------------------------------------- 1 | tags: 2 | - 'users' 3 | security: 4 | - Bearer: [] 5 | summary: Returns the user profile after going through the SSO flow 6 | responses: 7 | '200': 8 | description: OK 9 | content: 10 | application/json: 11 | schema: 12 | $ref: '#/definitions/User' 13 | '401': 14 | description: Bad permissions 15 | content: 16 | application/json: 17 | schema: 18 | $ref: '#/definitions/Error' 19 | '403': 20 | description: Access Denied 21 | content: 22 | application/json: 23 | schema: 24 | $ref: '#/definitions/Error' -------------------------------------------------------------------------------- /server/docs/api/users/users_register.yaml: -------------------------------------------------------------------------------- 1 | tags: 2 | - 'users' 3 | summary: User registration endpoint 4 | consumes: 5 | - application/json 6 | parameters: 7 | - in: body 8 | name: user 9 | description: The user to create. 10 | schema: 11 | required: 12 | - username 13 | - password 14 | $ref: '#/definitions/User' 15 | responses: 16 | '200': 17 | description: OK 18 | content: 19 | application/json: 20 | schema: 21 | $ref: '#/definitions/User' 22 | '400': 23 | description: Bad permissions 24 | content: 25 | application/json: 26 | schema: 27 | $ref: '#/definitions/Error' -------------------------------------------------------------------------------- /server/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /server/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | script_location = migrations 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /server/migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | import os 5 | from logging.config import fileConfig 6 | 7 | from alembic import context 8 | from sqlalchemy import engine_from_config 9 | from sqlalchemy import pool 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | fileConfig(config.config_file_name) 18 | logger = logging.getLogger('alembic.env') 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | from flask import current_app 25 | 26 | config.set_main_option( 27 | 'sqlalchemy.url', 28 | str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) 29 | target_metadata = current_app.extensions['migrate'].db.metadata 30 | 31 | 32 | # other values from the config, defined by the needs of env.py, 33 | # can be acquired: 34 | # my_important_option = config.get_main_option("my_important_option") 35 | # ... etc. 36 | 37 | 38 | def run_migrations_offline(): 39 | """Run migrations in 'offline' mode. 40 | 41 | This configures the context with just a URL 42 | and not an Engine, though an Engine is acceptable 43 | here as well. By skipping the Engine creation 44 | we don't even need a DBAPI to be available. 45 | 46 | Calls to context.execute() here emit the given string to the 47 | script output. 48 | 49 | """ 50 | url = config.get_main_option("sqlalchemy.url") 51 | context.configure( 52 | url=url, target_metadata=target_metadata, literal_binds=True 53 | ) 54 | 55 | with context.begin_transaction(): 56 | context.run_migrations() 57 | 58 | 59 | def run_migrations_online(): 60 | """Run migrations in 'online' mode. 61 | 62 | In this scenario we need to create an Engine 63 | and associate a connection with the context. 64 | 65 | """ 66 | 67 | # this callback is used to prevent an auto-migration from being generated 68 | # when there are no changes to the schema 69 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 70 | def process_revision_directives(context, revision, directives): 71 | if getattr(config.cmd_opts, 'autogenerate', False): 72 | script = directives[0] 73 | if script.upgrade_ops.is_empty(): 74 | directives[:] = [] 75 | logger.info('No changes in schema detected.') 76 | 77 | connectable = engine_from_config( 78 | config.get_section(config.config_ini_section), 79 | prefix='sqlalchemy.', 80 | poolclass=pool.NullPool, 81 | ) 82 | 83 | with connectable.connect() as connection: 84 | context.configure( 85 | connection=connection, 86 | target_metadata=target_metadata, 87 | process_revision_directives=process_revision_directives, 88 | **current_app.extensions['migrate'].configure_args 89 | ) 90 | 91 | with context.begin_transaction(): 92 | context.run_migrations() 93 | 94 | 95 | if context.is_offline_mode(): 96 | run_migrations_offline() 97 | else: 98 | run_migrations_online() 99 | -------------------------------------------------------------------------------- /server/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import app 11 | ${imports if imports else ""} 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = ${repr(up_revision)} 15 | down_revision = ${repr(down_revision)} 16 | branch_labels = ${repr(branch_labels)} 17 | depends_on = ${repr(depends_on)} 18 | 19 | 20 | def upgrade(): 21 | ${upgrades if upgrades else "pass"} 22 | 23 | 24 | def downgrade(): 25 | ${downgrades if downgrades else "pass"} 26 | -------------------------------------------------------------------------------- /server/migrations/versions/8b4e8a2a43bd_added_oauth2_tables.py: -------------------------------------------------------------------------------- 1 | """Added oauth2 tables 2 | 3 | Revision ID: 8b4e8a2a43bd 4 | Revises: eb7f82a4d3c8 5 | Create Date: 2020-03-20 22:59:36.571492 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | import app 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = '8b4e8a2a43bd' 15 | down_revision = 'eb7f82a4d3c8' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table('oauth2_client', 23 | sa.Column('client_id', sa.String(length=48), nullable=True), 24 | sa.Column('client_secret', sa.String(length=120), nullable=True), 25 | sa.Column('client_id_issued_at', sa.Integer(), nullable=False), 26 | sa.Column('client_secret_expires_at', sa.Integer(), nullable=False), 27 | sa.Column('client_metadata', sa.Text(), nullable=True), 28 | sa.Column('id', app.database.GUID(), nullable=False), 29 | sa.Column('user_id', app.database.GUID(), nullable=False), 30 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='oauth2_client_user_id_fkey'), 31 | sa.PrimaryKeyConstraint('id') 32 | ) 33 | op.create_index(op.f('ix_oauth2_client_client_id'), 'oauth2_client', ['client_id'], unique=False) 34 | op.create_table('oauth2_code', 35 | sa.Column('code', sa.String(length=120), nullable=False), 36 | sa.Column('client_id', sa.String(length=48), nullable=True), 37 | sa.Column('redirect_uri', sa.Text(), nullable=True), 38 | sa.Column('response_type', sa.Text(), nullable=True), 39 | sa.Column('scope', sa.Text(), nullable=True), 40 | sa.Column('nonce', sa.Text(), nullable=True), 41 | sa.Column('auth_time', sa.Integer(), nullable=False), 42 | sa.Column('code_challenge', sa.Text(), nullable=True), 43 | sa.Column('code_challenge_method', sa.String(length=48), nullable=True), 44 | sa.Column('id', app.database.GUID(), nullable=False), 45 | sa.Column('user_id', app.database.GUID(), nullable=False), 46 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='oauth2_code_user_id_fkey'), 47 | sa.PrimaryKeyConstraint('id'), 48 | sa.UniqueConstraint('code', name='oauth2_code_code_unique') 49 | ) 50 | op.create_table('oauth2_token', 51 | sa.Column('client_id', sa.String(length=48), nullable=True), 52 | sa.Column('token_type', sa.String(length=40), nullable=True), 53 | sa.Column('access_token', sa.String(length=255), nullable=False), 54 | sa.Column('refresh_token', sa.String(length=255), nullable=True), 55 | sa.Column('provider', sa.String(length=255), nullable=False), 56 | sa.Column('scope', sa.Text(), nullable=True), 57 | sa.Column('revoked', sa.Boolean(), nullable=True), 58 | sa.Column('issued_at', sa.Integer(), nullable=False), 59 | sa.Column('expires_in', sa.Integer(), nullable=False), 60 | sa.Column('id', app.database.GUID(), nullable=False), 61 | sa.Column('user_id', app.database.GUID(), nullable=False), 62 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='oauth2_token_user_id_fkey'), 63 | sa.PrimaryKeyConstraint('id'), 64 | sa.UniqueConstraint('access_token', name='oauth2_code_access_token_unique') 65 | ) 66 | op.create_index(op.f('ix_oauth2_token_refresh_token'), 'oauth2_token', ['refresh_token'], unique=False) 67 | op.add_column('users', sa.Column('email', sa.String(length=255), nullable=True)) 68 | op.add_column('users', sa.Column('password_hash', sa.String(length=128), nullable=True)) 69 | op.add_column('users', sa.Column('username', sa.String(length=255), nullable=True)) 70 | op.create_unique_constraint(table_name='users', constraint_name='users_email_unique', columns=['email']) 71 | # ### end Alembic commands ### 72 | 73 | 74 | def downgrade(): 75 | # ### commands auto generated by Alembic - please adjust! ### 76 | op.drop_constraint(table_name='users', constraint_name='users_email_unique', type_='unique') 77 | op.drop_column('users', 'username') 78 | op.drop_column('users', 'password_hash') 79 | op.drop_column('users', 'email') 80 | op.drop_index(op.f('ix_oauth2_token_refresh_token'), table_name='oauth2_token') 81 | op.drop_table('oauth2_token') 82 | op.drop_table('oauth2_code') 83 | op.drop_index(op.f('ix_oauth2_client_client_id'), table_name='oauth2_client') 84 | op.drop_table('oauth2_client') 85 | # ### end Alembic commands ### 86 | -------------------------------------------------------------------------------- /server/migrations/versions/eb7f82a4d3c8_first_migration.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: eb7f82a4d3c8 4 | Revises: 5 | Create Date: 2020-03-17 16:52:37.117228 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import app 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = 'eb7f82a4d3c8' 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table('users', 23 | sa.Column('id', app.database.GUID(), nullable=False), 24 | sa.PrimaryKeyConstraint('id') 25 | ) 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade(): 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.drop_table('users') 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /server/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | flask_testing 4 | invoke -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | psycopg2 3 | flask_sqlalchemy 4 | Flask-Migrate 5 | flask_cors 6 | Authlib 7 | requests 8 | pyjwt 9 | argon2-cffi 10 | 11 | # Api docs 12 | apispec 13 | apispec-webframeworks 14 | flasgger 15 | flask-marshmallow 16 | marshmallow 17 | marshmallow-sqlalchemy -------------------------------------------------------------------------------- /server/run.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from app import create_app 4 | 5 | app = create_app() 6 | 7 | if __name__ == "__main__": 8 | app.run("0.0.0.0", port=os.getenv('PORT')) 9 | -------------------------------------------------------------------------------- /server/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from invoke import task, Collection 2 | 3 | from .create import create_collection 4 | from .db import db_collection 5 | 6 | ns = Collection() 7 | ns.add_collection(db_collection) 8 | ns.add_collection(create_collection) 9 | 10 | 11 | @task 12 | def list(ctx, release=False): 13 | """ List all available tasks 14 | """ 15 | ctx.run('invoke --list') 16 | 17 | 18 | ns.add_task(list) 19 | 20 | 21 | @task 22 | def vvv(ctx, release=False): 23 | """ Current service version 24 | """ 25 | from service_ico import PACKAGE_VERSION 26 | print(PACKAGE_VERSION) 27 | 28 | 29 | ns.add_task(vvv) 30 | 31 | 32 | @task 33 | def bump(ctx, bump): 34 | """ `inv bump ` Checkout master, execute a bump, push to origin. 35 | """ 36 | assert bump in ('patch', 'minor', 'major') 37 | 38 | ctx.run('git checkout master') 39 | ctx.run('git pull') 40 | ctx.run('bumpversion {bump}'.format(bump=bump)) 41 | ctx.run('git push') 42 | 43 | 44 | ns.add_task(bump) 45 | -------------------------------------------------------------------------------- /server/tasks/create.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | from app.models import Users, OAuth2Client 5 | from invoke import task, Collection 6 | from werkzeug.security import gen_salt 7 | 8 | from app import create_app 9 | 10 | create_collection = Collection('create') 11 | 12 | 13 | def get_env(test=False): 14 | env = 'test' if test else 'development' 15 | os.environ['FLASK_CONFIGURATION'] = env 16 | return env 17 | 18 | 19 | @task(help={ 20 | 'username': "The username of the user you want to create", 21 | 'email': "The email of the user you want to create", 22 | 'password': "The password of the user you want to create" 23 | }) 24 | def user(ctx, username='test', password='123'): 25 | get_env() 26 | app = create_app() 27 | 28 | with app.app_context(): 29 | user = Users.query.filter_by(email=f'{username}@example.com').first() 30 | if not user: 31 | Users.create(email=f'{username}@example.com', username=username, password=password) 32 | print("User has been created") 33 | 34 | 35 | create_collection.add_task(user) 36 | 37 | 38 | @task(help={ 39 | "email": "The email to which the client will be attached", 40 | "client_name": "Third party client name", 41 | "client_uri": "Third party client uri. Used for validating requests", 42 | "grant_types": "The allowed grant_types.", 43 | "redirect_uri": "Allowed redirect urls after successful call to oauth/authorization", 44 | "response_type": "The allowed response types. " 45 | "Should be one of code, id_token, token or a mix of them for a hybrid flow", 46 | "scope": "What resources the third party is allowed to request.", 47 | "token_endpoint_auth_method": "We'll enforce the third party to provide " 48 | "client_id and client_code as basic auth during oauth/token access token request" 49 | }) 50 | def client(ctx, 51 | email='test@example.com', 52 | client_name="Awesome Client", 53 | client_uri="http://localhost:5001", 54 | grant_types="authorization_code", 55 | redirect_uris="http://localhost:5001", 56 | response_types="code id_token token", 57 | scope="profile username", 58 | token_endpoint_auth_method="client_secret_basic"): 59 | get_env() 60 | app = create_app() 61 | 62 | with app.app_context(): 63 | client_id = gen_salt(24) 64 | user = Users.query.filter_by(email=email).first() 65 | if not user: 66 | print(f"User with email {email} could not be found") 67 | client = OAuth2Client.create(client_id=client_id, user_id=user.id) 68 | # Mixin doesn't set the issue_at date 69 | client.client_id_issued_at = int(time.time()) 70 | if client.token_endpoint_auth_method == 'none': 71 | client.client_secret = '' 72 | else: 73 | client.client_secret = gen_salt(48) 74 | 75 | client_metadata = { 76 | "client_name": client_name, 77 | "client_uri": client_uri, 78 | "grant_types": grant_types.split(), 79 | "redirect_uris": redirect_uris.split(), 80 | "response_types": response_types.split(), 81 | "scope": scope, 82 | "token_endpoint_auth_method": token_endpoint_auth_method 83 | } 84 | client.set_client_metadata(client_metadata) 85 | client.save() 86 | print("Client has been created") 87 | 88 | 89 | create_collection.add_task(client) 90 | 91 | 92 | @task 93 | def test_data(ctx): 94 | user(ctx) 95 | client(ctx) 96 | 97 | 98 | create_collection.add_task(test_data) 99 | -------------------------------------------------------------------------------- /server/tasks/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | from invoke import task, Collection 5 | from sqlalchemy import create_engine 6 | from sqlalchemy.exc import OperationalError 7 | 8 | from app import create_app 9 | 10 | db_collection = Collection('db') 11 | 12 | 13 | def get_env(test=False): 14 | env = 'test' if test else 'development' 15 | os.environ['FLASK_CONFIGURATION'] = env 16 | return env 17 | 18 | 19 | @task 20 | def wait(ctx, max_attempts=3, wait_time=1): 21 | attempts = 0 22 | app = create_app() 23 | with app.app_context(): 24 | while True: 25 | try: 26 | e = create_engine(app.config['SQLALCHEMY_DATABASE_URI']) 27 | e.connect() 28 | break 29 | except OperationalError as e: 30 | if attempts >= max_attempts: 31 | raise e 32 | attempts += 1 33 | time.sleep(wait_time) 34 | print("Attempting to connect to database.") 35 | print("Connection to database established.") 36 | 37 | 38 | db_collection.add_task(wait) 39 | 40 | 41 | @task(help={'message': "Pass a message to the revision"}) 42 | def create(ctx, message, test=False): 43 | """ Invoke alembic to generate new migration from the existing db 44 | and service' models. 45 | """ 46 | get_env(test) 47 | app = create_app() 48 | 49 | with app.app_context(): 50 | import alembic.config 51 | alembic.config.main(argv=[ 52 | '-c', 53 | 'migrations/alembic.ini', 54 | 'revision', 55 | '--autogenerate', 56 | '-m', 57 | message, 58 | ]) 59 | 60 | 61 | db_collection.add_task(create) 62 | 63 | 64 | @task(help={'revision': "Pass the version you want to migrate to"}) 65 | def up(ctx, revision='head', test=False): 66 | """ Invoke alembic to migrate db up to current HEAD. 67 | """ 68 | get_env(test) 69 | app = create_app() 70 | 71 | with app.app_context(): 72 | import alembic.config 73 | alembic.config.main(argv=[ 74 | '-c', 75 | 'migrations/alembic.ini', 76 | 'upgrade', 77 | revision, 78 | ]) 79 | 80 | 81 | db_collection.add_task(up) 82 | 83 | 84 | @task 85 | def down(ctx, revision='-1', test=False): 86 | """ Invoke alembic to migrate db down to base. 87 | """ 88 | 89 | get_env(test) 90 | app = create_app() 91 | 92 | with app.app_context(): 93 | import alembic.config 94 | alembic.config.main(argv=[ 95 | '-c', 96 | 'migrations/alembic.ini', 97 | 'downgrade', 98 | revision 99 | ]) 100 | 101 | 102 | db_collection.add_task(down) 103 | --------------------------------------------------------------------------------