diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..d95d32f --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,28 @@ +name: Build Docker +run-name: Build Docker Image +on: [push] + +jobs: + Build Docker: + runs-on: [ubuntu-latest, amd] + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Install Docker + run : | + apt-get install ca-certificates curl gnupg + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + apt-get update + apt-get install docker-ce-cli -y + - name: Build Docker image + run : | + echo "${{ secrets.DOCKERGIT_TOKEN }}" | docker login git.woodburn.au -u nathanwoodburn --password-stdin + tag_num=$(git rev-parse --short HEAD) + docker build -t hns-login:$tag_num . + docker tag hns-login:$tag_num git.woodburn.au/nathanwoodburn/hns-login:$tag_num + docker push git.woodburn.au/nathanwoodburn/hns-login:$tag_num + docker tag hns-login:$tag_num git.woodburn.au/nathanwoodburn/hns-login:latest + docker push git.woodburn.au/nathanwoodburn/hns-login:latest \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..040812d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ + +__pycache__/ + +instance/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1522c9b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10-bullseye +COPY requirements.txt /app/ +WORKDIR /app +RUN pip install -r requirements.txt +COPY . . +VOLUME /app/instance +CMD ["flask", "run", "-p", "9090", "-h", "0.0.0.0"] \ No newline at end of file diff --git a/README.md b/README.md index e69501f..d75c05e 100644 --- a/README.md +++ b/README.md @@ -1 +1,10 @@ # varo-openid + +## Add a client +Set the following parameters: + +Allowed Scope: `profile` +Allowed Grant Types: `authorization_code` +Allowed Response Types: `code` +Token Endpoint Authentication Method: `client_secret_post` + diff --git a/app.py b/app.py new file mode 100644 index 0000000..72a7d05 --- /dev/null +++ b/app.py @@ -0,0 +1,9 @@ +from website.app import create_app + + +app = create_app({ + 'SECRET_KEY': 'secret', + 'OAUTH2_REFRESH_TOKEN_GENERATOR': True, + 'SQLALCHEMY_TRACK_MODIFICATIONS': False, + 'SQLALCHEMY_DATABASE_URI': 'sqlite:///db.sqlite', +}) \ No newline at end of file diff --git a/requrements.txt b/requrements.txt new file mode 100644 index 0000000..fca4a56 --- /dev/null +++ b/requrements.txt @@ -0,0 +1,4 @@ +Flask +Flask-SQLAlchemy +Authlib +requests \ No newline at end of file diff --git a/website/app.py b/website/app.py new file mode 100644 index 0000000..ded960c --- /dev/null +++ b/website/app.py @@ -0,0 +1,36 @@ +import os +from flask import Flask +from .models import db +from .oauth2 import config_oauth +from .routes import bp + + +def create_app(config=None): + app = Flask(__name__) + + # load default configuration + app.config.from_object('website.settings') + + # load environment configuration + if 'WEBSITE_CONF' in os.environ: + app.config.from_envvar('WEBSITE_CONF') + + # load app specified configuration + if config is not None: + if isinstance(config, dict): + app.config.update(config) + elif config.endswith('.py'): + app.config.from_pyfile(config) + + setup_app(app) + return app + + +def setup_app(app): + + db.init_app(app) + # Create tables if they do not exist already + with app.app_context(): + db.create_all() + config_oauth(app) + app.register_blueprint(bp, url_prefix='') \ No newline at end of file diff --git a/website/models.py b/website/models.py new file mode 100644 index 0000000..ef66d8e --- /dev/null +++ b/website/models.py @@ -0,0 +1,56 @@ +import time +from flask_sqlalchemy import SQLAlchemy +from authlib.integrations.sqla_oauth2 import ( + OAuth2ClientMixin, + OAuth2AuthorizationCodeMixin, + OAuth2TokenMixin, +) + +db = SQLAlchemy() + + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(40), unique=True) + + def __str__(self): + return self.username + + def get_user_id(self): + return self.id + + def check_password(self, password): + return password == 'valid' + + +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() \ No newline at end of file diff --git a/website/oauth2.py b/website/oauth2.py new file mode 100644 index 0000000..4ab250e --- /dev/null +++ b/website/oauth2.py @@ -0,0 +1,101 @@ +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()) \ No newline at end of file diff --git a/website/routes.py b/website/routes.py new file mode 100644 index 0000000..e091e6b --- /dev/null +++ b/website/routes.py @@ -0,0 +1,144 @@ +import time +from .varo_auth import flask_login as varo_auth_flask_login +from flask import Blueprint, request, session, url_for +from flask import render_template, redirect, jsonify, send_from_directory +from werkzeug.security import gen_salt +from authlib.integrations.flask_oauth2 import current_token +from authlib.oauth2 import OAuth2Error +from .models import db, User, OAuth2Client +from .oauth2 import authorization, require_oauth + + +bp = Blueprint('home', __name__) + + +def current_user(): + if 'id' in session: + uid = session['id'] + return User.query.get(uid) + return None + + +def split_by_crlf(s): + return [v for v in s.splitlines() if v] + + +@bp.route('/', methods=('GET', 'POST')) +def home(): + next_page = request.args.get('next') + if request.method == 'POST': + auth = varo_auth_flask_login(request) + if auth == False: + return redirect('/?error=login_failed') + print(auth) + user = User.query.filter_by(username=auth).first() + if not user: + user = User(username=auth) + db.session.add(user) + db.session.commit() + session['id'] = user.id + # if user is not just to log in, but need to head back to the auth page, then go for it + if next_page: + return redirect(next_page) + return redirect('/') + user = current_user() + if user: + clients = OAuth2Client.query.filter_by(user_id=user.id).all() + if next_page: + return redirect(next_page) + else: + clients = [] + + return render_template('home.html', user=user, clients=clients) + + +@bp.route('/logout') +def logout(): + del session['id'] + next = request.args.get('next') + if next: + return redirect(url_for('home.home', next=next)) + + return redirect('/') + + +@bp.route('/create_client', methods=('GET', 'POST')) +def create_client(): + user = current_user() + if not user: + return redirect('/') + if request.method == 'GET': + return render_template('create_client.html') + + 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=user.id, + ) + + form = request.form + client_metadata = { + "client_name": form["client_name"], + "client_uri": form["client_uri"], + "grant_types": split_by_crlf(form["grant_type"]), + "redirect_uris": split_by_crlf(form["redirect_uri"]), + "response_types": split_by_crlf(form["response_type"]), + "scope": form["scope"], + "token_endpoint_auth_method": form["token_endpoint_auth_method"] + } + client.set_client_metadata(client_metadata) + + if form['token_endpoint_auth_method'] == 'none': + client.client_secret = '' + else: + client.client_secret = gen_salt(48) + + db.session.add(client) + db.session.commit() + return redirect('/') + + +@bp.route('/oauth/authorize', methods=['GET', 'POST']) +def authorize(): + user = current_user() + # if user log status is not true (Auth server), then to log it in + if not user: + return redirect(url_for('home.home', next=request.url)) + if request.method == 'GET': + try: + grant = authorization.get_consent_grant(end_user=user) + except OAuth2Error as error: + return error.error + return render_template('authorize.html', user=user, grant=grant) + + grant_user = user + + return authorization.create_authorization_response(grant_user=grant_user) + + +@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('/api/me') +@require_oauth('profile') +def api_me(): + user = current_token.user + print(user.id, user.username) + return jsonify(id=user.id, username=user.username, + email="auth+" + user.username + "@hnshosting.au", + displayName=user.username+"/") + + + +@bp.route('/favicon.png') +def favicon(): + return send_from_directory('templates', 'favicon.png', mimetype='image/png') \ No newline at end of file diff --git a/website/settings.py b/website/settings.py new file mode 100644 index 0000000..e69de29 diff --git a/website/templates/authorize.html b/website/templates/authorize.html new file mode 100644 index 0000000..e0369b4 --- /dev/null +++ b/website/templates/authorize.html @@ -0,0 +1,62 @@ + + + + + + HNS Login + + + + +

Authorize Access

+

{{grant.client.client_name}} is requesting to access your account.

+

+ +

+You are currently logged in as {{ user.username }}/ (Change Account) +

+ +
+
+ +
+ +
+ Powered by Varo Auth and Nathan.Woodburn/ +
+ + + + diff --git a/website/templates/create_client.html b/website/templates/create_client.html new file mode 100644 index 0000000..2c8ff4f --- /dev/null +++ b/website/templates/create_client.html @@ -0,0 +1,101 @@ + + + + + + HNS Login + + + + + + + + + + +

Home

+

Create OAuth Client

+ +
+ + + + + + + + +
+ +
+
+ Powered by Varo Auth and Nathan.Woodburn/ +
+ + \ No newline at end of file diff --git a/website/templates/favicon.png b/website/templates/favicon.png new file mode 100644 index 0000000..88ddd85 Binary files /dev/null and b/website/templates/favicon.png differ diff --git a/website/templates/home.html b/website/templates/home.html new file mode 100644 index 0000000..fa151cb --- /dev/null +++ b/website/templates/home.html @@ -0,0 +1,119 @@ + + + + + + HNS Login + + + + +

HNS Login

+ +{% if user %} + +

You are currently logged in as {{ user }}/

+ + +Log Out + +{% for client in clients %} +
+Client Info
+  {%- for key in client.client_info %}
+  {{ key }}: {{ client.client_info[key] }}
+  {%- endfor %}
+Client Metadata
+  {%- for key in client.client_metadata %}
+  {{ key }}: {{ client.client_metadata[key] }}
+  {%- endfor %}
+
+
+{% endfor %} + +

+

Want to implement OAuth?
+Contact Nathan.Woodburn/ on any social media platform

+ +{% else %} +

Login with your Handshake domain

+

If you have already used Varo Auth, you can just select the domain you want to login with.

+ + + + + + + +{% endif %} + + + +
+ Powered by Varo Auth and Nathan.Woodburn/ +
+ + \ No newline at end of file diff --git a/website/varo_auth.py b/website/varo_auth.py new file mode 100644 index 0000000..738d07d --- /dev/null +++ b/website/varo_auth.py @@ -0,0 +1,22 @@ +import requests +import json + +def flask_login(request): + dict = request.form.to_dict() + keys = dict.keys() + keys = list(keys)[0] + keys = json.loads(keys) + auth_request = keys['request'] + return login(auth_request) + +def login(request): + r = requests.get(f'https://auth.varo.domains/verify/{request}') + r = r.json() + if r['success'] == False: + return False + + if 'data' in r: + data = r['data'] + if 'name' in data: + return data['name'] + return False \ No newline at end of file