Commit 2bb08aba authored by Julian's avatar Julian
Browse files

Implemented device login for OAuth2 authorizations

parent 6cacdf54
Pipeline #7008 passed with stage
in 3 minutes and 11 seconds
"""device login
Revision ID: a8c6b6e91c28
Revises: bad6fc529510
Create Date: 2021-07-19 14:37:02.559667
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a8c6b6e91c28'
down_revision = 'bad6fc529510'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('device_login_initiation',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('type', sa.Enum('OAUTH2', name='devicelogintype'), nullable=False),
sa.Column('code0', sa.String(length=32), nullable=False),
sa.Column('code1', sa.String(length=32), nullable=False),
sa.Column('secret', sa.String(length=128), nullable=False),
sa.Column('created', sa.DateTime(), nullable=False),
sa.Column('oauth2_client_id', sa.String(length=40), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_device_login_initiation')),
sa.UniqueConstraint('code0', name=op.f('uq_device_login_initiation_code0')),
sa.UniqueConstraint('code1', name=op.f('uq_device_login_initiation_code1'))
)
op.create_table('device_login_confirmation',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('initiation_id', sa.Integer(), nullable=False),
sa.Column('user_dn', sa.String(length=128), nullable=False),
sa.Column('code0', sa.String(length=32), nullable=False),
sa.Column('code1', sa.String(length=32), nullable=False),
sa.ForeignKeyConstraint(['initiation_id'], ['device_login_initiation.id'], name=op.f('fk_device_login_confirmation_initiation_id_device_login_initiation')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_device_login_confirmation')),
sa.UniqueConstraint('initiation_id', 'code0', name=op.f('uq_device_login_confirmation_initiation_id_code0')),
sa.UniqueConstraint('initiation_id', 'code1', name=op.f('uq_device_login_confirmation_initiation_id_code1')),
sa.UniqueConstraint('user_dn', name=op.f('uq_device_login_confirmation_user_dn'))
)
def downgrade():
op.drop_table('device_login_confirmation')
op.drop_table('device_login_initiation')
import datetime
from urllib.parse import urlparse, parse_qs
from flask import url_for
from flask import url_for, session
# These imports are required, because otherwise we get circular imports?!
from uffd import ldap, user
from uffd.user.models import User
from uffd.oauth2.models import OAuth2Client
from uffd.session.models import DeviceLoginConfirmation
from uffd.oauth2.models import OAuth2Client, OAuth2DeviceLoginInitiation
from uffd import create_app, db, ldap
from utils import dump, UffdTestCase
......@@ -52,11 +53,7 @@ class TestViews(UffdTestCase):
'test1': {'client_secret': 'testsecret1', 'redirect_uris': ['http://localhost:5008/callback'], 'required_group': 'uffd_admin'},
}
def test_authorization(self):
self.client.post(path=url_for('session.login'),
data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=True)
state = 'teststate'
r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state=state, redirect_uri='http://localhost:5009/callback'), follow_redirects=False)
def assert_authorization(self, r):
while True:
if r.status_code != 302 or r.location.startswith('http://localhost:5009/callback'):
break
......@@ -64,7 +61,7 @@ class TestViews(UffdTestCase):
self.assertEqual(r.status_code, 302)
self.assertTrue(r.location.startswith('http://localhost:5009/callback'))
args = parse_qs(urlparse(r.location).query)
self.assertEqual(args['state'], [state])
self.assertEqual(args['state'], ['teststate'])
code = args['code'][0]
r = self.client.post(path=url_for('oauth2.token'),
data={'grant_type': 'authorization_code', 'code': code, 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test', 'client_secret': 'testsecret'}, follow_redirects=True)
......@@ -83,5 +80,33 @@ class TestViews(UffdTestCase):
self.assertEqual(r.json['email'], user.mail)
self.assertTrue(r.json.get('groups'))
class TestViewsOL(TestViews):
use_openldap = True
def test_authorization(self):
self.client.post(path=url_for('session.login'),
data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=True)
r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback'), follow_redirects=False)
self.assert_authorization(r)
def test_authorization_devicelogin_start(self):
ref = url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback')
r = self.client.get(path=url_for('session.devicelogin_start', ref=ref), follow_redirects=True)
# check response
initiation = OAuth2DeviceLoginInitiation.query.filter_by(id=session['devicelogin_id'], secret=session['devicelogin_secret']).one()
self.assertEqual(r.status_code, 200)
self.assertFalse(initiation.expired)
self.assertEqual(initiation.oauth2_client_id, 'test')
self.assertIsNotNone(initiation.description)
def test_authorization_devicelogin_auth(self):
with self.client.session_transaction() as _session:
initiation = OAuth2DeviceLoginInitiation(oauth2_client_id='test')
db.session.add(initiation)
confirmation = DeviceLoginConfirmation(initiation=initiation, user=get_user())
db.session.add(confirmation)
db.session.commit()
_session['devicelogin_id'] = initiation.id
_session['devicelogin_secret'] = initiation.secret
code = confirmation.code
self.client.get(path='/')
ref = url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback')
r = self.client.post(path=url_for('session.devicelogin_submit', ref=ref), data={'confirmation-code': code}, follow_redirects=False)
self.assert_authorization(r)
......@@ -7,6 +7,8 @@ from flask import url_for, request
from uffd import ldap, user
from uffd.session.views import login_required
from uffd.session.models import DeviceLoginConfirmation
from uffd.oauth2.models import OAuth2DeviceLoginInitiation
from uffd import create_app, db
from utils import dump, UffdTestCase
......@@ -133,6 +135,33 @@ class TestSession(UffdTestCase):
self.assertEqual(r.status_code, 200)
self.assertIsNone(request.user)
def test_deviceauth(self):
self.app.config['OAUTH2_CLIENTS'] = {
'test': {'client_secret': 'testsecret', 'redirect_uris': ['http://localhost:5009/callback', 'http://localhost:5009/callback2']},
}
initiation = OAuth2DeviceLoginInitiation(oauth2_client_id='test')
db.session.add(initiation)
db.session.commit()
code = initiation.code
self.login()
r = self.client.get(path=url_for('session.deviceauth'), follow_redirects=True)
dump('deviceauth', r)
self.assertEqual(r.status_code, 200)
r = self.client.get(path=url_for('session.deviceauth', **{'initiation-code': code}), follow_redirects=True)
dump('deviceauth_check', r)
self.assertEqual(r.status_code, 200)
self.assertIn(b'test', r.data)
r = self.client.post(path=url_for('session.deviceauth_submit'), data={'initiation-code': code}, follow_redirects=True)
dump('deviceauth_submit', r)
self.assertEqual(r.status_code, 200)
initiation = OAuth2DeviceLoginInitiation.query.filter_by(code=code).one()
self.assertEqual(len(initiation.confirmations), 1)
self.assertEqual(initiation.confirmations[0].user.loginname, 'testuser')
self.assertIn(initiation.confirmations[0].code.encode(), r.data)
r = self.client.get(path=url_for('session.deviceauth_finish'), follow_redirects=True)
self.assertEqual(r.status_code, 200)
self.assertEqual(DeviceLoginConfirmation.query.all(), [])
class TestSessionOL(TestSession):
use_openldap = True
......
......@@ -4,6 +4,7 @@ from ldapalchemy.dbutils import DBRelationship
from uffd.database import db
from uffd.user.models import User
from uffd.session.models import DeviceLoginInitiation, DeviceLoginType
class OAuth2Client:
def __init__(self, client_id, client_secret, redirect_uris, required_group=None, logout_urls=None):
......@@ -99,3 +100,17 @@ class OAuth2Token(db.Model):
db.session.delete(self)
db.session.commit()
return self
class OAuth2DeviceLoginInitiation(DeviceLoginInitiation):
__mapper_args__ = {
'polymorphic_identity': DeviceLoginType.OAUTH2
}
oauth2_client_id = Column(String(40))
@property
def oauth2_client(self):
return OAuth2Client.from_id(self.oauth2_client_id)
@property
def description(self):
return self.oauth2_client.client_id
......@@ -2,13 +2,14 @@ import datetime
import functools
import urllib.parse
from flask import Blueprint, request, jsonify, render_template, session, redirect
from flask import Blueprint, request, jsonify, render_template, session, redirect, url_for, flash
from flask_oauthlib.provider import OAuth2Provider
from sqlalchemy.exc import IntegrityError
from uffd.ratelimit import host_ratelimit, format_delay
from uffd.database import db
from uffd.session.views import login_required
from .models import OAuth2Client, OAuth2Grant, OAuth2Token
from uffd.session.models import DeviceLoginConfirmation
from .models import OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation
oauth = OAuth2Provider()
......@@ -23,7 +24,7 @@ def load_grant(client_id, code):
@oauth.grantsetter
def save_grant(client_id, code, oauthreq, *args, **kwargs): # pylint: disable=unused-argument
expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=100)
grant = OAuth2Grant(user_dn=request.user.dn, client_id=client_id,
grant = OAuth2Grant(user_dn=request.oauth2_user.dn, client_id=client_id,
code=code['code'], redirect_uri=oauthreq.redirect_uri, expires=expires, _scopes=' '.join(oauthreq.scopes))
db.session.add(grant)
db.session.commit()
......@@ -78,18 +79,52 @@ def inject_scope(func):
return decorator
@bp.route('/authorize', methods=['GET', 'POST'])
@login_required()
@inject_scope
@oauth.authorize_handler
def authorize(*args, **kwargs): # pylint: disable=unused-argument
client = kwargs['request'].client
request.oauth2_user = None
if request.user:
request.oauth2_user = request.user
elif 'devicelogin_started' in session:
del session['devicelogin_started']
host_delay = host_ratelimit.get_delay()
if host_delay:
flash('We received too many requests from your ip address/network! Please wait at least %s.'%format_delay(host_delay))
return redirect(url_for('session.login', ref=request.url, devicelogin=True))
host_ratelimit.log()
initiation = OAuth2DeviceLoginInitiation(oauth2_client_id=client.client_id)
db.session.add(initiation)
try:
db.session.commit()
except IntegrityError:
flash('Device login is currently not available. Try again later!')
return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
session['devicelogin_id'] = initiation.id
session['devicelogin_secret'] = initiation.secret
return redirect(url_for('session.devicelogin', ref=request.url))
elif 'devicelogin_id' in session and 'devicelogin_secret' in session and 'devicelogin_confirmation' in session:
initiation = OAuth2DeviceLoginInitiation.query.filter_by(id=session['devicelogin_id'], secret=session['devicelogin_secret'],
oauth2_client_id=client.client_id).one_or_none()
confirmation = DeviceLoginConfirmation.query.get(session['devicelogin_confirmation'])
del session['devicelogin_id']
del session['devicelogin_secret']
del session['devicelogin_confirmation']
if not initiation or initiation.expired or not confirmation:
flash('Device login failed')
return redirect(url_for('session.login', ref=request.url, devicelogin=True))
request.oauth2_user = confirmation.user
db.session.delete(initiation)
db.session.commit()
else:
return redirect(url_for('session.login', ref=request.url, devicelogin=True))
# Here we would normally ask the user, if he wants to give the requesting
# service access to his data. Since we only have trusted services (the
# clients defined in the server config), we don't ask for consent.
client = kwargs['request'].client
session['oauth2-clients'] = session.get('oauth2-clients', [])
if client.client_id not in session['oauth2-clients']:
session['oauth2-clients'].append(client.client_id)
return client.access_allowed(request.user)
return client.access_allowed(request.oauth2_user)
@bp.route('/token', methods=['GET', 'POST'])
@oauth.token_handler
......
import datetime
import secrets
import math
import enum
from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.ext.hybrid import hybrid_property
from ldapalchemy.dbutils import DBRelationship
from uffd.database import db
from uffd.user.models import User
def token_typeable(nbytes=None):
'''Return random text token that is easy to type (on mobile)'''
alphabet = '123456789abcdefghkmnopqrstuvwx' # No '0ijlyz'
if nbytes is None:
nbytes = secrets.DEFAULT_ENTROPY
nbytes_per_char = math.log(len(alphabet), 256)
nchars = math.ceil(nbytes / nbytes_per_char)
return ''.join([secrets.choice(alphabet) for _ in range(nchars)])
# Device login provides a convenient and secure way to log into SSO-enabled
# services on a secondary device without entering the user password or
# completing 2FA challenges.
#
# Use-cases:
# * A user wants to log into a single OAuth2-enabled web service on his
# mobile phone without trusting the device enough to expose his full
# credentials.
# * A user wants to log into an OAuth2-enabled web service on a secondary
# device at a busy event location with too little privacy to securly enter
# his credentials but already has a login session on his laptop.
# * A user wants to log into an OAuth2-enabled service via the web browser
# on a native mobile app on his phone and cannot use his 2FA method on that
# device (e.g. FIDO2 token with USB-A) or in the app's web view.
# The mechanism uses two random codes: When the user attempts to authenticate
# with an SSO-enabled service and chooses the "Device Login" option on the SSO
# login page, the SSO generates and displays an initiation code. That code is
# securly bound to the browser session that is used to request it. The user
# logs into the SSO on another device using his credentials and 2FA methods and
# opens a page to authorize the device login attempt. There he enteres the
# initiation code. The SSO displays the details of the device login attempt
# (i.e. the name of the service to log into). Once the user authorizes the
# login attempt, the SSO generates a confirmation code and displays it to the
# user. The user enters the confirmation code on the device he wants to log
# in with and proceeds with the authentication.
#
# An attacker might
# * generate initiation codes,
# * observe the displayed/entered initiation code,
# * observe the displayed/entered confirmation code and
# * possibly divert the victims attention and provoke typing errors.
#
# An attacker must not be able to
# * authenticate with an SSO-enabled service as another user or
# * trick a user to authenticate with an SSO-enabled service as the attacker.
#
# An example for the second case would be the Nextcloud mobile app: The app
# integrates closely with the phone's OS and provides features like
# auto-upload of photos, contacts and more. If the app would authenticate
# with an attacker-controlled account, the attacker would have access to
# this data.
class DeviceLoginType(enum.Enum):
OAUTH2 = 0
class DeviceLoginInitiation(db.Model):
'''Abstract initiation code class
An initiation code is generated and displayed when a user chooses
"Device Login" on the login page. Instances are always bound to a
specific service, e.g. a client id in case of OAuth2.
The code attribute is formed out of two indepentently unique parts
to ensure that at any time all existing codes differ in at least two
characters (i.e. mistyping one character can not result in another
existing and possibly attacker-controlled code).
An initiation code is securly bound to the session that it was created
with by storing both id and secret in the encrypted and authenticated
session cookie.'''
__tablename__ = 'device_login_initiation'
id = Column(Integer(), primary_key=True, autoincrement=True)
type = Column(Enum(DeviceLoginType), nullable=False)
code0 = Column(String(32), unique=True, nullable=False, default=lambda: token_typeable(3))
code1 = Column(String(32), unique=True, nullable=False, default=lambda: token_typeable(3))
secret = Column(String(128), nullable=False, default=lambda: secrets.token_hex(64))
confirmations = relationship('DeviceLoginConfirmation', back_populates='initiation', cascade='all, delete-orphan')
created = Column(DateTime, default=datetime.datetime.now, nullable=False)
__mapper_args__ = {
'polymorphic_on': type,
}
@hybrid_property
def code(self):
# Split into two parts, each unique, to ensure that every code differs
# in more than one character from other existing codes.
return self.code0 + self.code1
@hybrid_property
def expired(self):
return self.created < datetime.datetime.now() - datetime.timedelta(minutes=30)
@property
def description(self):
raise NotImplementedError()
class DeviceLoginConfirmation(db.Model):
'''Confirmation code class
A confirmation code is generated and displayed when an authenticated user
enters an initiation code and confirms the device login attempt. Every
instance is bound to both an initiation code and a user.
The code attribute is formed out of two indepentently unique parts
to ensure that at any time all existing codes differ in at least two
characters (i.e. mistyping one character can not result in another
existing and possibly attacker-controlled code).'''
__tablename__ = 'device_login_confirmation'
id = Column(Integer(), primary_key=True, autoincrement=True)
initiation_id = Column(Integer(), ForeignKey('device_login_initiation.id'), nullable=False)
initiation = relationship('DeviceLoginInitiation', back_populates='confirmations')
user_dn = Column(String(128), nullable=False, unique=True)
user = DBRelationship('user_dn', User)
code0 = Column(String(32), nullable=False, default=lambda: token_typeable(1))
code1 = Column(String(32), nullable=False, default=lambda: token_typeable(1))
__table_args__ = (
db.UniqueConstraint('initiation_id', 'code0', name='uq_device_login_confirmation_initiation_id_code0'),
db.UniqueConstraint('initiation_id', 'code1', name='uq_device_login_confirmation_initiation_id_code1'),
)
@hybrid_property
def code(self):
# Split into two parts, each unique, to ensure that every code differs
# in more than one character from other existing codes.
return self.code0 + self.code1
{% extends 'base.html' %}
{% block body %}
{% if not initiation %}
<form action="{{ url_for("session.deviceauth") }}">
{% elif not confirmation %}
<form action="{{ url_for("session.deviceauth_submit") }}" method="POST">
{% else %}
<form action="{{ url_for("session.deviceauth_finish") }}" method="POST">
{% endif %}
<div class="row mt-2 justify-content-center">
<div class="col-lg-6 col-md-10" style="background: #f7f7f7; box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3); padding: 30px;">
<div class="text-center">
<img alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" >
</div>
<div class="col-12">
<h2 class="text-center">Authorize Device Login</h2>
</div>
<div class="form-group col-12">
<p>Log into a service on another device without entering your password.</p>
</div>
<div class="form-group col-12">
<label for="initiation-code">Initiation Code</label>
{% if not initiation %}
<input type="text" class="form-control" id="initiation-code" name="initiation-code" value="{{ initiation_code or '' }}" required="required" tabindex = "1" autofocus>
{% else %}
<input type="text" class="form-control" id="initiation-code" name="initiation-code" value="{{ initiation.code }}" readonly>
{% endif %}
</div>
{% if confirmation %}
<div class="form-group col-12">
<label for="confirmation-code">Confirmation Code</label>
<input type="text" class="form-control" id="confirmation-code" name="confirmation-code" value="{{ confirmation.code }}" readonly>
</div>
{% endif %}
{% if not initiation %}
<div class="form-group col-12">
<p>Start logging into a service on the other device and chose "Device Login" on the login page. Enter the displayed initiation code in the box above.</p>
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block" tabindex = "2">Continue</button>
</div>
<div class="form-group col-12">
<a href="{{ url_for('index') }}" class="btn btn-secondary btn-block" tabindex="0">Cancel</a>
</div>
{% elif not confirmation %}
<div class="form-group col-12">
<p>Authorize the login for service <b>{{ initiation.description }}</b>?</p>
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block" tabindex = "2">Authorize Login</button>
</div>
<div class="form-group col-12">
<a href="{{ url_for('index') }}" class="btn btn-secondary btn-block" tabindex="0">Cancel</a>
</div>
{% else %}
<div class="form-group col-12">
<p>Enter the confirmation code on the other device and complete the login. Click <em>Finish</em> afterwards.</p>
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block" tabindex = "2">Finish</button>
</div>
{% endif %}
</div>
</div>
</form>
{% endblock %}
{% extends 'base.html' %}
{% block body %}
<form action="{{ url_for("session.devicelogin_submit", ref=ref) }}" method="POST">
<div class="row mt-2 justify-content-center">
<div class="col-lg-6 col-md-10" style="background: #f7f7f7; box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3); padding: 30px;">
<div class="text-center">
<img alt="branding logo" src="{{ config.get("BRANDING_LOGO_URL") }}" class="col-lg-8 col-md-12" >
</div>
<div class="col-12">
<h2 class="text-center">Device Login</h2>
</div>
<div class="form-group col-12">
<p>Use a login session on another device (e.g. your laptop) to log into a service without entering your password.</p>
</div>
{% if initiation %}
<div class="form-group col-12">
<label for="initiation-code">Initiation Code</label>
<input type="text" class="form-control" id="initiation-code" name="initiation-code" value="{{ initiation.code }}" readonly>
</div>
<input type="hidden" class="form-control" id="initiation-secret" name="initiation-secret" value="{{ initiation.secret }}">
<div class="form-group col-12">
<label for="confirmation-code">Confirmation Code</label>
<input type="text" class="form-control" id="confirmation-code" name="confirmation-code" required="required" tabindex = "1" autofocus>
</div>
<div class="form-group col-12">
<p>Open <code><a href="{{ url_for('session.deviceauth') }}">{{ url_for('session.deviceauth', _external=True) }}</a></code> on the other device and enter the initiation code there. Then enter the confirmation code in the box above.</p>
</div>
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block" tabindex = "3">Continue</button>
</div>
{% endif %}
<div class="form-group col-12">
<a href="{{ url_for('session.login', ref=ref, devicelogin=True) }}" class="btn btn-secondary btn-block" tabindex="0">Cancel</a>
</div>
</div>
</div>
</form>
{% endblock %}
......@@ -21,6 +21,12 @@
<div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block" tabindex = "3">Login</button>
</div>
{% if request.values.get('devicelogin') %}
<div class="text-center text-muted mb-3">- or -</div>
<div class="form-group col-12">
<a href="{{ url_for('session.devicelogin_start', ref=ref) }}" class="btn btn-primary btn-block" tabindex="0">Device Login</a>
</div>
{% endif %}
<div class="clearfix col-12">
{% if config['SELF_SIGNUP'] %}
<a href="{{ url_for("signup.signup_start") }}" class="float-left">Register</a>
......
......@@ -4,9 +4,12 @@ import functools
from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, session, abort
from uffd.database import db
from uffd.csrf import csrf_protect
from uffd.user.models import User
from uffd.ldap import ldap, test_user_bind, LDAPInvalidDnError, LDAPBindError, LDAPPasswordIsMandatoryError
from uffd.ratelimit import Ratelimit, host_ratelimit, format_delay
from uffd.session.models import DeviceLoginInitiation, DeviceLoginConfirmation
bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/')
......@@ -127,3 +130,65 @@ def login_required(group=None):
return func(*args, **kwargs)
return decorator
return wrapper
@bp.route("/login/device/start")
def devicelogin_start():
session['devicelogin_started'] = True
return redirect(request.values['ref'])
@bp.route("/login/device")
def devicelogin():
if 'devicelogin_id' not in session or 'devicelogin_secret' not in session:
return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
initiation = DeviceLoginInitiation.query.filter_by(id=session['devicelogin_id'], secret=session['devicelogin_secret']).one_or_none()
if not initiation or initiation.expired:
flash('Initiation code is no longer valid')
return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
return render_template('session/devicelogin.html', ref=request.values.get('ref'), initiation=initiation)
@bp.route("/login/device", methods=['POST'])
def devicelogin_submit():
if 'devicelogin_id' not in session or 'devicelogin_secret' not in session:
return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
initiation = DeviceLoginInitiation.query.filter_by(id=session['devicelogin_id'], secret=session['devicelogin_secret']).one_or_none()
if not initiation or initiation.expired:
flash('Initiation code is no longer valid')
return redirect(url_for('session.login', ref=request.values['ref'], devicelogin=True))
confirmation = DeviceLoginConfirmation.query.filter_by(initiation=initiation, code=request.form['confirmation-code']).one_or_none()
if confirmation is None:
flash('Invalid confirmation code')
return render_template('session/devicelogin.html', ref=request.values.get('ref'), initiation=initiation)
session['devicelogin_confirmation'] = confirmation.id
return redirect(request.values['ref'])
@bp.route("/device")
@login_required()
def deviceauth():
if 'initiation-code' not in request.values: