Skip to content
Snippets Groups Projects
Commit 2bb08aba authored by Julian's avatar Julian
Browse files

Implemented device login for OAuth2 authorizations

parent 6cacdf54
Branches
Tags
No related merge requests found
"""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 import datetime
from urllib.parse import urlparse, parse_qs 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?! # These imports are required, because otherwise we get circular imports?!
from uffd import ldap, user from uffd import ldap, user
from uffd.user.models import 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 uffd import create_app, db, ldap
from utils import dump, UffdTestCase from utils import dump, UffdTestCase
...@@ -52,11 +53,7 @@ class TestViews(UffdTestCase): ...@@ -52,11 +53,7 @@ class TestViews(UffdTestCase):
'test1': {'client_secret': 'testsecret1', 'redirect_uris': ['http://localhost:5008/callback'], 'required_group': 'uffd_admin'}, 'test1': {'client_secret': 'testsecret1', 'redirect_uris': ['http://localhost:5008/callback'], 'required_group': 'uffd_admin'},
} }
def test_authorization(self): def assert_authorization(self, r):
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)
while True: while True:
if r.status_code != 302 or r.location.startswith('http://localhost:5009/callback'): if r.status_code != 302 or r.location.startswith('http://localhost:5009/callback'):
break break
...@@ -64,7 +61,7 @@ class TestViews(UffdTestCase): ...@@ -64,7 +61,7 @@ class TestViews(UffdTestCase):
self.assertEqual(r.status_code, 302) self.assertEqual(r.status_code, 302)
self.assertTrue(r.location.startswith('http://localhost:5009/callback')) self.assertTrue(r.location.startswith('http://localhost:5009/callback'))
args = parse_qs(urlparse(r.location).query) args = parse_qs(urlparse(r.location).query)
self.assertEqual(args['state'], [state]) self.assertEqual(args['state'], ['teststate'])
code = args['code'][0] code = args['code'][0]
r = self.client.post(path=url_for('oauth2.token'), 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) 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): ...@@ -83,5 +80,33 @@ class TestViews(UffdTestCase):
self.assertEqual(r.json['email'], user.mail) self.assertEqual(r.json['email'], user.mail)
self.assertTrue(r.json.get('groups')) self.assertTrue(r.json.get('groups'))
class TestViewsOL(TestViews): def test_authorization(self):
use_openldap = True 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 ...@@ -7,6 +7,8 @@ from flask import url_for, request
from uffd import ldap, user from uffd import ldap, user
from uffd.session.views import login_required 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 uffd import create_app, db
from utils import dump, UffdTestCase from utils import dump, UffdTestCase
...@@ -133,6 +135,33 @@ class TestSession(UffdTestCase): ...@@ -133,6 +135,33 @@ class TestSession(UffdTestCase):
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertIsNone(request.user) 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): class TestSessionOL(TestSession):
use_openldap = True use_openldap = True
......
...@@ -4,6 +4,7 @@ from ldapalchemy.dbutils import DBRelationship ...@@ -4,6 +4,7 @@ from ldapalchemy.dbutils import DBRelationship
from uffd.database import db from uffd.database import db
from uffd.user.models import User from uffd.user.models import User
from uffd.session.models import DeviceLoginInitiation, DeviceLoginType
class OAuth2Client: class OAuth2Client:
def __init__(self, client_id, client_secret, redirect_uris, required_group=None, logout_urls=None): def __init__(self, client_id, client_secret, redirect_uris, required_group=None, logout_urls=None):
...@@ -99,3 +100,17 @@ class OAuth2Token(db.Model): ...@@ -99,3 +100,17 @@ class OAuth2Token(db.Model):
db.session.delete(self) db.session.delete(self)
db.session.commit() db.session.commit()
return self 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 ...@@ -2,13 +2,14 @@ import datetime
import functools import functools
import urllib.parse 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 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.database import db
from uffd.session.views import login_required from uffd.session.models import DeviceLoginConfirmation
from .models import OAuth2Client, OAuth2Grant, OAuth2Token from .models import OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation
oauth = OAuth2Provider() oauth = OAuth2Provider()
...@@ -23,7 +24,7 @@ def load_grant(client_id, code): ...@@ -23,7 +24,7 @@ def load_grant(client_id, code):
@oauth.grantsetter @oauth.grantsetter
def save_grant(client_id, code, oauthreq, *args, **kwargs): # pylint: disable=unused-argument def save_grant(client_id, code, oauthreq, *args, **kwargs): # pylint: disable=unused-argument
expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=100) 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)) code=code['code'], redirect_uri=oauthreq.redirect_uri, expires=expires, _scopes=' '.join(oauthreq.scopes))
db.session.add(grant) db.session.add(grant)
db.session.commit() db.session.commit()
...@@ -78,18 +79,52 @@ def inject_scope(func): ...@@ -78,18 +79,52 @@ def inject_scope(func):
return decorator return decorator
@bp.route('/authorize', methods=['GET', 'POST']) @bp.route('/authorize', methods=['GET', 'POST'])
@login_required()
@inject_scope @inject_scope
@oauth.authorize_handler @oauth.authorize_handler
def authorize(*args, **kwargs): # pylint: disable=unused-argument 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 # 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 # service access to his data. Since we only have trusted services (the
# clients defined in the server config), we don't ask for consent. # clients defined in the server config), we don't ask for consent.
client = kwargs['request'].client
session['oauth2-clients'] = session.get('oauth2-clients', []) session['oauth2-clients'] = session.get('oauth2-clients', [])
if client.client_id not in session['oauth2-clients']: if client.client_id not in session['oauth2-clients']:
session['oauth2-clients'].append(client.client_id) 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']) @bp.route('/token', methods=['GET', 'POST'])
@oauth.token_handler @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 @@ ...@@ -21,6 +21,12 @@
<div class="form-group col-12"> <div class="form-group col-12">
<button type="submit" class="btn btn-primary btn-block" tabindex = "3">Login</button> <button type="submit" class="btn btn-primary btn-block" tabindex = "3">Login</button>
</div> </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"> <div class="clearfix col-12">
{% if config['SELF_SIGNUP'] %} {% if config['SELF_SIGNUP'] %}
<a href="{{ url_for("signup.signup_start") }}" class="float-left">Register</a> <a href="{{ url_for("signup.signup_start") }}" class="float-left">Register</a>
......
...@@ -4,9 +4,12 @@ import functools ...@@ -4,9 +4,12 @@ import functools
from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, session, abort 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.user.models import User
from uffd.ldap import ldap, test_user_bind, LDAPInvalidDnError, LDAPBindError, LDAPPasswordIsMandatoryError from uffd.ldap import ldap, test_user_bind, LDAPInvalidDnError, LDAPBindError, LDAPPasswordIsMandatoryError
from uffd.ratelimit import Ratelimit, host_ratelimit, format_delay 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='/') bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/')
...@@ -127,3 +130,65 @@ def login_required(group=None): ...@@ -127,3 +130,65 @@ def login_required(group=None):
return func(*args, **kwargs) return func(*args, **kwargs)
return decorator return decorator
return wrapper 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:
return render_template('session/deviceauth.html')
initiation = DeviceLoginInitiation.query.filter_by(code=request.values['initiation-code']).one_or_none()
if initiation is None or initiation.expired:
flash('Invalid initiation code')
return redirect(url_for('session.deviceauth'))
return render_template('session/deviceauth.html', initiation=initiation)
@bp.route("/device", methods=['POST'])
@login_required()
@csrf_protect(blueprint=bp)
def deviceauth_submit():
DeviceLoginConfirmation.query.filter_by(user_dn=request.user.dn).delete()
initiation = DeviceLoginInitiation.query.filter_by(code=request.form['initiation-code']).one_or_none()
if initiation is None or initiation.expired:
flash('Invalid initiation code')
return redirect(url_for('session.deviceauth'))
confirmation = DeviceLoginConfirmation(user=request.user, initiation=initiation)
db.session.add(confirmation)
db.session.commit()
return render_template('session/deviceauth.html', initiation=initiation, confirmation=confirmation)
@bp.route("/device/finish", methods=['GET', 'POST'])
@login_required()
def deviceauth_finish():
DeviceLoginConfirmation.query.filter_by(user_dn=request.user.dn).delete()
db.session.commit()
return redirect(url_for('index'))
...@@ -69,6 +69,12 @@ ...@@ -69,6 +69,12 @@
</ul> </ul>
{% if request.user %} {% if request.user %}
<ul class="navbar-nav ml-auto"> <ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for("session.deviceauth") }}">
<span aria-hidden="true" class="fas fa-mobile-alt" title="Authorize Device Login"></span>
<span class="d-inline d-md-none">Device Login</span>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for("session.logout") }}"> <a class="nav-link" href="{{ url_for("session.logout") }}">
<span aria-hidden="true" class="fa fa-sign-out-alt"></span> <span aria-hidden="true" class="fa fa-sign-out-alt"></span>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment