From 44f825529602e93e64a7a34f13c953f06da50182 Mon Sep 17 00:00:00 2001 From: Prabhat Singh <59395410+TestPrab@users.noreply.github.com> Date: Wed, 4 Aug 2021 07:44:24 +0530 Subject: [PATCH] Added BCF Server (#1626) --- .gitignore | 35 +++ src/bcf/bcf/v3/bcfapi.py | 8 +- src/bcfserver/README.md | 35 +++ src/bcfserver/app.py | 4 + src/bcfserver/requirements.txt | 25 ++ src/bcfserver/website/__init__.py | 17 + src/bcfserver/website/app.py | 0 src/bcfserver/website/forms.py | 54 ++++ src/bcfserver/website/models.py | 69 +++++ src/bcfserver/website/oauth2.py | 102 ++++++ src/bcfserver/website/routes.py | 293 ++++++++++++++++++ src/bcfserver/website/templates/base.html | 107 +++++++ .../website/templates/clientdata.html | 17 + .../website/templates/createclient.html | 28 ++ src/bcfserver/website/templates/index.html | 3 + src/bcfserver/website/templates/login.html | 27 ++ src/bcfserver/website/templates/oauth.html | 1 + src/bcfserver/website/templates/ologin.html | 28 ++ src/bcfserver/website/templates/register.html | 28 ++ 19 files changed, 877 insertions(+), 4 deletions(-) create mode 100644 src/bcfserver/README.md create mode 100644 src/bcfserver/app.py create mode 100644 src/bcfserver/requirements.txt create mode 100644 src/bcfserver/website/__init__.py create mode 100644 src/bcfserver/website/app.py create mode 100644 src/bcfserver/website/forms.py create mode 100644 src/bcfserver/website/models.py create mode 100644 src/bcfserver/website/oauth2.py create mode 100644 src/bcfserver/website/routes.py create mode 100644 src/bcfserver/website/templates/base.html create mode 100644 src/bcfserver/website/templates/clientdata.html create mode 100644 src/bcfserver/website/templates/createclient.html create mode 100644 src/bcfserver/website/templates/index.html create mode 100644 src/bcfserver/website/templates/login.html create mode 100644 src/bcfserver/website/templates/oauth.html create mode 100644 src/bcfserver/website/templates/ologin.html create mode 100644 src/bcfserver/website/templates/register.html diff --git a/.gitignore b/.gitignore index b9af203030..e7e24bc94a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,39 @@ Pipfile.lock # Vim *.swp +### Flask ### +instance/* +!instance/.gitignore +.webassets-cache +.env +*.db + +### Flask.Python Stack ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + + +### Database ### +*.accdb +*.db +*.dbf +*.mdb +*.pdb +*.sqlite3 + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +Pipfile +Pipfile.lock \ No newline at end of file diff --git a/src/bcf/bcf/v3/bcfapi.py b/src/bcf/bcf/v3/bcfapi.py index f6b12951ad..700e7deceb 100644 --- a/src/bcf/bcf/v3/bcfapi.py +++ b/src/bcf/bcf/v3/bcfapi.py @@ -76,7 +76,7 @@ class Client: def delete(self, endpoint, params=None): headers = {"Authorization": "Bearer " + self.get_access_token()} resp = requests.put( - f"{self.baseurl}{endpoint}", + f"{self.api_baseurl}{endpoint}", headers=headers, params=params or None, ) @@ -92,11 +92,11 @@ class Client: return self.access_token def get_auth_methods(self): - resp = requests.get(f"{self.baseurl}opencde/1.0/auth") + resp = requests.get(f"{self.baseurl}foundation/1.0/auth") return resp.json()["supported_oauth2_flows"] def get_versions(self): - resp = requests.get(f"{self.baseurl}opencde/versions") + resp = requests.get(f"{self.baseurl}foundation/versions") resp_values = resp.json()["versions"] for version in resp_values: if "api_base_url" in version: @@ -108,7 +108,7 @@ class Client: self.api_baseurl = self.version_ids[self.version] def login(self): - resp = requests.get(f"{self.baseurl}opencde/1.0/auth") + resp = requests.get(f"{self.baseurl}foundation/1.0/auth") values = resp.json() self.auth_endpoint = values["oauth2_auth_url"] self.token_endpoint = values["oauth2_token_url"] diff --git a/src/bcfserver/README.md b/src/bcfserver/README.md new file mode 100644 index 0000000000..52919237cb --- /dev/null +++ b/src/bcfserver/README.md @@ -0,0 +1,35 @@ +# Server-Test + +## Set up the server by installing the dependencies + +### run `pip install -r requirements.txt` to install the dependencies + +#### setup the database by running `db.create_all()` in python shell by importing db from website + +### run `set FLASK_APP=app.py` to set the environment variable + +### run `flask run` to start the server + +### Go to [http://localhost:5000](http://localhost:5000) to see the server + +# Register the user + +#### Go to [http://localhost:5000/register](http://localhost:5000/register) to register the user + +# Create the client + +#### For `grant type` enter `authorization_code` + +#### For `response_type` enter `code secret` + +### Enter the scope and create the client + +### You will be redirected to the page with the details of your client id and secret + +### Use the bcf/v3/api.py to get the access token + +#### For authentication endpoint use `http://localhost:5000/oauth/authorize` + +### For token endpoint use `http://localhost:5000/oauth/token` + +### Base URL will be `http://localhost:5000/` diff --git a/src/bcfserver/app.py b/src/bcfserver/app.py new file mode 100644 index 0000000000..1af101b3ae --- /dev/null +++ b/src/bcfserver/app.py @@ -0,0 +1,4 @@ +from website import app + +if __name__ == "__main__": + app.run(debug=True) diff --git a/src/bcfserver/requirements.txt b/src/bcfserver/requirements.txt new file mode 100644 index 0000000000..bc8e9910e0 --- /dev/null +++ b/src/bcfserver/requirements.txt @@ -0,0 +1,25 @@ +appdirs +bcrypt +black +cffi +click +colorama +dnspython +email-validator +Flask +Flask-Login +Flask-WTF1 +greenlet +idnatsdangerous +Jinja +MarkupSafe +mypy-extensions +pathspec +pycparserregex +six +SQLAlchemy +tomli +Werkzeug +WTForms +authlib +Flask-RESTful \ No newline at end of file diff --git a/src/bcfserver/website/__init__.py b/src/bcfserver/website/__init__.py new file mode 100644 index 0000000000..22e14092e7 --- /dev/null +++ b/src/bcfserver/website/__init__.py @@ -0,0 +1,17 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from flask_bcrypt import Bcrypt + +app = Flask(__name__) +db = SQLAlchemy(app) +login_manager = LoginManager(app) +bcrypt = Bcrypt(app) +app.config["SECRET_KEY"] = "f613729206685405cde0e388" +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///sqlite.db" +login_manager.login_view = "login_page" +login_manager.login_message_category = "info" + + +from website import models, oauth2, routes + diff --git a/src/bcfserver/website/app.py b/src/bcfserver/website/app.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bcfserver/website/forms.py b/src/bcfserver/website/forms.py new file mode 100644 index 0000000000..a7e94e0dfa --- /dev/null +++ b/src/bcfserver/website/forms.py @@ -0,0 +1,54 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo +from wtforms import ValidationError +from website.models import User + + +class RegisterForm(FlaskForm): + def validate_username(self, username_to_check): + user = User.query.filter_by(username=username_to_check.data).first() + if user: + raise ValidationError( + "Username already exists! Please try a different username" + ) + + def validate_email_address(self, email_address_to_check): + email_address = User.query.filter_by( + email_address=email_address_to_check.data + ).first() + if email_address: + raise ValidationError("Email Address already exists!") + + username = StringField( + label="User Name:", validators=[Length(min=2, max=30), DataRequired()] + ) + email_address = StringField( + label="Email Address:", validators=[Email(), DataRequired()] + ) + password1 = PasswordField( + label="Password:", validators=[Length(min=6), DataRequired()] + ) + password2 = PasswordField( + label="Confirm Password:", validators=[EqualTo("password1"), DataRequired()] + ) + submit = SubmitField(label="Create Account") + + +class LoginForm(FlaskForm): + username = StringField(label="User Name:", validators=[DataRequired()]) + password = PasswordField(label="Password:", validators=[DataRequired()]) + submit = SubmitField(label="Sign in") + + +class OauthForm(FlaskForm): + client_name = StringField(label="Client Name:", validators=[DataRequired()]) + # client_uri = StringField(label="Client URI:", validators=[DataRequired()]) + grant_types = StringField(label="Grant Types:", validators=[DataRequired()]) + # redirect_uris = StringField(label="Redirect URIs:", validators=[DataRequired()]) + response_types = StringField(label="Response Types:", validators=[DataRequired()]) + scope = StringField(label="Scope:", validators=[DataRequired()]) + # token_endpoint_auth_method = StringField( + # label="Token Endpoint Auth Method:", validators=[DataRequired()] + # ) + submit = SubmitField(label="Create OAuth") diff --git a/src/bcfserver/website/models.py b/src/bcfserver/website/models.py new file mode 100644 index 0000000000..f2845c4d14 --- /dev/null +++ b/src/bcfserver/website/models.py @@ -0,0 +1,69 @@ +from website import db, bcrypt, login_manager +import time +from authlib.integrations.sqla_oauth2 import ( + OAuth2ClientMixin, + OAuth2AuthorizationCodeMixin, + OAuth2TokenMixin, +) +from flask_login import UserMixin + + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + + +class User(db.Model, UserMixin): + id = db.Column(db.Integer(), primary_key=True) + username = db.Column(db.String(50), unique=True, nullable=False) + password_hash = db.Column(db.String(80), nullable=False) + email_address = db.Column(db.String(50), unique=True, nullable=False) + + def __str__(self): + return f"{self.username} {self.email_address}" + + def get_user_id(self): + return self.id + + @property + def password(self): + return self.password + + @password.setter + def password(self, plain_text_password): + self.password_hash = bcrypt.generate_password_hash(plain_text_password).decode( + "utf-8" + ) + + def check_password(self, attempted_password): + return bcrypt.check_password_hash(self.password_hash, attempted_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", lazy=True) + + +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", lazy=True) + + +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", lazy=True) + + 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/src/bcfserver/website/oauth2.py b/src/bcfserver/website/oauth2.py new file mode 100644 index 0000000000..b0d9e24b9f --- /dev/null +++ b/src/bcfserver/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 .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/src/bcfserver/website/routes.py b/src/bcfserver/website/routes.py new file mode 100644 index 0000000000..1ebe79ca56 --- /dev/null +++ b/src/bcfserver/website/routes.py @@ -0,0 +1,293 @@ +from os import supports_bytes_environ +from website import app +from flask import render_template, redirect, url_for, flash, request, jsonify +from website.models import User +from website.forms import RegisterForm, LoginForm, OauthForm +from website import db +from flask_login import login_user, logout_user, login_required, current_user +from .models import db, User, OAuth2Client, OAuth2AuthorizationCode, OAuth2Token +import base64 +from werkzeug.security import gen_salt +import time +import urllib +import json + + +def split_by_crlf(s): + return [v for v in s.splitlines() if v] + + +@app.route("/") +def homepage(): + return render_template("index.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register_page(): + form = RegisterForm() + if form.validate_on_submit(): + user_to_create = User( + username=form.username.data, + email_address=form.email_address.data, + password=form.password1.data, + ) + + db.session.add(user_to_create) + db.session.commit() + login_user(user_to_create) + flash( + f"Account created successfully! {user_to_create.username}", + category="success", + ) + return redirect(url_for("homepage")) + if form.errors != {}: + for err_msg in form.errors.values(): + flash( + f"There was an error with creating a user: {err_msg}", category="danger" + ) + + return render_template("register.html", form=form) + + +@app.route("/createclient", methods=["GET", "POST"]) +@login_required +def create_client(): + grants = [ + "AuthorizationCodeGrant", + "ImplicitGrant", + "ResourceOwnerPasswordCredentialsGrant", + "ClientCredentialsGrant", + "RefreshTokenGrant", + ] + form = OauthForm() + if form.validate_on_submit(): + 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=current_user.id, + ) + client_metadata = { + "client_name": form.client_name.data, + "grant_types": split_by_crlf(form.grant_types.data), + "response_types": split_by_crlf(form.response_types.data), + "scope": form.scope.data, + "token_endpoint_auth_method": "client_secret_basic", + } + client.client_secret = gen_salt(48) + + client.set_client_metadata(client_metadata) + db.session.add(client) + db.session.commit() + flash( + "Oauth Client Created Successfully", + category="info", + ) + clients = OAuth2Client.query.filter_by(user_id=current_user.id).all() + for client in clients: + print(client.client_info) + print(client.client_metadata) + return render_template("clientdata.html", user=current_user.id, clients=clients) + return render_template("createclient.html", form=form, grants=grants) + + +@app.route("/login", methods=["GET", "POST"]) +def login_page(): + form = LoginForm() + if form.validate_on_submit(): + attempted_user = User.query.filter_by(username=form.username.data).first() + if attempted_user and attempted_user.check_password( + attempted_password=form.password.data + ): + login_user(attempted_user) + return redirect(url_for("homepage")) + else: + flash( + "Invalid Credentials", + category="danger", + ) + + return render_template("login.html", form=form) + + +@app.route("/logout") +def logoutpage(): + logout_user() + flash("You have been logged out!", category="info") + return redirect(url_for("homepage")) + + +@app.route("/foundation/1.0/auth") +def foundation_auth(): + data = { + "oauth2_auth_url": "http://127.0.0.1:5000/oauth/authorize", + "oauth2_token_url": "http://127.0.0.1:5000/oauth/token", + "supported_oauth2_flows": ["authorization_code"], + } + response = app.response_class( + response=json.dumps(data), status=200, mimetype="application/json" + ) + return response + + +@app.route("/foundation/versions") +def foundation_versions(): + Body = { + "versions": [ + { + "api_id": "opencde-foundation", + "version_id": "1.0", + "detailed_version": "https://github.com/BuildingSMART/opencde-foundation-API/tree/v1.0", + }, + # { + # "api_id": "bcf", + # "version_id": "2.1", + # "detailed_version": "https://github.com/buildingSMART/BCF-API/tree/release_2_1", + # "api_base_url": "http://127.0.0.1:5000/bcf/2.1" + # }, + { + "api_id": "bcf", + "version_id": "3.0", + "detailed_version": "https://github.com/buildingSMART/BCF-API/tree/release_3_0", + "api_base_url": "http://127.0.0.1:5000/bcf/3.0", + }, + ] + } + response = app.response_class( + response=json.dumps(Body), status=200, mimetype="application/json" + ) + return response + + +@app.route("/outh/login", methods=["GET", "POST"]) +def oauth_login(): + form = LoginForm() + if form.validate_on_submit(): + attempted_user = User.query.filter_by(username=form.username.data).first() + if attempted_user and attempted_user.check_password( + attempted_password=form.password.data + ): + login_user(attempted_user) + flash( + "Invalid Credentials", + category="info", + ) + client_id = request.args.get("client_id") + redirect_uri = request.args.get("redirect_uri") + state = request.args.get("state") + return redirect( + url_for( + "authorize", + client_id=client_id, + redirect_uri=redirect_uri, + state=state, + ) + ) + else: + flash( + "Invalid Credentials", + category="danger", + ) + return render_template("ologin.html", form=form) + + +@app.route("/oauth/authorize", methods=["GET", "POST"]) +def authorize(): + client_id = request.args.get("client_id") + redirect_uri = request.args.get("redirect_uri") + state = request.args.get("state") + if current_user.is_anonymous: + flash("Please Login !", category="info") + query = request.query_string + return redirect( + url_for( + "oauth_login", + client_id=client_id, + redirect_uri=redirect_uri, + state=state, + ) + ) + else: + try: + + user = current_user.id + flash(message=redirect_uri, category="warning") + client = OAuth2Client.query.filter_by(client_id=client_id).first() + if client: + if client.user_id == user: + code = gen_salt(48) + OauthCode = OAuth2AuthorizationCode( + client_id=client_id, + redirect_uri=redirect_uri, + user_id=user, + code=code, + response_type="code", + ) + db.session.add(OauthCode) + db.session.commit() + query = urllib.parse.urlencode( + { + "code": OauthCode.code, + "state": state, + } + ) + return redirect(f"{redirect_uri}?{query}") + else: + flash( + "You are not authorized to access this client", + category="danger", + ) + else: + flash("Client not found", category="danger") + except Exception as e: + flash(e, category="danger") + return render_template("oauth.html") + + +@app.route("/oauth/token", methods=["POST"]) +def issue_token(): + try: + code = request.form["code"] + Headers = str.split(request.headers["Authorization"]) + decode_header = base64.b64decode(Headers[1]).decode("utf-8") + creds = decode_header.split(":") + client_id = creds[0] + auth_code = OAuth2AuthorizationCode.query.filter_by(code=code).first() + if auth_code: + if auth_code.client_id == client_id: + access_token = gen_salt(48) + refresh_token = gen_salt(48) + expires_in = 3600 + OauthToken = OAuth2Token( + client_id=auth_code.client_id, + user_id=auth_code.user_id, + access_token=access_token, + refresh_token=refresh_token, + expires_in=expires_in, + scope=auth_code.scope, + token_type="Bearer", + ) + db.session.add(OauthToken) + db.session.commit() + query = { + "access_token": access_token, + "refresh_token": refresh_token, + "expires_in": expires_in, + "auth_code": auth_code.scope, + } + response = app.response_class( + response=json.dumps(query), status=200, mimetype="application/json" + ) + return response + else: + flash( + "You are not authorized to access this client", + category="danger", + ) + else: + flash("Client not found", category="danger") + except Exception as e: + flash(e, category="danger") + return render_template("oauth.html") diff --git a/src/bcfserver/website/templates/base.html b/src/bcfserver/website/templates/base.html new file mode 100644 index 0000000000..b55f4839e9 --- /dev/null +++ b/src/bcfserver/website/templates/base.html @@ -0,0 +1,107 @@ + + +
+ + + + + +
+{{ client.client_info|tojson }}
+{{ client.client_metadata|tojson }}
+
+