diff --git a/api.py b/api.py index 6523557a..8f0a27bc 100644 --- a/api.py +++ b/api.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 -__copyright__ = 'Copyright (c) 2021-2022, Utrecht University' +__copyright__ = 'Copyright (c) 2021-2023, Utrecht University' __license__ = 'GPLv3, see LICENSE' +import base64 import json import sys +import zlib from timeit import default_timer as timer from typing import Any, Dict, Optional @@ -52,7 +54,7 @@ def break_strings(N: int, m: int) -> int: return (N - 1) // m + 1 def nrep_string_expr(s: str, m: int = 64) -> str: - return ' ++\n'.join('"{}"'.format(escape_quotes(s[i * m:i * m + m])) for i in range(break_strings(len(s), m) + 1)) + return '++\n'.join('"{}"'.format(escape_quotes(s[i * m:i * m + m])) for i in range(break_strings(len(s), m) + 1)) if app.config.get('LOG_API_CALL_DURATION', False): begintime = timer() @@ -61,7 +63,11 @@ def nrep_string_expr(s: str, m: int = 64) -> str: data = {} params = json.dumps(data) - arg_str_expr = nrep_string_expr(params) + + # Compress params and encode as base64 to reduce size (max rule length in iRODS is 20KB) + compressed_params = zlib.compress(params.encode()) + base64_encoded_params = base64.b64encode(compressed_params) + arg_str_expr = nrep_string_expr(base64_encoded_params.decode('utf-8')) # Set parameters as variable instead of parameter input to circumvent iRODS string limits. rule_body = ''' *x={} diff --git a/search/static/search/js/revision.js b/search/static/search/js/revision.js index d8fd5f29..e8a60aee 100644 --- a/search/static/search/js/revision.js +++ b/search/static/search/js/revision.js @@ -7,59 +7,79 @@ var revisionTargetColl = '', folderSelectBrowser = null, dlgCurrentFolder = '', - currentSearchArg = '', + currentSearchStringRev = null, + currentSearchTypeRev = null, mainTable = null; const browsePageItems = 10; $( document ).ready(function() { // Click on file browser -> open revision details - startBrowsing(browsePageItems); + if ($('#file-browser').length && $('#search-filter').val().length > 0) { + currentSearchStringRev = $('#search-filter').val() + currentSearchTypeRev = $('#search_concept').attr('data-type') - $('#file-browser tbody').on('click', 'tr', function () { + if (currentSearchTypeRev === 'revision') { + browseRevisions() + } + } + + // Click on file browser -> open revision details + $(document).on("click","#file-browser tbody tr",function() { clickFileForRevisionDetails($(this), mainTable); }); + $('#search-panel li').on('click', function () { + currentSearchStringRev = $('#search-filter').val() + currentSearchTypeRev = $('#search_concept').attr('data-type') + browseRevisions() + }) + $('.search-btn').on('click', function() { + currentSearchStringRev = $('#search-filter').val() + currentSearchTypeRev = $('#search_concept').attr('data-type') browseRevisions(); }); $("#search-filter").bind('keypress', function(e) { if (e.keyCode==13) { + currentSearchStringRev = $('#search-filter').val() + currentSearchTypeRev = $('#search_concept').attr('data-type') browseRevisions(); } }); }); /// MAIN TABLE containing revisioned files -function startBrowsing(items) { - var mainTable = $('#file-browser').DataTable({ - "bFilter": false, - "bInfo": false, - "bLengthChange": true, - "language": { - "emptyTable": "No accessible files/folders present", - "lengthMenu": "_MENU_" +function browseRevisions() +{ if (currentSearchTypeRev === 'revision') { + // Destroy current Datatable. + const datatable = $('#file-browser').DataTable() + datatable.destroy() + + $('#file-browser').DataTable({ + bFilter: false, + bInfo: false, + bLengthChange: true, + language: { + emptyTable: 'Your search did not match any documents', + lengthMenu: '_MENU_' }, - "dom": '<"top">frt<"bottom"lp><"clear">', - 'columns': [{render: tableRenderer.name, orderable: false, data: 'main_original_dataname'}, - {render: tableRenderer.count, orderable: false, data: 'revision_count'} + dom: '<"top">rt<"bottom"lp><"clear">', + columns: [ + { render: tableRenderer.name, orderable: false, data: 'main_original_dataname' }, + { render: tableRenderer.count, orderable: false, data: 'revision_count' } ], - "ajax": getRevisionListContents, - "processing": true, - "serverSide": true, - "iDeferLoading": 0, - "ordering": false, - "pageLength": items - }); - browseRevisions(); -} - -function browseRevisions() -{ currentSearchArg = $("#search-filter").val(); - - let fileBrowser = $('#file-browser').DataTable(); - fileBrowser.ajax.reload(); + ajax: getRevisionListContents, + processing: true, + serverSide: true, + ordering: false, + pageLength: 10 + }) + $('#file-browser').on('length.dt', function (e, settings, len) { + Yoda.storage.session.set('pageLength', len) + }) + } } // Fetches directory contents to populate the listing table. @@ -88,7 +108,7 @@ let getRevisionListContents = (() => { let get = async (args) => { // Check if we can use the cache. if (cache.length - && currentSearchArg === cacheSearchArg /// DIT MOET SEARCH ARGUMENT WORDEN!!! + && currentSearchStringRev === cacheSearchArg /// DIT MOET SEARCH ARGUMENT WORDEN!!! //&& args.order[0].dir === cacheSortOrder //&& args.order[0].column === cacheSortCol && args.start >= cacheStart @@ -100,7 +120,7 @@ let getRevisionListContents = (() => { let j = ++i; let result = await Yoda.call('revisions_search_on_filename', - {'searchString': currentSearchArg, /// TOEVOEGEN SEARCH ARGUMENT + {'searchString': currentSearchStringRev, /// TOEVOEGEN SEARCH ARGUMENT 'offset': args.start, 'limit': batchSize}); @@ -111,7 +131,7 @@ let getRevisionListContents = (() => { total = result.total; cacheStart = args.start; cache = result.items; - cacheSearchArg = currentSearchArg; + cacheSearchArg = currentSearchStringRev; //cacheSortCol = args.order[0].column; //cacheSortOrder = args.order[0].dir; diff --git a/user/user.py b/user/user.py index 88b76eb5..3c1a90db 100644 --- a/user/user.py +++ b/user/user.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -__copyright__ = 'Copyright (c) 2021-2022, Utrecht University' +__copyright__ = 'Copyright (c) 2021-2023, Utrecht University' __license__ = 'GPLv3, see LICENSE' import json +import secrets from typing import List import jwt @@ -205,6 +206,9 @@ def userinfo_request(token: str) -> requests.Response: return response + class StateMismatchError(Exception): + pass + class UserinfoSubMismatchError(Exception): pass @@ -216,6 +220,13 @@ class UserinfoEmailMismatchError(Exception): exception_occurred = "OPENID_ERROR" # To identify exception in finally-clause try: + email = g.login_username.lower() + + # Ensure that the request is not a forgery and that the user sending + # this connect request is the expected user. + if request.args['state'] != session.get('state'): + raise StateMismatchError + token_response = token_request() js = token_response.json() access_token = js['access_token'] @@ -245,8 +256,6 @@ class UserinfoEmailMismatchError(Exception): # Check if login email matches with user info email. email_identifier = app.config.get('OIDC_EMAIL_FIELD') - email = g.login_username.lower() - userinfo_email = userinfo_payload[email_identifier] if not isinstance(userinfo_email, list): userinfo_email = [userinfo_email] @@ -307,6 +316,10 @@ class UserinfoEmailMismatchError(Exception): True ) + except StateMismatchError: + # Invalid state parameter. + log_error("Invalid state parameter") + except UserinfoSubMismatchError: # Possible Token substitution attack. log_error( @@ -320,13 +333,20 @@ class UserinfoEmailMismatchError(Exception): f'Mismatch between email and user info email: {email} is not in {userinfo_email}', True ) + exception_occurred = "USERINFO_EMAIL_MISMATCH_ERROR" except Exception: log_error(f"Unexpected exception during callback for username {email}", True) finally: if exception_occurred == "CAT_INVALID_USER_ERROR" or exception_occurred == "CAT_INVALID_AUTHENTICATION": - flash('Username/password was incorrect', 'danger') + flash('Username / password was incorrect', 'danger') + elif exception_occurred == "USERINFO_EMAIL_MISMATCH_ERROR": + flash( + 'Unable to sign in. Please verify that your username has been entered correctly. ' + 'If your username has been entered correctly and this issue persists, please contact the system administrator', + 'danger' + ) elif exception_occurred == "OPENID_ERROR": flash( 'An error occurred during the OpenID Connect protocol. ' @@ -357,6 +377,11 @@ def should_redirect_to_oidc(username: str) -> bool: def oidc_authorize_url(username: str) -> str: authorize_url: str = app.config.get('OIDC_AUTH_URI') + # Generate a random string for the state parameter. + # https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1 + session['state'] = secrets.token_urlsafe(32) + authorize_url += '&state=' + session['state'] + if app.config.get('OIDC_LOGIN_HINT') and username: authorize_url += '&login_hint=' + username @@ -414,11 +439,25 @@ def prepare_user() -> None: g.user = user_id g.irods = irods - # Check for notifications. - endpoints = ["static", "call", "upload_get", "upload_post"] - if request.endpoint is not None and not request.endpoint.endswith(tuple(endpoints)): - response = api.call('notifications_load', data={}) - g.notifications = len(response['data']) + try: + # Check for notifications. + endpoints = ["static", "call", "upload_get", "upload_post"] + if request.endpoint is not None and not request.endpoint.endswith(tuple(endpoints)): + response = api.call('notifications_load', data={}) + g.notifications = len(response['data']) + except PAM_AUTH_PASSWORD_FAILED: + # Password is not valid any more (probably OIDC access token). + connman.clean(session.sid) + session.clear() + + session['login_username'] = login_username + + # If the username matches the domain set for OIDC + if should_redirect_to_oidc(login_username): + return redirect(oidc_authorize_url(login_username)) + # Else (i.e. it is an external user, local user, or OIDC is disabled) + else: + return redirect(url_for('user_bp.login')) else: redirect('user_bp.login')