feat: Push initial version

This commit is contained in:
Nathan Woodburn 2024-02-09 23:04:20 +11:00
parent 924d9639a5
commit 6f195c86a1
Signed by: nathanwoodburn
GPG Key ID: 203B000478AD0EF1
16 changed files with 702 additions and 0 deletions

View File

@ -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

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__/
instance/

7
Dockerfile Normal file
View File

@ -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"]

View File

@ -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`

9
app.py Normal file
View File

@ -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',
})

4
requrements.txt Normal file
View File

@ -0,0 +1,4 @@
Flask
Flask-SQLAlchemy
Authlib
requests

36
website/app.py Normal file
View File

@ -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='')

56
website/models.py Normal file
View File

@ -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()

101
website/oauth2.py Normal file
View File

@ -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())

144
website/routes.py Normal file
View File

@ -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')

0
website/settings.py Normal file
View File

View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HNS Login</title>
<link rel="icon" href="favicon.png" type="image/png">
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
/* Dark theme*/
background-color: #222;
color: #fff;
}
h1 {
margin: 0;
padding: 20px;
background-color: #333;
color: #fff;
text-align: center;
}
p {
margin: 0;
padding: 20px;
text-align: center;
}
form {
text-align: center;
}
button {
padding: 10px 20px;
font-size: 16px;
background-color: #333;
color: #fff;
border: none;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Authorize Access</h1>
<p><strong>{{grant.client.client_name}}</strong> is requesting to access your account.</p>
</p>
<p>
You are currently logged in as <strong>{{ user.username }}/</strong> (<a href="{{ url_for('.logout', next=request.url) }}">Change Account</a>)
</p>
<form action="" method="post">
<br>
<button>Continue</button>
</form>
<div style="position: fixed; bottom: 0; width: 100%; text-align: center; background-color: #333; padding: 10px;">
Powered by <a href="https://auth.varo.domains/implement" target="_blank">Varo Auth</a> and <a href="nathan.woodburn.au" target="_blank">Nathan.Woodburn/</a>
</div>
</body>
</html>

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HNS Login</title>
<link rel="icon" href="favicon.png" type="image/png">
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
/* Dark theme*/
background-color: #222;
color: #fff;
}
h1 {
margin: 0;
padding: 20px;
background-color: #333;
color: #fff;
text-align: center;
}
h2 {
margin: 0;
padding: 20px;
text-align: center;
}
p {
margin: 0;
padding: 20px;
text-align: center;
}
form {
text-align: center;
}
button {
padding: 10px 20px;
font-size: 16px;
background-color: #333;
color: #fff;
border: none;
cursor: pointer;
}
</style>
<style>
label, label > span { display: block; }
label { margin: 15px 0; }
</style>
</head>
<body>
<a href="/"><h1>Home</h1></a>
<h2>Create OAuth Client</h2>
<form action="" method="post">
<label>
<span>Client Name</span>
<input type="text" name="client_name">
</label>
<label>
<span>Client URI</span>
<input type="url" name="client_uri">
</label>
<label>
<span>Allowed Scope</span>
<input type="text" name="scope">
</label>
<label>
<span>Redirect URIs</span>
<textarea name="redirect_uri" cols="30" rows="10"></textarea>
</label>
<label>
<span>Allowed Grant Types</span>
<textarea name="grant_type" cols="30" rows="10"></textarea>
</label>
<label>
<span>Allowed Response Types</span>
<textarea name="response_type" cols="30" rows="10"></textarea>
</label>
<label>
<span>Token Endpoint Auth Method</span>
<select name="token_endpoint_auth_method">
<option value="client_secret_basic">client_secret_basic</option>
<option value="client_secret_post">client_secret_post</option>
<option value="none">none</option>
</select>
</label>
<button>Submit</button>
</form>
<div style="height: 5em;"></div>
<div style="bottom: 0; text-align: center; background-color: #333; padding: 10px;">
Powered by <a href="https://auth.varo.domains/implement" target="_blank">Varo Auth</a> and <a href="nathan.woodburn.au" target="_blank">Nathan.Woodburn/</a>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

119
website/templates/home.html Normal file
View File

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HNS Login</title>
<link rel="icon" href="favicon.png" type="image/png">
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
/* Dark theme*/
background-color: #222;
color: #fff;
}
h1 {
margin: 0;
padding: 20px;
background-color: #333;
color: #fff;
text-align: center;
}
h2 {
margin: 0;
padding: 20px;
text-align: center;
}
p {
margin: 0;
padding: 20px;
text-align: center;
}
form {
text-align: center;
}
button {
padding: 10px 20px;
font-size: 16px;
background-color: #333;
color: #fff;
border: none;
cursor: pointer;
}
a.button {
display: block;
width: 200px;
margin: 20px auto;
padding: 10px 20px;
font-size: 16px;
background-color: #333;
color: #fff;
border: none;
cursor: pointer;
text-align: center;
text-decoration: none;
}
button.loginbutton {
/* Put in the centre of the screen */
margin-left: 50%;
transform: translateX(-50%);
}
</style>
</head>
<body>
<h1>HNS Login</h1>
{% if user %}
<h2>You are currently logged in as <strong>{{ user }}/</strong></h2>
<a href="{{ url_for('.logout') }}" class="button">Log Out</a>
{% for client in clients %}
<pre>
<strong>Client Info</strong>
{%- for key in client.client_info %}
<strong>{{ key }}: </strong>{{ client.client_info[key] }}
{%- endfor %}
<strong>Client Metadata</strong>
{%- for key in client.client_metadata %}
<strong>{{ key }}: </strong>{{ client.client_metadata[key] }}
{%- endfor %}
</pre>
<hr>
{% endfor %}
<br><br>
<p>Want to implement OAuth?<br>
Contact Nathan.Woodburn/ on any social media platform</p>
{% else %}
<h2>Login with your Handshake domain</h2>
<p>If you have already used Varo Auth, you can just select the domain you want to login with.</p>
<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
<script type="text/javascript" src="https://auth.varo.domains/v1"></script>
<script>var varo = new Varo();</script>
<button class="loginbutton" onclick='varo.auth().then(auth => {
if (auth.success) {
// handle success by calling your api to update the users session
$.post("/", JSON.stringify(auth.data), (response) => {
window.location.reload();
});
}
});'>Login</button>
{% endif %}
<div style="position: fixed; bottom: 0; width: 100%; text-align: center; background-color: #333; padding: 10px;">
Powered by <a href="https://auth.varo.domains/implement" target="_blank">Varo Auth</a> and <a href="nathan.woodburn.au" target="_blank">Nathan.Woodburn/</a>
</div>
</body>
</html>

22
website/varo_auth.py Normal file
View File

@ -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