sixth commit
This commit is contained in:
parent
325d2af09c
commit
2356c50fba
|
@ -4,7 +4,7 @@ A security shield for protecting a number of small web applications.
|
||||||
## Requirements
|
## Requirements
|
||||||
Python 3.7+
|
Python 3.7+
|
||||||
PostgreSQL 11.5+
|
PostgreSQL 11.5+
|
||||||
Python packages: `wheel gunicorn aiohttp aiohttp_jinja2 asyncpg passlib argon2_cffi uvloop`
|
Python packages: `wheel gunicorn aiohttp aiohttp_jinja2 asyncpg passlib argon2_cffi uvloop fido2`
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
```
|
```
|
||||||
|
|
217
auth.py
Normal file
217
auth.py
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Endpoints for registering and authenticating FIDO2 security keys.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import functools
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from fido2.client import ClientData
|
||||||
|
from fido2.server import Fido2Server, RelyingParty
|
||||||
|
from fido2.ctap2 import AttestationObject, AuthenticatorData, \
|
||||||
|
AttestedCredentialData
|
||||||
|
from fido2 import cbor
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
routes = web.RouteTableDef()
|
||||||
|
rp = RelyingParty(config.server_domain, 'Buckler')
|
||||||
|
server = Fido2Server(rp)
|
||||||
|
|
||||||
|
def auth_required(func):
|
||||||
|
"""
|
||||||
|
Wrapper for views with should be protected by authtication.
|
||||||
|
"""
|
||||||
|
@functools.wraps(func)
|
||||||
|
async def wrapper(request, *args, **kwargs):
|
||||||
|
login_url = request.app.router['login'].url_for()
|
||||||
|
sid = request.cookies.get('session')
|
||||||
|
try:
|
||||||
|
user_id = int(request.cookies.get('userid', '0'))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
user_id = None
|
||||||
|
if not sid or not user_id:
|
||||||
|
raise web.HTTPFound(location=login_url)
|
||||||
|
async with request.app['pool'].acquire() as conn:
|
||||||
|
session = await conn.fetchrow(
|
||||||
|
"SELECT user_info.id, user_info.username, user_info.admin, "
|
||||||
|
"user_session.last_used "
|
||||||
|
"FROM user_info LEFT JOIN user_session "
|
||||||
|
"ON (user_info.id = user_session.user_id) "
|
||||||
|
"WHERE user_info.id = $1 AND user_session.id = $2 "
|
||||||
|
"AND user_session.expires > NOW()",
|
||||||
|
user_id, sid)
|
||||||
|
if session:
|
||||||
|
request['session'] = dict(session)
|
||||||
|
resp = await func(request, *args, **kwargs)
|
||||||
|
tz = session['last_used'].tzinfo
|
||||||
|
delta = datetime.now(tz) - session['last_used']
|
||||||
|
if delta.seconds > 600:
|
||||||
|
async with request.app['pool'].acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE user_session SET last_used = NOW(), "
|
||||||
|
"expires = NOW() + INTERVAL '30 DAYS' "
|
||||||
|
"WHERE user_id = $1",
|
||||||
|
user_id)
|
||||||
|
resp.set_cookie(
|
||||||
|
'userid',
|
||||||
|
user_id,
|
||||||
|
max_age=30*24*60*60,
|
||||||
|
secure=True,
|
||||||
|
httponly=True)
|
||||||
|
resp.set_cookie(
|
||||||
|
'session',
|
||||||
|
sid,
|
||||||
|
max_age=30*24*60*60,
|
||||||
|
secure=True,
|
||||||
|
httponly=True)
|
||||||
|
return resp
|
||||||
|
else:
|
||||||
|
raise web.HTTPFound(location=login_url)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post(config.url_prefix + '/register/begin', name='register_begin')
|
||||||
|
@auth_required
|
||||||
|
async def register_begin(request):
|
||||||
|
user_id = request['session']['id']
|
||||||
|
username = request['session']['username']
|
||||||
|
|
||||||
|
async with request.app['pool'].acquire() as conn:
|
||||||
|
exist_cred = await conn.fetch(
|
||||||
|
"SELECT * FROM user_credential WHERE user_id = $1",
|
||||||
|
user_id)
|
||||||
|
exist_cred = [AttestedCredentialData(c['credential']) for c in exist_cred]
|
||||||
|
|
||||||
|
registration_data, state = server.register_begin({
|
||||||
|
'id': str(user_id).encode('utf8'),
|
||||||
|
'name': username,
|
||||||
|
'displayName': username,
|
||||||
|
}, exist_cred, user_verification='discouraged')
|
||||||
|
|
||||||
|
resp = web.Response(body=cbor.encode(registration_data))
|
||||||
|
resp.set_cookie('state', json.dumps(state))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post(config.url_prefix + '/register/complete',name='register_complete')
|
||||||
|
@auth_required
|
||||||
|
async def register_complete(request):
|
||||||
|
user_id = request['session']['id']
|
||||||
|
username = request['session']['username']
|
||||||
|
|
||||||
|
data = await request.read()
|
||||||
|
data = cbor.decode(data)
|
||||||
|
client_data = ClientData(data['clientDataJSON'])
|
||||||
|
att_obj = AttestationObject(data['attestationObject'])
|
||||||
|
|
||||||
|
nick = data['security_key_nick']
|
||||||
|
try:
|
||||||
|
assert 64 >= len(nick) >= 1
|
||||||
|
except AssertionError:
|
||||||
|
d = {'ok': False,
|
||||||
|
'status': 400,
|
||||||
|
'message': "security key nick must be between 1 and 64 chars"
|
||||||
|
}
|
||||||
|
return web.json_response(d, status=400)
|
||||||
|
|
||||||
|
state = json.loads(request.cookies.get('state'))
|
||||||
|
auth_data = server.register_complete(
|
||||||
|
state,
|
||||||
|
client_data,
|
||||||
|
att_obj
|
||||||
|
)
|
||||||
|
|
||||||
|
async with request.app['pool'].acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO user_credential (user_id, nick, credential) "
|
||||||
|
"VALUES ($1, $2, $3)",
|
||||||
|
user_id, nick, auth_data.credential_data)
|
||||||
|
|
||||||
|
resp = web.json_response({'ok': True})
|
||||||
|
resp.set_cookie('state', '', max_age=0)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post(config.url_prefix + '/authenticate/begin',
|
||||||
|
name='authenticate_begin')
|
||||||
|
async def authenticate_begin(request):
|
||||||
|
user_id = int(request.cookies.get('userid'))
|
||||||
|
if not user_id:
|
||||||
|
raise web.HTTPUnauthorized
|
||||||
|
data = await request.read()
|
||||||
|
data = cbor.decode(data)
|
||||||
|
async with request.app['pool'].acquire() as conn:
|
||||||
|
credentials = await conn.fetch(
|
||||||
|
"SELECT * FROM user_credential WHERE user_id = $1",
|
||||||
|
user_id)
|
||||||
|
|
||||||
|
credentials =[AttestedCredentialData(c['credential']) for c in credentials]
|
||||||
|
|
||||||
|
auth_data, state = server.authenticate_begin(credentials)
|
||||||
|
resp = web.Response(body=cbor.encode(auth_data))
|
||||||
|
resp.set_cookie('state', json.dumps(state))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post(config.url_prefix + '/authenticate/complete',
|
||||||
|
name='authenticate_complete')
|
||||||
|
async def authenticate_complete(request):
|
||||||
|
user_id = int(request.cookies.get('userid'))
|
||||||
|
|
||||||
|
async with request.app['pool'].acquire() as conn:
|
||||||
|
credentials = await conn.fetch(
|
||||||
|
"SELECT * FROM user_credential WHERE user_id = $1",
|
||||||
|
user_id)
|
||||||
|
credentials =[AttestedCredentialData(c['credential']) for c in credentials]
|
||||||
|
|
||||||
|
data = await request.read()
|
||||||
|
data = cbor.decode(data)
|
||||||
|
credential_id = data['credentialId']
|
||||||
|
client_data = ClientData(data['clientDataJSON'])
|
||||||
|
auth_data = AuthenticatorData(data['authenticatorData'])
|
||||||
|
signature = data['signature']
|
||||||
|
|
||||||
|
state = json.loads(request.cookies.get('state'))
|
||||||
|
server.authenticate_complete(
|
||||||
|
state,
|
||||||
|
credentials,
|
||||||
|
credential_id,
|
||||||
|
client_data,
|
||||||
|
auth_data,
|
||||||
|
signature
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: refactor so code isn't duplicated
|
||||||
|
url = request.cookies.get('redirect')
|
||||||
|
if not url:
|
||||||
|
url = request.app.router['index'].url_for()
|
||||||
|
resp = web.json_response({'ok': True, 'redirect': str(url)})
|
||||||
|
resp.set_cookie('state', '', max_age=0)
|
||||||
|
|
||||||
|
resp.set_cookie('redirect', '', max_age=0)
|
||||||
|
resp.set_cookie(
|
||||||
|
'userid',
|
||||||
|
user_id,
|
||||||
|
max_age=30*24*60*60,
|
||||||
|
secure=True,
|
||||||
|
httponly=True)
|
||||||
|
sid = secrets.token_urlsafe(64)
|
||||||
|
ip_address = request.headers['X-Real-IP']
|
||||||
|
async with request.app['pool'].acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO user_session (user_id, id, ip_address)"
|
||||||
|
"VALUES ($1, $2, $3)",
|
||||||
|
user_id,
|
||||||
|
sid,
|
||||||
|
ip_address)
|
||||||
|
resp.set_cookie(
|
||||||
|
'session',
|
||||||
|
sid,
|
||||||
|
max_age=30*24*60*60,
|
||||||
|
secure=True,
|
||||||
|
httponly=True)
|
||||||
|
|
||||||
|
return resp
|
93
buckler.py
93
buckler.py
|
@ -4,7 +4,6 @@ A security shield for protecting a number of small web applications.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import functools
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
|
@ -16,6 +15,7 @@ from passlib.hash import argon2
|
||||||
import asyncpg
|
import asyncpg
|
||||||
import uvloop
|
import uvloop
|
||||||
|
|
||||||
|
import auth
|
||||||
import mail
|
import mail
|
||||||
import config
|
import config
|
||||||
import validation
|
import validation
|
||||||
|
@ -23,61 +23,8 @@ import validation
|
||||||
uvloop.install()
|
uvloop.install()
|
||||||
routes = web.RouteTableDef()
|
routes = web.RouteTableDef()
|
||||||
|
|
||||||
def auth_required(func):
|
|
||||||
"""
|
|
||||||
Wrapper for views with should be protected by authtication.
|
|
||||||
"""
|
|
||||||
@functools.wraps(func)
|
|
||||||
async def wrapper(request, *args, **kwargs):
|
|
||||||
login_url = request.app.router['login'].url_for()
|
|
||||||
sid = request.cookies.get('session')
|
|
||||||
try:
|
|
||||||
user_id = int(request.cookies.get('userid', '0'))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
user_id = None
|
|
||||||
if not sid or not user_id:
|
|
||||||
raise web.HTTPFound(location=login_url)
|
|
||||||
async with request.app['pool'].acquire() as conn:
|
|
||||||
session = await conn.fetchrow(
|
|
||||||
"SELECT user_info.id, user_info.username, user_info.admin, "
|
|
||||||
"user_session.last_used "
|
|
||||||
"FROM user_info LEFT JOIN user_session "
|
|
||||||
"ON (user_info.id = user_session.user_id) "
|
|
||||||
"WHERE user_info.id = $1 AND user_session.id = $2 "
|
|
||||||
"AND user_session.expires > NOW()",
|
|
||||||
user_id, sid)
|
|
||||||
if session:
|
|
||||||
request['session'] = dict(session)
|
|
||||||
resp = await func(request, *args, **kwargs)
|
|
||||||
tz = session['last_used'].tzinfo
|
|
||||||
delta = datetime.now(tz) - session['last_used']
|
|
||||||
if delta.seconds > 600:
|
|
||||||
async with request.app['pool'].acquire() as conn:
|
|
||||||
await conn.execute(
|
|
||||||
"UPDATE user_session SET last_used = NOW(), "
|
|
||||||
"expires = NOW() + INTERVAL '30 DAYS' "
|
|
||||||
"WHERE user_id = $1",
|
|
||||||
user_id)
|
|
||||||
resp.set_cookie(
|
|
||||||
'userid',
|
|
||||||
user_id,
|
|
||||||
max_age=30*24*60*60,
|
|
||||||
secure=True,
|
|
||||||
httponly=True)
|
|
||||||
resp.set_cookie(
|
|
||||||
'session',
|
|
||||||
sid,
|
|
||||||
max_age=30*24*60*60,
|
|
||||||
secure=True,
|
|
||||||
httponly=True)
|
|
||||||
return resp
|
|
||||||
else:
|
|
||||||
raise web.HTTPFound(location=login_url)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get(config.url_prefix + '/', name='index')
|
@routes.get(config.url_prefix + '/', name='index')
|
||||||
@auth_required
|
@auth.auth_required
|
||||||
async def index(request):
|
async def index(request):
|
||||||
"""The index page."""
|
"""The index page."""
|
||||||
async with request.app['pool'].acquire() as conn:
|
async with request.app['pool'].acquire() as conn:
|
||||||
|
@ -94,6 +41,9 @@ async def index(request):
|
||||||
"SELECT user_info.id, user_info.username, app_user.app_id "
|
"SELECT user_info.id, user_info.username, app_user.app_id "
|
||||||
"FROM user_info LEFT JOIN app_user "
|
"FROM user_info LEFT JOIN app_user "
|
||||||
"ON (user_info.id = app_user.user_id)")
|
"ON (user_info.id = app_user.user_id)")
|
||||||
|
fido2_keys = await conn.fetch(
|
||||||
|
"SELECT * FROM user_credential 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]
|
||||||
|
@ -104,6 +54,13 @@ async def index(request):
|
||||||
return render_template("index.html", request, locals())
|
return render_template("index.html", request, locals())
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get(config.url_prefix + '/change_password', name='change_password')
|
||||||
|
@auth.auth_required
|
||||||
|
async def change_password(request):
|
||||||
|
"""Allows a user to change their password."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@routes.get(config.url_prefix + '/login', name='login')
|
@routes.get(config.url_prefix + '/login', name='login')
|
||||||
@routes.post(config.url_prefix + '/login', name='login')
|
@routes.post(config.url_prefix + '/login', name='login')
|
||||||
async def login(request):
|
async def login(request):
|
||||||
|
@ -120,12 +77,29 @@ async def login(request):
|
||||||
user_info = await conn.fetchrow(
|
user_info = await conn.fetchrow(
|
||||||
"SELECT * FROM user_info WHERE username = $1",
|
"SELECT * FROM user_info WHERE username = $1",
|
||||||
username)
|
username)
|
||||||
|
if user_info:
|
||||||
|
has_cred = await conn.fetchrow(
|
||||||
|
"SELECT EXISTS(SELECT id FROM user_credential "
|
||||||
|
"WHERE user_id = $1)",
|
||||||
|
user_info['id'])
|
||||||
|
|
||||||
if not user_info:
|
if not user_info:
|
||||||
login_failed = True
|
login_failed = True
|
||||||
return render_template("login.html", request, locals())
|
return render_template("login.html", request, locals())
|
||||||
|
|
||||||
|
if has_cred['exists'] and user_info['passwordless']:
|
||||||
|
url_prefix = config.url_prefix
|
||||||
|
resp = render_template("login_key.html", request, locals())
|
||||||
|
resp.set_cookie('userid', user_info['id'])
|
||||||
|
return resp
|
||||||
|
|
||||||
if argon2.verify(password, user_info['password_hash']):
|
if argon2.verify(password, user_info['password_hash']):
|
||||||
|
if has_cred['exists']:
|
||||||
|
url_prefix = config.url_prefix
|
||||||
|
resp = render_template("login_key.html", request, locals())
|
||||||
|
resp.set_cookie('userid', user_info['id'])
|
||||||
|
return resp
|
||||||
|
|
||||||
url = request.cookies.get('redirect')
|
url = request.cookies.get('redirect')
|
||||||
if not url:
|
if not url:
|
||||||
url = request.app.router['index'].url_for()
|
url = request.app.router['index'].url_for()
|
||||||
|
@ -218,6 +192,14 @@ async def register(request):
|
||||||
return render_template("register_result.html", request, locals())
|
return render_template("register_result.html", request, locals())
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get(config.url_prefix + '/add_key', name='add_key')
|
||||||
|
@auth.auth_required
|
||||||
|
async def add_key(request):
|
||||||
|
"""Add a new security key."""
|
||||||
|
url_prefix = config.url_prefix
|
||||||
|
return render_template("register_key.html", request, locals())
|
||||||
|
|
||||||
|
|
||||||
@routes.get(config.url_prefix + '/get_session', name='get_session')
|
@routes.get(config.url_prefix + '/get_session', name='get_session')
|
||||||
async def get_session(request):
|
async def get_session(request):
|
||||||
"""Returns a user's application session."""
|
"""Returns a user's application session."""
|
||||||
|
@ -296,6 +278,7 @@ async def init_app():
|
||||||
await conn.execute(file.read())
|
await conn.execute(file.read())
|
||||||
|
|
||||||
app.router.add_routes(routes)
|
app.router.add_routes(routes)
|
||||||
|
app.router.add_routes(auth.routes)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
10
buckler.sql
10
buckler.sql
|
@ -5,7 +5,8 @@ CREATE TABLE IF NOT EXISTS user_info (
|
||||||
password_hash TEXT,
|
password_hash TEXT,
|
||||||
date_created TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
date_created TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
active BOOLEAN NOT NULL DEFAULT FALSE,
|
active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
admin BOOLEAN NOT NULL DEFAULT FALSE
|
admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
passwordless BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_session (
|
CREATE TABLE IF NOT EXISTS user_session (
|
||||||
|
@ -43,3 +44,10 @@ CREATE TABLE IF NOT EXISTS app_user (
|
||||||
session_data JSON,
|
session_data JSON,
|
||||||
PRIMARY KEY (user_id, app_id)
|
PRIMARY KEY (user_id, app_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_credential (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER references user_info(id) ON DELETE CASCADE,
|
||||||
|
nick TEXT NOT NULL,
|
||||||
|
credential BYTEA NOT NULL
|
||||||
|
);
|
||||||
|
|
59
static/buckler-auth.js
Normal file
59
static/buckler-auth.js
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
function register() {
|
||||||
|
fetch(url_prefix + '/register/begin', {
|
||||||
|
method: 'POST',
|
||||||
|
}).then(function(response) {
|
||||||
|
if(!response.ok) { throw new Error('Error getting registration data!'); }
|
||||||
|
return response.arrayBuffer();
|
||||||
|
}).then(CBOR.decode).then(function(options) {
|
||||||
|
return navigator.credentials.create(options);
|
||||||
|
}).then(function(attestation) {
|
||||||
|
return fetch(url_prefix + '/register/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/cbor'},
|
||||||
|
body: CBOR.encode({
|
||||||
|
"attestationObject": new Uint8Array(attestation.response.attestationObject),
|
||||||
|
"clientDataJSON": new Uint8Array(attestation.response.clientDataJSON),
|
||||||
|
"security_key_nick": document.querySelector('#security_key_nick').value,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}).then(function(response) {
|
||||||
|
return response.json();
|
||||||
|
}).then(function(json) {
|
||||||
|
console.log(json);
|
||||||
|
if (!json.ok) { throw new Error('HTTP error, status = ' + json.status + ', message = ' + json.message); }
|
||||||
|
window.location = url_prefix + '/login';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
fetch(url_prefix + '/authenticate/begin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/cbor'},
|
||||||
|
body: CBOR.encode({
|
||||||
|
})
|
||||||
|
}).then(function(response) {
|
||||||
|
if(!response.ok) { throw new Error('Error getting registration data!'); }
|
||||||
|
return response.arrayBuffer();
|
||||||
|
}).then(CBOR.decode).then(function(options) {
|
||||||
|
return navigator.credentials.get(options);
|
||||||
|
}).then(function(assertion) {
|
||||||
|
return fetch(url_prefix + '/authenticate/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/cbor'},
|
||||||
|
body: CBOR.encode({
|
||||||
|
"credentialId": new Uint8Array(assertion.rawId),
|
||||||
|
"authenticatorData": new Uint8Array(assertion.response.authenticatorData),
|
||||||
|
"clientDataJSON": new Uint8Array(assertion.response.clientDataJSON),
|
||||||
|
"signature": new Uint8Array(assertion.response.signature)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}, function(reason) {
|
||||||
|
console.log('navigator.credentials.get() failed for the following reason: ' + reason);
|
||||||
|
}).then(function(response) {
|
||||||
|
return response.json();
|
||||||
|
}).then(function(json) {
|
||||||
|
console.log(json);
|
||||||
|
if (!json.ok) { throw new Error('HTTP error, status = ' + json.status + ', message = ' + json.message); }
|
||||||
|
window.location = url_prefix + '/';
|
||||||
|
});
|
||||||
|
}
|
|
@ -18,6 +18,9 @@ header {
|
||||||
main {
|
main {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2em;
|
gap: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
main section {
|
||||||
background-color: whitesmoke;
|
background-color: whitesmoke;
|
||||||
padding: 5%;
|
padding: 5%;
|
||||||
border-radius: 0.5em;
|
border-radius: 0.5em;
|
||||||
|
|
406
static/cbor.js
Normal file
406
static/cbor.js
Normal file
|
@ -0,0 +1,406 @@
|
||||||
|
/*
|
||||||
|
* The MIT License (MIT)
|
||||||
|
*
|
||||||
|
* Copyright (c) 2014-2016 Patrick Gansterer <paroga@paroga.com>
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function(global, undefined) { "use strict";
|
||||||
|
var POW_2_24 = 5.960464477539063e-8,
|
||||||
|
POW_2_32 = 4294967296,
|
||||||
|
POW_2_53 = 9007199254740992;
|
||||||
|
|
||||||
|
function encode(value) {
|
||||||
|
var data = new ArrayBuffer(256);
|
||||||
|
var dataView = new DataView(data);
|
||||||
|
var lastLength;
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
function prepareWrite(length) {
|
||||||
|
var newByteLength = data.byteLength;
|
||||||
|
var requiredLength = offset + length;
|
||||||
|
while (newByteLength < requiredLength)
|
||||||
|
newByteLength <<= 1;
|
||||||
|
if (newByteLength !== data.byteLength) {
|
||||||
|
var oldDataView = dataView;
|
||||||
|
data = new ArrayBuffer(newByteLength);
|
||||||
|
dataView = new DataView(data);
|
||||||
|
var uint32count = (offset + 3) >> 2;
|
||||||
|
for (var i = 0; i < uint32count; ++i)
|
||||||
|
dataView.setUint32(i << 2, oldDataView.getUint32(i << 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
lastLength = length;
|
||||||
|
return dataView;
|
||||||
|
}
|
||||||
|
function commitWrite() {
|
||||||
|
offset += lastLength;
|
||||||
|
}
|
||||||
|
function writeFloat64(value) {
|
||||||
|
commitWrite(prepareWrite(8).setFloat64(offset, value));
|
||||||
|
}
|
||||||
|
function writeUint8(value) {
|
||||||
|
commitWrite(prepareWrite(1).setUint8(offset, value));
|
||||||
|
}
|
||||||
|
function writeUint8Array(value) {
|
||||||
|
var dataView = prepareWrite(value.length);
|
||||||
|
for (var i = 0; i < value.length; ++i)
|
||||||
|
dataView.setUint8(offset + i, value[i]);
|
||||||
|
commitWrite();
|
||||||
|
}
|
||||||
|
function writeUint16(value) {
|
||||||
|
commitWrite(prepareWrite(2).setUint16(offset, value));
|
||||||
|
}
|
||||||
|
function writeUint32(value) {
|
||||||
|
commitWrite(prepareWrite(4).setUint32(offset, value));
|
||||||
|
}
|
||||||
|
function writeUint64(value) {
|
||||||
|
var low = value % POW_2_32;
|
||||||
|
var high = (value - low) / POW_2_32;
|
||||||
|
var dataView = prepareWrite(8);
|
||||||
|
dataView.setUint32(offset, high);
|
||||||
|
dataView.setUint32(offset + 4, low);
|
||||||
|
commitWrite();
|
||||||
|
}
|
||||||
|
function writeTypeAndLength(type, length) {
|
||||||
|
if (length < 24) {
|
||||||
|
writeUint8(type << 5 | length);
|
||||||
|
} else if (length < 0x100) {
|
||||||
|
writeUint8(type << 5 | 24);
|
||||||
|
writeUint8(length);
|
||||||
|
} else if (length < 0x10000) {
|
||||||
|
writeUint8(type << 5 | 25);
|
||||||
|
writeUint16(length);
|
||||||
|
} else if (length < 0x100000000) {
|
||||||
|
writeUint8(type << 5 | 26);
|
||||||
|
writeUint32(length);
|
||||||
|
} else {
|
||||||
|
writeUint8(type << 5 | 27);
|
||||||
|
writeUint64(length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeItem(value) {
|
||||||
|
var i;
|
||||||
|
|
||||||
|
if (value === false)
|
||||||
|
return writeUint8(0xf4);
|
||||||
|
if (value === true)
|
||||||
|
return writeUint8(0xf5);
|
||||||
|
if (value === null)
|
||||||
|
return writeUint8(0xf6);
|
||||||
|
if (value === undefined)
|
||||||
|
return writeUint8(0xf7);
|
||||||
|
|
||||||
|
switch (typeof value) {
|
||||||
|
case "number":
|
||||||
|
if (Math.floor(value) === value) {
|
||||||
|
if (0 <= value && value <= POW_2_53)
|
||||||
|
return writeTypeAndLength(0, value);
|
||||||
|
if (-POW_2_53 <= value && value < 0)
|
||||||
|
return writeTypeAndLength(1, -(value + 1));
|
||||||
|
}
|
||||||
|
writeUint8(0xfb);
|
||||||
|
return writeFloat64(value);
|
||||||
|
|
||||||
|
case "string":
|
||||||
|
var utf8data = [];
|
||||||
|
for (i = 0; i < value.length; ++i) {
|
||||||
|
var charCode = value.charCodeAt(i);
|
||||||
|
if (charCode < 0x80) {
|
||||||
|
utf8data.push(charCode);
|
||||||
|
} else if (charCode < 0x800) {
|
||||||
|
utf8data.push(0xc0 | charCode >> 6);
|
||||||
|
utf8data.push(0x80 | charCode & 0x3f);
|
||||||
|
} else if (charCode < 0xd800) {
|
||||||
|
utf8data.push(0xe0 | charCode >> 12);
|
||||||
|
utf8data.push(0x80 | (charCode >> 6) & 0x3f);
|
||||||
|
utf8data.push(0x80 | charCode & 0x3f);
|
||||||
|
} else {
|
||||||
|
charCode = (charCode & 0x3ff) << 10;
|
||||||
|
charCode |= value.charCodeAt(++i) & 0x3ff;
|
||||||
|
charCode += 0x10000;
|
||||||
|
|
||||||
|
utf8data.push(0xf0 | charCode >> 18);
|
||||||
|
utf8data.push(0x80 | (charCode >> 12) & 0x3f);
|
||||||
|
utf8data.push(0x80 | (charCode >> 6) & 0x3f);
|
||||||
|
utf8data.push(0x80 | charCode & 0x3f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeTypeAndLength(3, utf8data.length);
|
||||||
|
return writeUint8Array(utf8data);
|
||||||
|
|
||||||
|
default:
|
||||||
|
var length;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
length = value.length;
|
||||||
|
writeTypeAndLength(4, length);
|
||||||
|
for (i = 0; i < length; ++i)
|
||||||
|
encodeItem(value[i]);
|
||||||
|
} else if (value instanceof Uint8Array) {
|
||||||
|
writeTypeAndLength(2, value.length);
|
||||||
|
writeUint8Array(value);
|
||||||
|
} else {
|
||||||
|
var keys = Object.keys(value);
|
||||||
|
length = keys.length;
|
||||||
|
writeTypeAndLength(5, length);
|
||||||
|
for (i = 0; i < length; ++i) {
|
||||||
|
var key = keys[i];
|
||||||
|
encodeItem(key);
|
||||||
|
encodeItem(value[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encodeItem(value);
|
||||||
|
|
||||||
|
if ("slice" in data)
|
||||||
|
return data.slice(0, offset);
|
||||||
|
|
||||||
|
var ret = new ArrayBuffer(offset);
|
||||||
|
var retView = new DataView(ret);
|
||||||
|
for (var i = 0; i < offset; ++i)
|
||||||
|
retView.setUint8(i, dataView.getUint8(i));
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(data, tagger, simpleValue) {
|
||||||
|
var dataView = new DataView(data);
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
if (typeof tagger !== "function")
|
||||||
|
tagger = function(value) { return value; };
|
||||||
|
if (typeof simpleValue !== "function")
|
||||||
|
simpleValue = function() { return undefined; };
|
||||||
|
|
||||||
|
function commitRead(length, value) {
|
||||||
|
offset += length;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function readArrayBuffer(length) {
|
||||||
|
return commitRead(length, new Uint8Array(data, offset, length));
|
||||||
|
}
|
||||||
|
function readFloat16() {
|
||||||
|
var tempArrayBuffer = new ArrayBuffer(4);
|
||||||
|
var tempDataView = new DataView(tempArrayBuffer);
|
||||||
|
var value = readUint16();
|
||||||
|
|
||||||
|
var sign = value & 0x8000;
|
||||||
|
var exponent = value & 0x7c00;
|
||||||
|
var fraction = value & 0x03ff;
|
||||||
|
|
||||||
|
if (exponent === 0x7c00)
|
||||||
|
exponent = 0xff << 10;
|
||||||
|
else if (exponent !== 0)
|
||||||
|
exponent += (127 - 15) << 10;
|
||||||
|
else if (fraction !== 0)
|
||||||
|
return (sign ? -1 : 1) * fraction * POW_2_24;
|
||||||
|
|
||||||
|
tempDataView.setUint32(0, sign << 16 | exponent << 13 | fraction << 13);
|
||||||
|
return tempDataView.getFloat32(0);
|
||||||
|
}
|
||||||
|
function readFloat32() {
|
||||||
|
return commitRead(4, dataView.getFloat32(offset));
|
||||||
|
}
|
||||||
|
function readFloat64() {
|
||||||
|
return commitRead(8, dataView.getFloat64(offset));
|
||||||
|
}
|
||||||
|
function readUint8() {
|
||||||
|
return commitRead(1, dataView.getUint8(offset));
|
||||||
|
}
|
||||||
|
function readUint16() {
|
||||||
|
return commitRead(2, dataView.getUint16(offset));
|
||||||
|
}
|
||||||
|
function readUint32() {
|
||||||
|
return commitRead(4, dataView.getUint32(offset));
|
||||||
|
}
|
||||||
|
function readUint64() {
|
||||||
|
return readUint32() * POW_2_32 + readUint32();
|
||||||
|
}
|
||||||
|
function readBreak() {
|
||||||
|
if (dataView.getUint8(offset) !== 0xff)
|
||||||
|
return false;
|
||||||
|
offset += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function readLength(additionalInformation) {
|
||||||
|
if (additionalInformation < 24)
|
||||||
|
return additionalInformation;
|
||||||
|
if (additionalInformation === 24)
|
||||||
|
return readUint8();
|
||||||
|
if (additionalInformation === 25)
|
||||||
|
return readUint16();
|
||||||
|
if (additionalInformation === 26)
|
||||||
|
return readUint32();
|
||||||
|
if (additionalInformation === 27)
|
||||||
|
return readUint64();
|
||||||
|
if (additionalInformation === 31)
|
||||||
|
return -1;
|
||||||
|
throw "Invalid length encoding";
|
||||||
|
}
|
||||||
|
function readIndefiniteStringLength(majorType) {
|
||||||
|
var initialByte = readUint8();
|
||||||
|
if (initialByte === 0xff)
|
||||||
|
return -1;
|
||||||
|
var length = readLength(initialByte & 0x1f);
|
||||||
|
if (length < 0 || (initialByte >> 5) !== majorType)
|
||||||
|
throw "Invalid indefinite length element";
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendUtf16Data(utf16data, length) {
|
||||||
|
for (var i = 0; i < length; ++i) {
|
||||||
|
var value = readUint8();
|
||||||
|
if (value & 0x80) {
|
||||||
|
if (value < 0xe0) {
|
||||||
|
value = (value & 0x1f) << 6
|
||||||
|
| (readUint8() & 0x3f);
|
||||||
|
length -= 1;
|
||||||
|
} else if (value < 0xf0) {
|
||||||
|
value = (value & 0x0f) << 12
|
||||||
|
| (readUint8() & 0x3f) << 6
|
||||||
|
| (readUint8() & 0x3f);
|
||||||
|
length -= 2;
|
||||||
|
} else {
|
||||||
|
value = (value & 0x0f) << 18
|
||||||
|
| (readUint8() & 0x3f) << 12
|
||||||
|
| (readUint8() & 0x3f) << 6
|
||||||
|
| (readUint8() & 0x3f);
|
||||||
|
length -= 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 0x10000) {
|
||||||
|
utf16data.push(value);
|
||||||
|
} else {
|
||||||
|
value -= 0x10000;
|
||||||
|
utf16data.push(0xd800 | (value >> 10));
|
||||||
|
utf16data.push(0xdc00 | (value & 0x3ff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeItem() {
|
||||||
|
var initialByte = readUint8();
|
||||||
|
var majorType = initialByte >> 5;
|
||||||
|
var additionalInformation = initialByte & 0x1f;
|
||||||
|
var i;
|
||||||
|
var length;
|
||||||
|
|
||||||
|
if (majorType === 7) {
|
||||||
|
switch (additionalInformation) {
|
||||||
|
case 25:
|
||||||
|
return readFloat16();
|
||||||
|
case 26:
|
||||||
|
return readFloat32();
|
||||||
|
case 27:
|
||||||
|
return readFloat64();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
length = readLength(additionalInformation);
|
||||||
|
if (length < 0 && (majorType < 2 || 6 < majorType))
|
||||||
|
throw "Invalid length";
|
||||||
|
|
||||||
|
switch (majorType) {
|
||||||
|
case 0:
|
||||||
|
return length;
|
||||||
|
case 1:
|
||||||
|
return -1 - length;
|
||||||
|
case 2:
|
||||||
|
if (length < 0) {
|
||||||
|
var elements = [];
|
||||||
|
var fullArrayLength = 0;
|
||||||
|
while ((length = readIndefiniteStringLength(majorType)) >= 0) {
|
||||||
|
fullArrayLength += length;
|
||||||
|
elements.push(readArrayBuffer(length));
|
||||||
|
}
|
||||||
|
var fullArray = new Uint8Array(fullArrayLength);
|
||||||
|
var fullArrayOffset = 0;
|
||||||
|
for (i = 0; i < elements.length; ++i) {
|
||||||
|
fullArray.set(elements[i], fullArrayOffset);
|
||||||
|
fullArrayOffset += elements[i].length;
|
||||||
|
}
|
||||||
|
return fullArray;
|
||||||
|
}
|
||||||
|
return readArrayBuffer(length);
|
||||||
|
case 3:
|
||||||
|
var utf16data = [];
|
||||||
|
if (length < 0) {
|
||||||
|
while ((length = readIndefiniteStringLength(majorType)) >= 0)
|
||||||
|
appendUtf16Data(utf16data, length);
|
||||||
|
} else
|
||||||
|
appendUtf16Data(utf16data, length);
|
||||||
|
return String.fromCharCode.apply(null, utf16data);
|
||||||
|
case 4:
|
||||||
|
var retArray;
|
||||||
|
if (length < 0) {
|
||||||
|
retArray = [];
|
||||||
|
while (!readBreak())
|
||||||
|
retArray.push(decodeItem());
|
||||||
|
} else {
|
||||||
|
retArray = new Array(length);
|
||||||
|
for (i = 0; i < length; ++i)
|
||||||
|
retArray[i] = decodeItem();
|
||||||
|
}
|
||||||
|
return retArray;
|
||||||
|
case 5:
|
||||||
|
var retObject = {};
|
||||||
|
for (i = 0; i < length || length < 0 && !readBreak(); ++i) {
|
||||||
|
var key = decodeItem();
|
||||||
|
retObject[key] = decodeItem();
|
||||||
|
}
|
||||||
|
return retObject;
|
||||||
|
case 6:
|
||||||
|
return tagger(decodeItem(), length);
|
||||||
|
case 7:
|
||||||
|
switch (length) {
|
||||||
|
case 20:
|
||||||
|
return false;
|
||||||
|
case 21:
|
||||||
|
return true;
|
||||||
|
case 22:
|
||||||
|
return null;
|
||||||
|
case 23:
|
||||||
|
return undefined;
|
||||||
|
default:
|
||||||
|
return simpleValue(length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ret = decodeItem();
|
||||||
|
if (offset !== data.byteLength)
|
||||||
|
throw "Remaining bytes";
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj = { encode: encode, decode: decode };
|
||||||
|
|
||||||
|
if (typeof define === "function" && define.amd)
|
||||||
|
define("cbor/cbor", obj);
|
||||||
|
else if (typeof module !== "undefined" && module.exports)
|
||||||
|
module.exports = obj;
|
||||||
|
else if (!global.CBOR)
|
||||||
|
global.CBOR = obj;
|
||||||
|
|
||||||
|
})(this);
|
|
@ -14,37 +14,74 @@
|
||||||
<h1>Buckler</h1>
|
<h1>Buckler</h1>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<div>
|
<section>
|
||||||
Available sites
|
Available sites
|
||||||
<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>
|
||||||
</div>
|
</section>
|
||||||
{% if request['session']['admin'] %}
|
{% if request['session']['admin'] %}
|
||||||
<table id="users">
|
<section>
|
||||||
<thead>
|
<table id="users">
|
||||||
<tr>
|
<thead>
|
||||||
<th>User</th>
|
<tr>
|
||||||
{% for app in apps %}
|
<th>User</th>
|
||||||
<th>{{ app }}</th>
|
{% for app in apps %}
|
||||||
|
<th>{{ app }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for username, values in users.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ username }}</td>
|
||||||
|
{% for value in values %}
|
||||||
|
<td><input type="checkbox" onchange="perm_change(this.parentElement.parentElement)"{% if value %} checked{% endif %}></td>
|
||||||
|
{% endfor %}
|
||||||
|
<td><input type="submit"></td>
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tbody>
|
||||||
</thead>
|
</table>
|
||||||
<tbody>
|
</section>
|
||||||
{% for username, values in users.items() %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ username }}</td>
|
|
||||||
{% for value in values %}
|
|
||||||
<td><input type="checkbox" onchange="perm_change(this.parentElement.parentElement)"{% if value %} checked{% endif %}></td>
|
|
||||||
{% endfor %}
|
|
||||||
<td><input type="submit"></td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<section>
|
||||||
|
<form action="{{ request.app.router['change_password'].url_for() }}" method="post" enctype="application/x-www-form-urlencoded">
|
||||||
|
<label for="current_password">Current password</label>
|
||||||
|
<input id="current_password" name="current_password" type="password"><br>
|
||||||
|
<label for="new_password">New password</label>
|
||||||
|
<input id="new_password" name="new_password" type="password"><br>
|
||||||
|
<label for="verify_password">Verify password</label>
|
||||||
|
<input id="verify_password" name="verify_password" type="password"><br>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
{% if fido2_keys %}
|
||||||
|
<table id="security_keys">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nick</th>
|
||||||
|
<th>Delete</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key in fido2_keys %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ key['nick'] }}</td>
|
||||||
|
<td><input type="checkbox"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<span>No registered keys.</span>
|
||||||
|
{% endif %}
|
||||||
|
<br><a href="./add_key">Add key</a>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
21
templates/login_key.html
Normal file
21
templates/login_key.html
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Buckler - Login</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/buckler.css">
|
||||||
|
<script type="text/javascript" src="/static/cbor.js"></script>
|
||||||
|
<script type="text/javascript" src="/static/buckler-auth.js"></script>
|
||||||
|
<script>const url_prefix = '{{ url_prefix }}';</script>
|
||||||
|
<script>window.onload = login;</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Buckler Login</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<span>Operate your authenticator device now.</span>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
22
templates/register_key.html
Normal file
22
templates/register_key.html
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Buckler - Register Security Key</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/buckler.css">
|
||||||
|
<script type="text/javascript" src="/static/cbor.js"></script>
|
||||||
|
<script type="text/javascript" src="/static/buckler-auth.js"></script>
|
||||||
|
<script>const url_prefix = '{{ url_prefix }}';</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div id="devices">
|
||||||
|
<div class="device">
|
||||||
|
<h1>Register Security Key</h1>
|
||||||
|
<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><input type="button" value="Submit" onclick="register()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in New Issue
Block a user