diff --git a/migrations/versions/a8c6b6e91c28_device_login.py b/migrations/versions/a8c6b6e91c28_device_login.py new file mode 100644 index 0000000000000000000000000000000000000000..8d594b4afc7903cc09abcf1c30cabe69264f3ca2 --- /dev/null +++ b/migrations/versions/a8c6b6e91c28_device_login.py @@ -0,0 +1,45 @@ +"""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') diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py index b77a09925ed7702325c91c4b3e0a6ed694cb14e5..13d8b87ed8ac993a7730e11f8c39d8756c8fba3e 100644 --- a/tests/test_oauth2.py +++ b/tests/test_oauth2.py @@ -1,13 +1,14 @@ 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) diff --git a/tests/test_session.py b/tests/test_session.py index 0882b08e16d895cc474e951488a312ba88c74e14..74acf489ba6f83998909ec1fbf136e9f9c44b4c1 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -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 diff --git a/uffd/oauth2/models.py b/uffd/oauth2/models.py index 162ac321f33fd9a9c68ce60047bf6de7d151c586..c56ba0bd9c430bc7cd26e2caf4f4d9d2d38466b1 100644 --- a/uffd/oauth2/models.py +++ b/uffd/oauth2/models.py @@ -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 diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py index 12e62febde73d9f22b56a0b2620b0222535fffee..30957deb14556cc99ee87977ba784515c8ebaf27 100644 --- a/uffd/oauth2/views.py +++ b/uffd/oauth2/views.py @@ -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 diff --git a/uffd/session/models.py b/uffd/session/models.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d515395f66f815b94e216c39893ea00f5b24e433 100644 --- a/uffd/session/models.py +++ b/uffd/session/models.py @@ -0,0 +1,142 @@ +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 diff --git a/uffd/session/templates/session/deviceauth.html b/uffd/session/templates/session/deviceauth.html new file mode 100644 index 0000000000000000000000000000000000000000..09eb76f2501bc7e16f52385c244eedc21e12ace6 --- /dev/null +++ b/uffd/session/templates/session/deviceauth.html @@ -0,0 +1,67 @@ +{% 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 %} diff --git a/uffd/session/templates/session/devicelogin.html b/uffd/session/templates/session/devicelogin.html new file mode 100644 index 0000000000000000000000000000000000000000..4bd92b15971675db671bab48b860246a2cc53401 --- /dev/null +++ b/uffd/session/templates/session/devicelogin.html @@ -0,0 +1,39 @@ +{% 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 %} diff --git a/uffd/session/templates/session/login.html b/uffd/session/templates/session/login.html index 15c0f8f03e402965ae5ba497e1973f236a47ed37..54ee477d10ccfa997180f5796ea81690c8526d2a 100644 --- a/uffd/session/templates/session/login.html +++ b/uffd/session/templates/session/login.html @@ -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> diff --git a/uffd/session/views.py b/uffd/session/views.py index 2c57bc6df93c5ffae966ee219759c5a6c3cdd932..0c93ef727c82da4051bfdd5cf198bd213aab3032 100644 --- a/uffd/session/views.py +++ b/uffd/session/views.py @@ -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: + 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')) diff --git a/uffd/templates/base.html b/uffd/templates/base.html index 807af010be4f73c6d3ad2a8e38449c1cbbb4b6a8..4fd7ce50aa7018d6a759f910237031fb3b56296c 100644 --- a/uffd/templates/base.html +++ b/uffd/templates/base.html @@ -69,6 +69,12 @@ </ul> {% if request.user %} <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"> <a class="nav-link" href="{{ url_for("session.logout") }}"> <span aria-hidden="true" class="fa fa-sign-out-alt"></span>