Compare commits
5 Commits
2356c50fba
...
9d1a720ab7
Author | SHA1 | Date | |
---|---|---|---|
9d1a720ab7 | |||
4f01847b03 | |||
c66efb985a | |||
a93a178b57 | |||
e3c33a7492 |
55
buckler.py
55
buckler.py
|
@ -44,6 +44,10 @@ async def index(request):
|
||||||
fido2_keys = await conn.fetch(
|
fido2_keys = await conn.fetch(
|
||||||
"SELECT * FROM user_credential WHERE user_id = $1",
|
"SELECT * FROM user_credential WHERE user_id = $1",
|
||||||
request['session']['id'])
|
request['session']['id'])
|
||||||
|
active_sessions = await conn.fetch(
|
||||||
|
"SELECT ip_address FROM user_session "
|
||||||
|
"WHERE user_id = $1",
|
||||||
|
request['session']['id'])
|
||||||
|
|
||||||
if request['session']['admin']:
|
if request['session']['admin']:
|
||||||
apps = [app['name'] for app in apps]
|
apps = [app['name'] for app in apps]
|
||||||
|
@ -229,7 +233,8 @@ async def get_session(request):
|
||||||
session = session[0]
|
session = session[0]
|
||||||
data = await conn.fetchrow(
|
data = await conn.fetchrow(
|
||||||
"SELECT user_info.username, app_user.session_data "
|
"SELECT user_info.username, app_user.session_data "
|
||||||
"FROM user_info, app_user "
|
"FROM user_info LEFT JOIN app_user "
|
||||||
|
"ON (user_info.id = app_user.user_id) "
|
||||||
"WHERE user_info.id = $1 AND app_user.app_id = $2",
|
"WHERE user_info.id = $1 AND app_user.app_id = $2",
|
||||||
session['user_id'], app['id'])
|
session['user_id'], app['id'])
|
||||||
|
|
||||||
|
@ -244,11 +249,14 @@ async def get_session(request):
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
data_meta = dict(data)
|
data_meta = dict(data)
|
||||||
data_meta.update(
|
data_meta['last_used'] = session['last_used'].isoformat()
|
||||||
{'last_used': session['last_used'].isoformat()})
|
data_meta['user_sid'] = user_sid
|
||||||
|
data_meta['user_id'] = user_id
|
||||||
|
session_data = data_meta.pop('session_data')
|
||||||
data = {
|
data = {
|
||||||
'meta': data_meta,
|
'meta': data_meta,
|
||||||
'session_data': json.loads(data_meta.pop('session_data'))}
|
'session_data': json.loads(session_data)
|
||||||
|
}
|
||||||
|
|
||||||
return web.json_response(data)
|
return web.json_response(data)
|
||||||
else:
|
else:
|
||||||
|
@ -264,7 +272,44 @@ async def get_session(request):
|
||||||
@routes.post(config.url_prefix + '/set_session', name='set_session')
|
@routes.post(config.url_prefix + '/set_session', name='set_session')
|
||||||
async def set_session(request):
|
async def set_session(request):
|
||||||
"""Allows an application to set a user app session."""
|
"""Allows an application to set a user app session."""
|
||||||
pass
|
# TODO: only allow LAN IPs
|
||||||
|
app_id = request.query.get('app_id')
|
||||||
|
app_key = request.query.get('app_key')
|
||||||
|
user_id = request.query.get('userid')
|
||||||
|
user_sid = request.query.get('session')
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_id = int(app_id)
|
||||||
|
user_id = int(user_id)
|
||||||
|
assert all((app_id, app_key, user_id, user_sid))
|
||||||
|
except (ValueError, AssertionError):
|
||||||
|
return web.json_response({'error': "Invalid credentials."})
|
||||||
|
|
||||||
|
conn = await request.app['pool'].acquire()
|
||||||
|
app = await conn.fetchrow("SELECT * FROM app_info WHERE id = $1", app_id)
|
||||||
|
if app:
|
||||||
|
if argon2.verify(app_key, app['key_hash']):
|
||||||
|
session = await conn.fetchrow(
|
||||||
|
"SELECT * FROM user_session "
|
||||||
|
"WHERE user_id = $1 AND id = $2 AND expires > NOW()",
|
||||||
|
user_id, user_sid)
|
||||||
|
if session:
|
||||||
|
session_data = await request.text()
|
||||||
|
# TODO: error handling, verify json
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE app_user SET session_data = $1 "
|
||||||
|
"WHERE user_id = $2 AND app_id = $3",
|
||||||
|
session_data, user_id, app_id)
|
||||||
|
await conn.close()
|
||||||
|
return web.json_response({'success': True})
|
||||||
|
else:
|
||||||
|
error = {'error': "User ID or Session ID invalid."}
|
||||||
|
else:
|
||||||
|
error = {'error': "App ID or Key invalid."}
|
||||||
|
else:
|
||||||
|
error = {'error': "App ID or Key invalid."}
|
||||||
|
await conn.close()
|
||||||
|
return web.json_response(error)
|
||||||
|
|
||||||
|
|
||||||
async def init_app():
|
async def init_app():
|
||||||
|
|
|
@ -41,7 +41,7 @@ CREATE TABLE IF NOT EXISTS app_info (
|
||||||
CREATE TABLE IF NOT EXISTS app_user (
|
CREATE TABLE IF NOT EXISTS app_user (
|
||||||
user_id INTEGER references user_info(id) ON DELETE CASCADE,
|
user_id INTEGER references user_info(id) ON DELETE CASCADE,
|
||||||
app_id INTEGER references app_info(id) ON DELETE CASCADE,
|
app_id INTEGER references app_info(id) ON DELETE CASCADE,
|
||||||
session_data JSON,
|
session_data JSON DEFAULT '{}',
|
||||||
PRIMARY KEY (user_id, app_id)
|
PRIMARY KEY (user_id, app_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
107
buckler_flask.py
Normal file
107
buckler_flask.py
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Session interface middlewares to integrate the flask app with Buckler.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask.sessions import SessionInterface, SessionMixin
|
||||||
|
from flask import session, redirect, request
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
class BucklerSessionInterface(SessionInterface):
|
||||||
|
"""
|
||||||
|
Queries the Buckler server for session data to the current user and
|
||||||
|
application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.url = config.buckler['url']
|
||||||
|
self.app_id = config.buckler['app_id']
|
||||||
|
self.app_key = config.buckler['app_key']
|
||||||
|
|
||||||
|
def open_session(self, app, request):
|
||||||
|
"""Called when a request is initiated."""
|
||||||
|
user_id = request.cookies.get('userid')
|
||||||
|
user_sid = request.cookies.get('session')
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'app_id': self.app_id,
|
||||||
|
'app_key': self.app_key,
|
||||||
|
'userid': user_id,
|
||||||
|
'session': user_sid
|
||||||
|
}
|
||||||
|
params = urllib.parse.urlencode(params)
|
||||||
|
req = urllib.request.Request(self.url + f"/get_session?{params}")
|
||||||
|
res = urllib.request.urlopen(req)
|
||||||
|
data = json.loads(res.read())
|
||||||
|
if data.get('error'):
|
||||||
|
return None
|
||||||
|
session = BucklerSession()
|
||||||
|
session.update(data['session_data'])
|
||||||
|
session.meta = data['meta']
|
||||||
|
session.cookies = request.cookies
|
||||||
|
return session
|
||||||
|
|
||||||
|
def save_session(self, app, session, response):
|
||||||
|
"""Called at the end of a request."""
|
||||||
|
if not session.modified:
|
||||||
|
return
|
||||||
|
user_id = session.meta.get('user_id')
|
||||||
|
user_sid = session.meta.get('user_sid')
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'app_id': self.app_id,
|
||||||
|
'app_key': self.app_key,
|
||||||
|
'userid': user_id,
|
||||||
|
'session': user_sid
|
||||||
|
}
|
||||||
|
params = urllib.parse.urlencode(params)
|
||||||
|
data = json.dumps(session)
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self.url + f"/set_session?{params}",
|
||||||
|
data=data.encode('utf8'),
|
||||||
|
method='POST')
|
||||||
|
res = urllib.request.urlopen(req)
|
||||||
|
|
||||||
|
last_used = datetime.fromisoformat(session.meta['last_used'])
|
||||||
|
now = datetime.now(last_used.tzinfo)
|
||||||
|
delta = now - last_used
|
||||||
|
if delta.seconds > 600:
|
||||||
|
response.set_cookie(
|
||||||
|
'userid',
|
||||||
|
session.cookies['userid'],
|
||||||
|
max_age=30*24*60*60,
|
||||||
|
secure=True,
|
||||||
|
httponly=True)
|
||||||
|
response.set_cookie(
|
||||||
|
'session',
|
||||||
|
session.cookies['session'],
|
||||||
|
max_age=30*24*60*60,
|
||||||
|
secure=True,
|
||||||
|
httponly=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BucklerSession(dict, SessionMixin):
|
||||||
|
"""A server side session class based on the Buckler security shield."""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.modified = False
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
super().__setitem__(key, value)
|
||||||
|
self.modified = True
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth():
|
||||||
|
"""
|
||||||
|
Requires the user to be properly authenticated with Buckler before
|
||||||
|
accessing any views on the application.
|
||||||
|
"""
|
||||||
|
if not hasattr(session, 'meta'):
|
||||||
|
resp = redirect(config.buckler['login_url'])
|
||||||
|
resp.set_cookie('redirect', request.url)
|
||||||
|
return resp
|
|
@ -22,13 +22,19 @@ main {
|
||||||
|
|
||||||
main section {
|
main section {
|
||||||
background-color: whitesmoke;
|
background-color: whitesmoke;
|
||||||
padding: 5%;
|
padding: 2% 5%;
|
||||||
border-radius: 0.5em;
|
border-radius: 0.5em;
|
||||||
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14),
|
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14),
|
||||||
0 3px 1px -2px rgba(0, 0, 0, 0.2),
|
0 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||||
0 1px 5px 0 rgba(0, 0, 0, 0.12);
|
0 1px 5px 0 rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
#avail_sites {
|
#avail_sites {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 1em;
|
padding-left: 1em;
|
||||||
|
|
|
@ -1,3 +1,17 @@
|
||||||
|
function load() {
|
||||||
|
let headers = document.querySelectorAll('h2');
|
||||||
|
headers.forEach(function(header) {
|
||||||
|
header.addEventListener('click', function() {
|
||||||
|
let article = this.nextElementSibling;
|
||||||
|
if (article.style.display === 'none') {
|
||||||
|
article.style.display = '';
|
||||||
|
} else {
|
||||||
|
article.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function perm_change(row) {
|
function perm_change(row) {
|
||||||
let user_perms = users_perms[row.children[0].textContent];
|
let user_perms = users_perms[row.children[0].textContent];
|
||||||
let row_perms = [];
|
let row_perms = [];
|
||||||
|
|
|
@ -2,28 +2,37 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Buckler</title>
|
<title>Buckler</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/buckler.css">
|
||||||
<script>
|
<script>
|
||||||
var users_perms = {{ users_json|safe }};
|
var users_perms = {{ users_json|safe }};
|
||||||
</script>
|
</script>
|
||||||
<script type="text/javascript" src="/static/buckler.js"></script>
|
<script type="text/javascript" src="/static/buckler.js"></script>
|
||||||
<link rel="stylesheet" type="text/css" href="/static/buckler.css">
|
<script>window.onload = load;</script>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=0.8">
|
||||||
|
<meta name="description" content="A small shield for web applications.">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<object id="logo" data="/static/buckler_icon.svg"></object>
|
<object id="logo" title="Buckler logo" data="/static/buckler_icon.svg"></object>
|
||||||
<h1>Buckler</h1>
|
<h1>Buckler</h1>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<section>
|
<section>
|
||||||
Available sites
|
<h2>Available Sites</h2>
|
||||||
|
<article>
|
||||||
|
<hr>
|
||||||
<ul id="avail_sites">
|
<ul id="avail_sites">
|
||||||
{% for site in avail_sites %}
|
{% for site in avail_sites %}
|
||||||
<li><a href="{{ site['url'] }}">{{ site['name'] }}</a></li>
|
<li><a href="{{ site['url'] }}">{{ site['name'] }}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
{% if request['session']['admin'] %}
|
{% if request['session']['admin'] %}
|
||||||
<section>
|
<section>
|
||||||
|
<h2>User Permissions</h2>
|
||||||
|
<article style="display: none;">
|
||||||
|
<hr>
|
||||||
<table id="users">
|
<table id="users">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -46,9 +55,13 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<section>
|
<section>
|
||||||
|
<h2>Change Password</h2>
|
||||||
|
<article style="display: none;">
|
||||||
|
<hr>
|
||||||
<form action="{{ request.app.router['change_password'].url_for() }}" method="post" enctype="application/x-www-form-urlencoded">
|
<form action="{{ request.app.router['change_password'].url_for() }}" method="post" enctype="application/x-www-form-urlencoded">
|
||||||
<label for="current_password">Current password</label>
|
<label for="current_password">Current password</label>
|
||||||
<input id="current_password" name="current_password" type="password"><br>
|
<input id="current_password" name="current_password" type="password"><br>
|
||||||
|
@ -58,8 +71,12 @@
|
||||||
<input id="verify_password" name="verify_password" type="password"><br>
|
<input id="verify_password" name="verify_password" type="password"><br>
|
||||||
<input type="submit" value="Submit">
|
<input type="submit" value="Submit">
|
||||||
</form>
|
</form>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
|
<h2>Security Keys</h2>
|
||||||
|
<article style="display: none;">
|
||||||
|
<hr>
|
||||||
{% if fido2_keys %}
|
{% if fido2_keys %}
|
||||||
<table id="security_keys">
|
<table id="security_keys">
|
||||||
<thead>
|
<thead>
|
||||||
|
@ -81,6 +98,29 @@
|
||||||
<span>No registered keys.</span>
|
<span>No registered keys.</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<br><a href="./add_key">Add key</a>
|
<br><a href="./add_key">Add key</a>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Active Sessions</h2>
|
||||||
|
<article style="display: none;">
|
||||||
|
<hr>
|
||||||
|
<table id="active_sessions">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Delete</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for session in active_sessions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ session['ip_address'] }}</td>
|
||||||
|
<td><input type="checkbox"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -2,12 +2,17 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Buckler - Login</title>
|
<title>Buckler - Login</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/buckler.css">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=0.8">
|
||||||
|
<meta name="description" content="A small shield for web applications.">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
<object id="logo" title="Buckler logo" data="/static/buckler_icon.svg"></object>
|
||||||
<h1>Buckler Login</h1>
|
<h1>Buckler Login</h1>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
<section>
|
||||||
<form action="{{ request.app.router['login'].url_for() }}" method="post" enctype="application/x-www-form-urlencoded">
|
<form action="{{ request.app.router['login'].url_for() }}" method="post" enctype="application/x-www-form-urlencoded">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input id="username" name="username" type="text"><br>
|
<input id="username" name="username" type="text"><br>
|
||||||
|
@ -18,6 +23,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<input type="submit" value="Login">
|
<input type="submit" value="Login">
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -7,9 +7,12 @@
|
||||||
<script type="text/javascript" src="/static/buckler-auth.js"></script>
|
<script type="text/javascript" src="/static/buckler-auth.js"></script>
|
||||||
<script>const url_prefix = '{{ url_prefix }}';</script>
|
<script>const url_prefix = '{{ url_prefix }}';</script>
|
||||||
<script>window.onload = login;</script>
|
<script>window.onload = login;</script>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=0.8">
|
||||||
|
<meta name="description" content="A small shield for web applications.">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
<object id="logo" title="Buckler logo" data="/static/buckler_icon.svg"></object>
|
||||||
<h1>Buckler Login</h1>
|
<h1>Buckler Login</h1>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
|
|
@ -2,9 +2,17 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Buckler - Register</title>
|
<title>Buckler - Register</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/buckler.css">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=0.8">
|
||||||
|
<meta name="description" content="A small shield for web applications.">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<header>
|
||||||
|
<object id="logo" title="Buckler logo" data="/static/buckler_icon.svg"></object>
|
||||||
<h1>Buckler Register</h1>
|
<h1>Buckler Register</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
<form method="POST" enctype="application/x-www-form-urlencoded">
|
<form method="POST" enctype="application/x-www-form-urlencoded">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input id="username" name="username" type="text" minlength="3" maxlength="20"><br>
|
<input id="username" name="username" type="text" minlength="3" maxlength="20"><br>
|
||||||
|
@ -37,5 +45,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<input type="submit" value="Register">
|
<input type="submit" value="Register">
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -6,17 +6,21 @@
|
||||||
<script type="text/javascript" src="/static/cbor.js"></script>
|
<script type="text/javascript" src="/static/cbor.js"></script>
|
||||||
<script type="text/javascript" src="/static/buckler-auth.js"></script>
|
<script type="text/javascript" src="/static/buckler-auth.js"></script>
|
||||||
<script>const url_prefix = '{{ url_prefix }}';</script>
|
<script>const url_prefix = '{{ url_prefix }}';</script>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=0.8">
|
||||||
|
<meta name="description" content="A small shield for web applications.">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<header>
|
||||||
<div id="devices">
|
<object id="logo" title="Buckler logo" data="/static/buckler_icon.svg"></object>
|
||||||
<div class="device">
|
|
||||||
<h1>Register Security Key</h1>
|
<h1>Register Security Key</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
<p>Upon clicking submit, your security key will begin flashing. Have it ready.
|
<p>Upon clicking submit, your security key will begin flashing. Have it ready.
|
||||||
<p><label for="security_key_nick">Security key nick</label> <input id="security_key_nick" type="text" minlength="1" maxlength="64" required>
|
<p><label for="security_key_nick">Security key nick</label>
|
||||||
|
<input id="security_key_nick" type="text" minlength="1" maxlength="64" required>
|
||||||
<p><input type="button" value="Submit" onclick="register()">
|
<p><input type="button" value="Submit" onclick="register()">
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -2,8 +2,19 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Buckler - Register</title>
|
<title>Buckler - Register</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/buckler.css">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=0.8">
|
||||||
|
<meta name="description" content="A small shield for web applications.">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<header>
|
||||||
|
<object id="logo" title="Buckler logo" data="/static/buckler_icon.svg"></object>
|
||||||
|
<h1>Buckler Register</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user