├── README.md
├── .env.template
├── requirements.txt
├── .gitignore
├── LICENSE
├── templates
└── index.html
└── app.py
/README.md:
--------------------------------------------------------------------------------
1 | flask-oauth-example
2 | ===================
3 |
--------------------------------------------------------------------------------
/.env.template:
--------------------------------------------------------------------------------
1 | GITHUB_CLIENT_ID=
2 | GITHUB_CLIENT_SECRET=
3 | GOOGLE_CLIENT_ID=
4 | GOOGLE_CLIENT_SECRET=
5 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | blinker==1.6.2
2 | certifi==2023.5.7
3 | charset-normalizer==3.1.0
4 | click==8.1.3
5 | Flask==2.3.2
6 | Flask-Login==0.6.2
7 | Flask-SQLAlchemy==3.0.3
8 | greenlet==2.0.2
9 | idna==3.4
10 | itsdangerous==2.1.2
11 | Jinja2==3.1.2
12 | MarkupSafe==2.1.2
13 | python-dotenv==1.0.0
14 | requests==2.31.0
15 | SQLAlchemy==2.0.15
16 | typing_extensions==4.6.2
17 | urllib3==2.0.2
18 | Werkzeug==2.3.4
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 |
5 | # C extensions
6 | *.so
7 |
8 | # Distribution / packaging
9 | .Python
10 | env/
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | lib/
17 | lib64/
18 | parts/
19 | sdist/
20 | var/
21 | *.egg-info/
22 | .installed.cfg
23 | *.egg
24 |
25 | # PyInstaller
26 | # Usually these files are written by a python script from a template
27 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
28 | *.manifest
29 | *.spec
30 |
31 | # Installer logs
32 | pip-log.txt
33 | pip-delete-this-directory.txt
34 |
35 | # Unit test / coverage reports
36 | htmlcov/
37 | .tox/
38 | .coverage
39 | .cache
40 | nosetests.xml
41 | coverage.xml
42 |
43 | # Translations
44 | *.mo
45 | *.pot
46 |
47 | # Django stuff:
48 | *.log
49 |
50 | # Sphinx documentation
51 | docs/_build/
52 |
53 | # PyBuilder
54 | target/
55 |
56 | # application files
57 | venv*
58 | *.sqlite
59 | .*
60 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Miguel Grinberg
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Flask + OAuth 2.0 Demo
4 |
5 |
6 |
7 |
12 |
13 | {% with messages = get_flashed_messages() %}
14 | {% if messages %}
15 |
16 | {% for message in messages %}
17 | {{ message }}
18 | {% endfor %}
19 |
20 | {% endif %}
21 | {% endwith %}
22 | {% if current_user.is_authenticated %}
23 |
Hi, {{ current_user.username }}!
24 |
25 | Logout
26 |
27 | {% else %}
28 |
29 | Login with Google
30 | Login with GitHub
31 |
32 | {% endif %}
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | import os
2 | import secrets
3 | from urllib.parse import urlencode
4 |
5 | from dotenv import load_dotenv
6 | from flask import Flask, redirect, url_for, render_template, flash, session, \
7 | current_app, request, abort
8 | from flask_sqlalchemy import SQLAlchemy
9 | from flask_login import LoginManager, UserMixin, login_user, logout_user,\
10 | current_user
11 | import requests
12 |
13 | load_dotenv()
14 |
15 | app = Flask(__name__)
16 | app.config['SECRET_KEY'] = 'top secret!'
17 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite'
18 | app.config['OAUTH2_PROVIDERS'] = {
19 | # Google OAuth 2.0 documentation:
20 | # https://developers.google.com/identity/protocols/oauth2/web-server#httprest
21 | 'google': {
22 | 'client_id': os.environ.get('GOOGLE_CLIENT_ID'),
23 | 'client_secret': os.environ.get('GOOGLE_CLIENT_SECRET'),
24 | 'authorize_url': 'https://accounts.google.com/o/oauth2/auth',
25 | 'token_url': 'https://accounts.google.com/o/oauth2/token',
26 | 'userinfo': {
27 | 'url': 'https://www.googleapis.com/oauth2/v3/userinfo',
28 | 'email': lambda json: json['email'],
29 | },
30 | 'scopes': ['https://www.googleapis.com/auth/userinfo.email'],
31 | },
32 |
33 | # GitHub OAuth 2.0 documentation:
34 | # https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
35 | 'github': {
36 | 'client_id': os.environ.get('GITHUB_CLIENT_ID'),
37 | 'client_secret': os.environ.get('GITHUB_CLIENT_SECRET'),
38 | 'authorize_url': 'https://github.com/login/oauth/authorize',
39 | 'token_url': 'https://github.com/login/oauth/access_token',
40 | 'userinfo': {
41 | 'url': 'https://api.github.com/user/emails',
42 | 'email': lambda json: json[0]['email'],
43 | },
44 | 'scopes': ['user:email'],
45 | },
46 | }
47 |
48 | db = SQLAlchemy(app)
49 | login = LoginManager(app)
50 | login.login_view = 'index'
51 |
52 |
53 | class User(UserMixin, db.Model):
54 | __tablename__ = 'users'
55 | id = db.Column(db.Integer, primary_key=True)
56 | username = db.Column(db.String(64), nullable=False)
57 | email = db.Column(db.String(64), nullable=True)
58 |
59 |
60 | @login.user_loader
61 | def load_user(id):
62 | return db.session.get(User, int(id))
63 |
64 |
65 | @app.route('/')
66 | def index():
67 | return render_template('index.html')
68 |
69 |
70 | @app.route('/logout')
71 | def logout():
72 | logout_user()
73 | flash('You have been logged out.')
74 | return redirect(url_for('index'))
75 |
76 |
77 | @app.route('/authorize/')
78 | def oauth2_authorize(provider):
79 | if not current_user.is_anonymous:
80 | return redirect(url_for('index'))
81 |
82 | provider_data = current_app.config['OAUTH2_PROVIDERS'].get(provider)
83 | if provider_data is None:
84 | abort(404)
85 |
86 | # generate a random string for the state parameter
87 | session['oauth2_state'] = secrets.token_urlsafe(16)
88 |
89 | # create a query string with all the OAuth2 parameters
90 | qs = urlencode({
91 | 'client_id': provider_data['client_id'],
92 | 'redirect_uri': url_for('oauth2_callback', provider=provider,
93 | _external=True),
94 | 'response_type': 'code',
95 | 'scope': ' '.join(provider_data['scopes']),
96 | 'state': session['oauth2_state'],
97 | })
98 |
99 | # redirect the user to the OAuth2 provider authorization URL
100 | return redirect(provider_data['authorize_url'] + '?' + qs)
101 |
102 |
103 | @app.route('/callback/')
104 | def oauth2_callback(provider):
105 | if not current_user.is_anonymous:
106 | return redirect(url_for('index'))
107 |
108 | provider_data = current_app.config['OAUTH2_PROVIDERS'].get(provider)
109 | if provider_data is None:
110 | abort(404)
111 |
112 | # if there was an authentication error, flash the error messages and exit
113 | if 'error' in request.args:
114 | for k, v in request.args.items():
115 | if k.startswith('error'):
116 | flash(f'{k}: {v}')
117 | return redirect(url_for('index'))
118 |
119 | # make sure that the state parameter matches the one we created in the
120 | # authorization request
121 | if request.args['state'] != session.get('oauth2_state'):
122 | abort(401)
123 |
124 | # make sure that the authorization code is present
125 | if 'code' not in request.args:
126 | abort(401)
127 |
128 | # exchange the authorization code for an access token
129 | response = requests.post(provider_data['token_url'], data={
130 | 'client_id': provider_data['client_id'],
131 | 'client_secret': provider_data['client_secret'],
132 | 'code': request.args['code'],
133 | 'grant_type': 'authorization_code',
134 | 'redirect_uri': url_for('oauth2_callback', provider=provider,
135 | _external=True),
136 | }, headers={'Accept': 'application/json'})
137 | if response.status_code != 200:
138 | abort(401)
139 | oauth2_token = response.json().get('access_token')
140 | if not oauth2_token:
141 | abort(401)
142 |
143 | # use the access token to get the user's email address
144 | response = requests.get(provider_data['userinfo']['url'], headers={
145 | 'Authorization': 'Bearer ' + oauth2_token,
146 | 'Accept': 'application/json',
147 | })
148 | if response.status_code != 200:
149 | abort(401)
150 | email = provider_data['userinfo']['email'](response.json())
151 |
152 | # find or create the user in the database
153 | user = db.session.scalar(db.select(User).where(User.email == email))
154 | if user is None:
155 | user = User(email=email, username=email.split('@')[0])
156 | db.session.add(user)
157 | db.session.commit()
158 |
159 | # log the user in
160 | login_user(user)
161 | return redirect(url_for('index'))
162 |
163 |
164 | with app.app_context():
165 | db.create_all()
166 |
167 | if __name__ == '__main__':
168 | app.run(debug=True)
169 |
--------------------------------------------------------------------------------