From 84ccfd720edfe609272b92d5da8ce860649f56a1 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 5 Jul 2020 17:29:15 +0300 Subject: [PATCH] initial commit --- .gitignore | 13 +++ .idea/hw_oauth.iml | 10 ++ .idea/inspectionProfiles/Project_Default.xml | 12 +++ .../inspectionProfiles/profiles_settings.xml | 6 ++ .idea/modules.xml | 8 ++ app.py | 73 +++++++++++++ config.py | 24 +++++ requirements.txt | 8 ++ website/__init__.py | 32 ++++++ website/forms.py | 15 +++ website/models.py | 68 ++++++++++++ website/oauth2.py | 102 ++++++++++++++++++ website/routes.py | 85 +++++++++++++++ website/templates/authorize.html | 34 ++++++ website/templates/home.html | 32 ++++++ website/templates/login.html | 43 ++++++++ 16 files changed, 565 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/hw_oauth.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/modules.xml create mode 100644 app.py create mode 100644 config.py create mode 100644 requirements.txt create mode 100644 website/__init__.py create mode 100644 website/forms.py create mode 100644 website/models.py create mode 100644 website/oauth2.py create mode 100644 website/routes.py create mode 100644 website/templates/authorize.html create mode 100644 website/templates/home.html create mode 100644 website/templates/login.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bba54d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ + +# ide +.vscode/ + +# virtualenv +venv/ + +# cache +*.pyc +__pycache__/ + +# db +*.sqlite diff --git a/.idea/hw_oauth.iml b/.idea/hw_oauth.iml new file mode 100644 index 0000000..3efea49 --- /dev/null +++ b/.idea/hw_oauth.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..ad9c1f8 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..de932ec --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..85a0098 --- /dev/null +++ b/app.py @@ -0,0 +1,73 @@ +import time + +from website import create_app +from flask_script import Manager, Shell +from flask_migrate import MigrateCommand +from werkzeug.security import gen_salt +import os + +app = create_app(os.getenv('FLASK_ENV') or 'config.DevelopementConfig') + +manager = Manager(app) + +manager.add_command('db', MigrateCommand) + + +@manager.command +def init_db(): + from website.models import db + db.create_all() + print("database init") + + +@manager.command +def test_data(): + from website.models import db, User, OAuth2Client + print("add test data") + admin = User() + admin.name = "Admin" + admin.username = "admin" + admin.email = "test@example.com" + admin.set_password("admin") + db.session.add(admin) + db.session.commit() + print('added user: {}'.format(admin)) + + client_id = gen_salt(24) + client_id_issued_at = int(time.time()) + client = OAuth2Client( + client_id=client_id, + client_id_issued_at=client_id_issued_at, + user_id=admin.get_id(), + ) + client_metadata = { + "client_name": "Test client", + "client_uri": "data:text/html;charset=utf-8,client-uri", + "grant_types": ['authorization_code', 'refresh_token'], + "redirect_uris": "data:text/html;charset=utf-8,redirect-uri", + "response_types": ['code', 'token'], + "scope": 'profile', + "token_endpoint_auth_method": 'client_secret_basic' + } + client.set_client_metadata(client_metadata) + client.client_secret = gen_salt(48) + db.session.add(client) + db.session.commit() + print('added client: id:{}, secret:{}'.format(client_id, client.client_secret)) + print('test url: http://127.0.0.1:5000/oauth/authorize?scope=profile&response_type=code&client_id={}'.format(client_id)) + + + +if __name__ == '__main__': + manager.run() + +# http://127.0.0.1:5000/oauth/authorize?scope=profile&response_type=code&client_id=rrC8VDll9RRbBjNLhY4T9jDO + +# > curl -u rrC8VDll9RRbBjNLhY4T9jDO:CxKyWG8Z972hkLQq7hMblH7BsP4JWtQn7qahqzW15h5ZUUJI -XPOST "http://127.0.0.1:5000/oauth/token" -F client_id=rrC8VDll9RRbBjNLhY4T9jDO -F client_secret=CxKyWG8Z972hkLQq7hMblH7BsP4JWtQn7qahqzW15h5ZUUJI -F grant_type=authorization_code -F code=wur5zaxmDfxK9Qsvvebqz1N0XKa7zewSrNYJJAYHlZGfsOJa +# < {"access_token": "LBdvuoKeiY9tHJKMoLdvrG8Zfqs4FbPOz1ze0Ahv96", "expires_in": 864000, "scope": "profile", "token_type": "Bearer"} + +# > curl -H "Authorization: Bearer LBdvuoKeiY9tHJKMoLdvrG8Zfqs4FbPOz1ze0Ahv96" "http://127.0.0.1:5000/api/me" +# { +# "id": 1, +# "username": "admin" +# } diff --git a/config.py b/config.py new file mode 100644 index 0000000..08ad30e --- /dev/null +++ b/config.py @@ -0,0 +1,24 @@ +import os + +app_dir = os.path.abspath(os.path.dirname(__file__)) + + +class BaseConfig: + SECRET_KEY = os.environ.get('SECRET_KEY') or '0d6e368e-bd0c-11ea-921d-9342d47f60ca' + SQLALCHEMY_TRACK_MODIFICATIONS = False + + +class DevelopementConfig(BaseConfig): + DEBUG = True + SQLALCHEMY_DATABASE_URI = 'sqlite:///db.sqlite' + AUTHLIB_INSECURE_TRANSPORT = True + + +class TestingConfig(BaseConfig): + DEBUG = True + SQLALCHEMY_DATABASE_URI = 'sqlite:///db.sqlite' + + +class ProductionConfig(BaseConfig): + DEBUG = False + SQLALCHEMY_DATABASE_URI = 'sqlite:///db.sqlite' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3fa87c8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +Flask==1.1.2 +Flask-WTF==0.14.3 +Flask-Script==2.0.6 +Flask_SQLAlchemy==2.4.3 +SQLAlchemy==1.3.18 +flask-migrate==2.5.3 +flask_login==0.5.0 +Authlib==0.14.3 diff --git a/website/__init__.py b/website/__init__.py new file mode 100644 index 0000000..3401d06 --- /dev/null +++ b/website/__init__.py @@ -0,0 +1,32 @@ +from flask import Flask +from flask_migrate import Migrate +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy +from .routes import bp +from .models import db, User +from .oauth2 import config_oauth +import config + +migrate = Migrate() +login_manager = LoginManager() +login_manager.login_view = 'home.login' + + +def create_app(cfg): + app = Flask(__name__) + app.config.from_object(cfg) + setup_app(app) + return app + + +def setup_app(app): + db.init_app(app) + config_oauth(app) + migrate.init_app(app, db) + login_manager.init_app(app) + app.register_blueprint(bp, url_prefix='') + + +@login_manager.user_loader +def load_user(user_id): + return db.session.query(User).get(user_id) diff --git a/website/forms.py b/website/forms.py new file mode 100644 index 0000000..eed32d4 --- /dev/null +++ b/website/forms.py @@ -0,0 +1,15 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, BooleanField, PasswordField +from wtforms.validators import DataRequired + + +class LoginForm(FlaskForm): + username = StringField("Username", validators=[DataRequired()]) + password = PasswordField("Password", validators=[DataRequired()]) + remember = BooleanField("Remember Me") + submit = SubmitField() + + +class ConfirmAccessForm(FlaskForm): + confirm = BooleanField("Confirm") + submit = SubmitField() diff --git a/website/models.py b/website/models.py new file mode 100644 index 0000000..aea5356 --- /dev/null +++ b/website/models.py @@ -0,0 +1,68 @@ +import time +from werkzeug.security import generate_password_hash, check_password_hash +from flask_sqlalchemy import SQLAlchemy +from authlib.integrations.sqla_oauth2 import ( + OAuth2ClientMixin, + OAuth2AuthorizationCodeMixin, + OAuth2TokenMixin, +) +from flask_login import UserMixin + +db = SQLAlchemy() + + +class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(40), unique=True) + name = db.Column(db.String(100)) + email = db.Column(db.String(100), nullable=False, unique=True) + password_hash = db.Column(db.String(100), nullable=False) + clients = db.relationship('OAuth2Client') + auth_codes = db.relationship('OAuth2AuthorizationCode') + tokens = db.relationship('OAuth2Token') + + def __repr__(self): + return "<{}:{}>".format(self.id, self.username) + + def get_user_id(self): + return self.id + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + +class OAuth2Client(db.Model, OAuth2ClientMixin): + __tablename__ = 'oauth2_client' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, db.ForeignKey('user.id', ondelete='CASCADE')) + user = db.relationship('User') + + +class OAuth2AuthorizationCode(db.Model, OAuth2AuthorizationCodeMixin): + __tablename__ = 'oauth2_code' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, db.ForeignKey('user.id', ondelete='CASCADE')) + user = db.relationship('User') + + +class OAuth2Token(db.Model, OAuth2TokenMixin): + __tablename__ = 'oauth2_token' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, db.ForeignKey('user.id', ondelete='CASCADE')) + user = db.relationship('User') + + def is_refresh_token_active(self): + if self.revoked: + return False + expires_at = self.issued_at + self.expires_in * 2 + return expires_at >= time.time() + diff --git a/website/oauth2.py b/website/oauth2.py new file mode 100644 index 0000000..b8bdca8 --- /dev/null +++ b/website/oauth2.py @@ -0,0 +1,102 @@ +from authlib.integrations.flask_oauth2 import ( + AuthorizationServer, + ResourceProtector, +) +from authlib.integrations.sqla_oauth2 import ( + create_query_client_func, + create_save_token_func, + create_revocation_endpoint, + create_bearer_token_validator, +) +from authlib.oauth2.rfc6749 import grants +from authlib.oauth2.rfc7636 import CodeChallenge +from werkzeug.security import gen_salt +from .models import db, User +from .models import OAuth2Client, OAuth2AuthorizationCode, OAuth2Token + + +class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): + TOKEN_ENDPOINT_AUTH_METHODS = [ + 'client_secret_basic', + 'client_secret_post', + 'none', + ] + + def save_authorization_code(self, code, request): + code_challenge = request.data.get('code_challenge') + code_challenge_method = request.data.get('code_challenge_method') + auth_code = OAuth2AuthorizationCode( + code=code, + client_id=request.client.client_id, + redirect_uri=request.redirect_uri, + scope=request.scope, + user_id=request.user.id, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ) + db.session.add(auth_code) + db.session.commit() + return auth_code + + def query_authorization_code(self, code, client): + auth_code = OAuth2AuthorizationCode.query.filter_by( + code=code, client_id=client.client_id).first() + if auth_code and not auth_code.is_expired(): + return auth_code + + def delete_authorization_code(self, authorization_code): + db.session.delete(authorization_code) + db.session.commit() + + def authenticate_user(self, authorization_code): + return User.query.get(authorization_code.user_id) + + +class PasswordGrant(grants.ResourceOwnerPasswordCredentialsGrant): + def authenticate_user(self, username, password): + user = User.query.filter_by(username=username).first() + if user is not None and user.check_password(password): + return user + + +class RefreshTokenGrant(grants.RefreshTokenGrant): + def authenticate_refresh_token(self, refresh_token): + token = OAuth2Token.query.filter_by(refresh_token=refresh_token).first() + if token and token.is_refresh_token_active(): + return token + + def authenticate_user(self, credential): + return User.query.get(credential.user_id) + + def revoke_old_credential(self, credential): + credential.revoked = True + db.session.add(credential) + db.session.commit() + + +query_client = create_query_client_func(db.session, OAuth2Client) +save_token = create_save_token_func(db.session, OAuth2Token) +authorization = AuthorizationServer( + query_client=query_client, + save_token=save_token, +) +require_oauth = ResourceProtector() + + +def config_oauth(app): + authorization.init_app(app) + + # support all grants + authorization.register_grant(grants.ImplicitGrant) + authorization.register_grant(grants.ClientCredentialsGrant) + authorization.register_grant(AuthorizationCodeGrant, [CodeChallenge(required=True)]) + authorization.register_grant(PasswordGrant) + authorization.register_grant(RefreshTokenGrant) + + # support revocation + revocation_cls = create_revocation_endpoint(db.session, OAuth2Token) + authorization.register_endpoint(revocation_cls) + + # protect resource + bearer_cls = create_bearer_token_validator(db.session, OAuth2Token) + require_oauth.register_token_validator(bearer_cls()) diff --git a/website/routes.py b/website/routes.py new file mode 100644 index 0000000..fd5c87b --- /dev/null +++ b/website/routes.py @@ -0,0 +1,85 @@ +from flask import Blueprint, Flask, request, render_template, redirect, url_for, flash, jsonify, make_response, session +from flask_login import login_required, login_user, current_user, logout_user +from authlib.integrations.flask_oauth2 import current_token +from authlib.oauth2 import OAuth2Error +from .models import User, OAuth2Client, db +from .forms import LoginForm, ConfirmAccessForm +from .oauth2 import authorization, require_oauth +from werkzeug.exceptions import abort + +bp = Blueprint('home', __name__) + + +@bp.route('/') +def home(): + user = None + if current_user.is_authenticated: + clients = current_user.clients + user = current_user.name + else: + clients = [] + + return render_template('home.html', user=user, clients=clients) + + +@bp.route('/login/', methods=['post', 'get']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('.home')) + form = LoginForm() + if form.validate_on_submit(): + user = db.session.query(User).filter(User.username == form.username.data).first() + if user and user.check_password(form.password.data): + login_user(user, remember=form.remember.data) + nextpage = request.args.get('next', url_for('.home')) + return redirect(nextpage) + else: + flash("Invalid username/password", 'error') + return render_template('login.html', form=form) + + +@bp.route('/logout/') +@login_required +def logout(): + logout_user() + flash("You have been logged out.") + return redirect(url_for('.home')) + + +@bp.route('/oauth/token', methods=['POST']) +def issue_token(): + return authorization.create_token_response() + + +@bp.route('/oauth/revoke', methods=['POST']) +def revoke_token(): + return authorization.create_endpoint_response('revocation') + + +@bp.route('/oauth/authorize', methods=['GET', 'POST']) +@login_required +def authorize(): + user = current_user + grant_user = None + form = ConfirmAccessForm() + + if request.method == 'GET': + try: + grant = authorization.validate_consent_request(end_user=user) + except OAuth2Error as error: + return error.error + return render_template('authorize.html', user=user, grant=grant, form=form) + + if form.validate_on_submit(): + if form.confirm.data: + grant_user = user + + return authorization.create_authorization_response(grant_user=grant_user) + + +@bp.route('/api/me') +@require_oauth('profile') +def api_me(): + user = current_token.user + return jsonify(id=user.id, username=user.username) + diff --git a/website/templates/authorize.html b/website/templates/authorize.html new file mode 100644 index 0000000..e1fd431 --- /dev/null +++ b/website/templates/authorize.html @@ -0,0 +1,34 @@ + + + + + Title + + + +{% for category, message in get_flashed_messages(with_categories=true) %} + {{ message }} +{% endfor %}
+ +
Logged in as {{user.name}} (Log Out)

