├── 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 | You need to enable JavaScript to run this app.
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 |
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 |
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 |
45 | Signup
46 |
47 |
48 | Login
49 |
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 |
21 |
22 | {name}
23 |
24 |
25 |
26 | {description}
27 |
28 | {
32 | if (isConnected) onDisconnect();
33 | else onConnect();
34 | }}
35 | variantColor={isConnected ? "red" : "blue"}
36 | >
37 | {isConnected ? `Disconnect from ${name}` : `Connect to ${name}`}
38 |
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 | Logout
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 |
8 |
9 |
10 |
11 |
12 |
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 |
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 |
--------------------------------------------------------------------------------