+ +

The application {{grant.client.client_name}} is requesting: +{{ grant.request.scope }} +

+ +

+ from You - a.k.a. {{ user.username }} +

+ +
+ {{ form.csrf_token }} +

+ {{ form.confirm.label() }} + {{ form.confirm() }} +

+

+ {{ form.submit() }} +

+
+ + \ No newline at end of file diff --git a/website/templates/home.html b/website/templates/home.html new file mode 100644 index 0000000..04ac14d --- /dev/null +++ b/website/templates/home.html @@ -0,0 +1,32 @@ + + + + + Title + + + +{% for category, message in get_flashed_messages(with_categories=true) %} + {{ message }} + {% endfor %}
+ +{% if user %} + +
Logged in as {{user.name}} (Log Out)
+ +

Clients:

+{% for client in clients %} +
+{{ client.client_info|tojson }}
+{{ client.client_metadata|tojson }}
+
+
+{% endfor %} +
+ +{% else %} +

Please, Login

+{% endif %} + + + \ No newline at end of file diff --git a/website/templates/login.html b/website/templates/login.html new file mode 100644 index 0000000..c8da230 --- /dev/null +++ b/website/templates/login.html @@ -0,0 +1,43 @@ + + + + + Login + + + + {% for category, message in get_flashed_messages(with_categories=true) %} + {{ message }} + {% endfor %} + +
+ {{ form.csrf_token }} +

+ {{ form.username.label() }} + {{ form.username() }} + {% if form.username.errors %} + {% for error in form.username.errors %} + {{ error }} + {% endfor %} + {% endif %} +

+

+ {{ form.password.label() }} + {{ form.password() }} + {% if form.password.errors %} + {% for error in form.password.errors %} + {{ error }} + {% endfor %} + {% endif %} +

+

+ {{ form.remember.label() }} + {{ form.remember() }} +

+

+ {{ form.submit() }} +

+
+ + + \ No newline at end of file