|
| 1 | +#!/usr/bin/python |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | +# |
| 4 | +# --- BEGIN_HEADER --- |
| 5 | +# |
| 6 | +# accountaction - handle account actions like change pw and renew access |
| 7 | +# Copyright (C) 2003-2025 The MiG Project by the Science HPC Center at UCPH |
| 8 | +# |
| 9 | +# This file is part of MiG. |
| 10 | +# |
| 11 | +# MiG is free software: you can redistribute it and/or modify |
| 12 | +# it under the terms of the GNU General Public License as published by |
| 13 | +# the Free Software Foundation; either version 2 of the License, or |
| 14 | +# (at your option) any later version. |
| 15 | +# |
| 16 | +# MiG is distributed in the hope that it will be useful, |
| 17 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 18 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 19 | +# GNU General Public License for more details. |
| 20 | +# |
| 21 | +# You should have received a copy of the GNU General Public License |
| 22 | +# along with this program; if not, write to the Free Software |
| 23 | +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
| 24 | +# |
| 25 | +# -- END_HEADER --- |
| 26 | +# |
| 27 | + |
| 28 | +"""Account actions backend e.g. for account renewal.""" |
| 29 | + |
| 30 | +from __future__ import absolute_import |
| 31 | + |
| 32 | +import os |
| 33 | +import time |
| 34 | + |
| 35 | +from mig.shared import returnvalues |
| 36 | +from mig.shared.base import is_gdp_user |
| 37 | +from mig.shared.defaults import keyword_auto, AUTH_MIG_CERT, AUTH_MIG_OID, \ |
| 38 | + AUTH_MIG_OIDC |
| 39 | +from mig.shared.functional import validate_input, REJECT_UNSET |
| 40 | +from mig.shared.gdp.all import ensure_gdp_user |
| 41 | +from mig.shared.griddaemons.https import default_user_abuse_hits, \ |
| 42 | + default_proto_abuse_hits, hit_rate_limit, expire_rate_limit, \ |
| 43 | + validate_auth_attempt |
| 44 | +from mig.shared.handlers import safe_handler, get_csrf_limit |
| 45 | +from mig.shared.htmlgen import themed_styles, themed_scripts |
| 46 | +from mig.shared.httpsclient import detect_client_auth, find_auth_type_and_label |
| 47 | +from mig.shared.init import initialize_main_variables |
| 48 | +from mig.shared.userdb import default_db_path |
| 49 | +from mig.shared.useradm import default_search, search_users, create_user |
| 50 | + |
| 51 | +SUPPORTED_ACTIONS = ["RENEW_ACCESS", ] |
| 52 | + |
| 53 | + |
| 54 | +def allow_renew_access(configuration, client_id, user_dict, auth_flavor): |
| 55 | + """Helper to check prerequisites for the RENEW_ACCESS requests.""" |
| 56 | + _logger = configuration.logger |
| 57 | + allow_renew, renew_err = False, 'Not fully implemented, yet' |
| 58 | + if auth_flavor in (AUTH_MIG_CERT, AUTH_MIG_OID, AUTH_MIG_OIDC): |
| 59 | + _logger.debug("Account renew for %r is allowed to proceed" % client_id) |
| 60 | + allow_renew, renew_err = True, "" |
| 61 | + else: |
| 62 | + _logger.warning("Account renew for %r with %s auth unsupported" % |
| 63 | + (client_id, auth_flavor)) |
| 64 | + renew_err = "Account access renew refused - invalid auth flavor!" |
| 65 | + return (allow_renew, renew_err) |
| 66 | + |
| 67 | + |
| 68 | +def renew_access(configuration, client_id, user_dict, auth_flavor): |
| 69 | + """Helper to actually renew access for the RENEW_ACCESS requests.""" |
| 70 | + _logger = configuration.logger |
| 71 | + renew_status, renew_err = False, 'Not fully implemented yet' |
| 72 | + peer_pattern = keyword_auto |
| 73 | + db_path = default_db_path(configuration) |
| 74 | + old_expire = user_dict.get('expire', -1) |
| 75 | + if auth_flavor == AUTH_MIG_CERT: |
| 76 | + extend_days = configuration.cert_valid_days |
| 77 | + elif auth_flavor == AUTH_MIG_OID: |
| 78 | + extend_days = configuration.oid_valid_days |
| 79 | + elif auth_flavor == AUTH_MIG_OIDC: |
| 80 | + extend_days = configuration.oidc_valid_days |
| 81 | + else: |
| 82 | + extend_days = configuration.generic_valid_days |
| 83 | + max_extend_secs = extend_days * 24 * 3600 |
| 84 | + new_expire = max(old_expire, time.time() + max_extend_secs) |
| 85 | + user_dict['expire'] = new_expire |
| 86 | + try: |
| 87 | + _logger.info("Renew %(distinguished_name)r with expire at %(expire)d" |
| 88 | + % user_dict) |
| 89 | + updated = create_user(user_dict, configuration, db_path, |
| 90 | + ask_renew=False, default_renew=True, |
| 91 | + verify_peer=peer_pattern, auto_create_db=False) |
| 92 | + if configuration.site_enable_gdp: |
| 93 | + (gdp_success, msg) = ensure_gdp_user(configuration, |
| 94 | + "127.0.0.1", |
| 95 | + user_dict['distinguished_name']) |
| 96 | + if not gdp_success: |
| 97 | + raise Exception("Failed to renew GDP user: %s" % msg) |
| 98 | + renewed_expire = updated.get('expire', -1) |
| 99 | + _logger.info("Renewed %(distinguished_name)r to expire at %(expire)d" % |
| 100 | + updated) |
| 101 | + if renewed_expire > old_expire: |
| 102 | + renew_status, renew_err = True, "" |
| 103 | + else: |
| 104 | + renew_err = "Renew could not extend account expire value (no peer?)" |
| 105 | + except Exception as exc: |
| 106 | + _logger.warning("Error renewing user %r: %s" % (client_id, exc)) |
| 107 | + |
| 108 | + # TODO: send email on renew? |
| 109 | + return (renew_status, renew_err) |
| 110 | + |
| 111 | + |
| 112 | +def signature(configuration): |
| 113 | + """Signature of the main function""" |
| 114 | + |
| 115 | + defaults = { |
| 116 | + 'action': REJECT_UNSET, |
| 117 | + } |
| 118 | + return ['text', defaults] |
| 119 | + |
| 120 | + |
| 121 | +def main(client_id, user_arguments_dict, environ=None): |
| 122 | + """Main function used by front end""" |
| 123 | + |
| 124 | + if environ is None: |
| 125 | + environ = os.environ |
| 126 | + (configuration, logger, output_objects, op_name) = \ |
| 127 | + initialize_main_variables(client_id, op_header=False, op_title=False, |
| 128 | + op_menu=False) |
| 129 | + # IMPORTANT: no title in init above so we MUST call it immediately here |
| 130 | + # or basic styling will break on e.g. the check user result. |
| 131 | + styles = themed_styles(configuration) |
| 132 | + scripts = themed_scripts(configuration, logged_in=False) |
| 133 | + title_entry = {'object_type': 'title', |
| 134 | + 'text': '%s account action' % |
| 135 | + configuration.short_title, |
| 136 | + 'skipmenu': True, 'style': styles, 'script': scripts} |
| 137 | + output_objects.append(title_entry) |
| 138 | + output_objects.append({'object_type': 'header', 'text': |
| 139 | + '%s account action' % |
| 140 | + configuration.short_title |
| 141 | + }) |
| 142 | + |
| 143 | + defaults = signature(configuration)[1] |
| 144 | + (validate_status, accepted) = validate_input(user_arguments_dict, |
| 145 | + defaults, output_objects, |
| 146 | + allow_rejects=False) |
| 147 | + if not validate_status: |
| 148 | + # NOTE: 'accepted' is a non-sensitive error string here |
| 149 | + logger.warning('%s invalid input: %s' % (op_name, accepted)) |
| 150 | + return (accepted, returnvalues.CLIENT_ERROR) |
| 151 | + |
| 152 | + action = accepted['action'][-1].strip() |
| 153 | + |
| 154 | + # Seconds to delay next attempt after hitting rate limit |
| 155 | + if action == "RENEW_ACCESS": |
| 156 | + delay_retry = 3600 |
| 157 | + else: |
| 158 | + delay_retry = 300 |
| 159 | + scripts['init'] += ''' |
| 160 | +function update_reload_counter(cnt, delay) { |
| 161 | + var remain = (delay - cnt); |
| 162 | + $("#reload_counter").html(remain.toString()); |
| 163 | + if (cnt >= delay) { |
| 164 | + /* Load previous page again without re-posting last attempt */ |
| 165 | + location = history.back(); |
| 166 | + } else { |
| 167 | + setTimeout(function() { update_reload_counter(cnt+1, delay); }, 1000); |
| 168 | + } |
| 169 | +} |
| 170 | + ''' |
| 171 | + |
| 172 | + if not safe_handler(configuration, 'post', op_name, client_id, |
| 173 | + get_csrf_limit(configuration), accepted): |
| 174 | + output_objects.append( |
| 175 | + {'object_type': 'error_text', 'text': '''Only accepting |
| 176 | +CSRF-filtered POST requests to prevent unintended updates''' |
| 177 | + }) |
| 178 | + return (output_objects, returnvalues.CLIENT_ERROR) |
| 179 | + |
| 180 | + if action not in SUPPORTED_ACTIONS: |
| 181 | + output_objects.append({'object_type': 'error_text', 'text': |
| 182 | + 'Unsupported account action: %r' % action}) |
| 183 | + output_objects.append( |
| 184 | + {'object_type': 'link', 'destination': 'javascript:history.back();', |
| 185 | + 'class': 'genericbutton', 'text': "Try again"}) |
| 186 | + return (output_objects, returnvalues.CLIENT_ERROR) |
| 187 | + |
| 188 | + (auth_type_name, auth_flavor) = detect_client_auth(configuration, environ) |
| 189 | + (auth_type, auth_label) = find_auth_type_and_label(configuration, |
| 190 | + auth_type_name, |
| 191 | + auth_flavor) |
| 192 | + if auth_type not in configuration.site_login_methods: |
| 193 | + output_objects.append({'object_type': 'error_text', 'text': |
| 194 | + 'Site does not support %r authentication' % |
| 195 | + auth_type_name}) |
| 196 | + output_objects.append( |
| 197 | + {'object_type': 'link', 'destination': 'javascript:history.back();', |
| 198 | + 'class': 'genericbutton', 'text': "Try again"}) |
| 199 | + return (output_objects, returnvalues.CLIENT_ERROR) |
| 200 | + |
| 201 | + logger.info('got %s account %s request from %r' % (auth_type_name, action, |
| 202 | + client_id)) |
| 203 | + |
| 204 | + client_addr = os.environ.get('REMOTE_ADDR', None) |
| 205 | + tcp_port = int(os.environ.get('REMOTE_PORT', '0')) |
| 206 | + |
| 207 | + status = returnvalues.OK |
| 208 | + |
| 209 | + # Rate account action attempts for any client_id from source addr to prevent |
| 210 | + # excessive requests spamming users or overloading server. |
| 211 | + # We do so no matter if client_id matches a valid user to prevent disclosure. |
| 212 | + # Rate limit does not affect action for another ID from same address as |
| 213 | + # that may be perfectly valid e.g. if behind a shared NAT-gateway. |
| 214 | + proto = 'https' |
| 215 | + disconnect, exceeded_rate_limit = False, False |
| 216 | + # Clean up expired entries in persistent rate limit cache |
| 217 | + expire_rate_limit(configuration, proto, fail_cache=delay_retry, |
| 218 | + expire_delay=delay_retry) |
| 219 | + if hit_rate_limit(configuration, proto, client_addr, client_id, |
| 220 | + max_user_hits=1): |
| 221 | + exceeded_rate_limit = True |
| 222 | + # Update rate limits and write to auth log |
| 223 | + (authorized, disconnect) = validate_auth_attempt( |
| 224 | + configuration, |
| 225 | + proto, |
| 226 | + "accountupdate", |
| 227 | + client_id, |
| 228 | + client_addr, |
| 229 | + tcp_port, |
| 230 | + secret='', |
| 231 | + authtype_enabled=True, |
| 232 | + modify_account=True, |
| 233 | + exceeded_rate_limit=exceeded_rate_limit, |
| 234 | + user_abuse_hits=default_user_abuse_hits, |
| 235 | + proto_abuse_hits=default_proto_abuse_hits, |
| 236 | + max_secret_hits=1, |
| 237 | + skip_notify=True, |
| 238 | + ) |
| 239 | + |
| 240 | + if exceeded_rate_limit or disconnect: |
| 241 | + logger.warning('Throttle %s for %s from %s - past rate limit' % |
| 242 | + (op_name, client_id, client_addr)) |
| 243 | + # NOTE: we keep actual result in plain text for json extract |
| 244 | + output_objects.append({'object_type': 'html_form', 'text': ''' |
| 245 | +<div class="vertical-spacer"></div> |
| 246 | +<div class="error leftpad errortext"> |
| 247 | +'''}) |
| 248 | + output_objects.append({'object_type': 'text', 'text': """ |
| 249 | +Invalid input or rate limit exceeded - please wait %d seconds before retrying. |
| 250 | +""" % delay_retry |
| 251 | + }) |
| 252 | + output_objects.append({'object_type': 'html_form', 'text': ''' |
| 253 | +</div> |
| 254 | +<div class="vertical-spacer"></div> |
| 255 | +<div class="info leftpad"> |
| 256 | +Origin will reload automatically in <span id="reload_counter">%d</span> seconds. |
| 257 | +</div> |
| 258 | +</div> |
| 259 | +''' % delay_retry}) |
| 260 | + scripts['ready'] += ''' |
| 261 | + setTimeout(function() { update_reload_counter(1, %d); }, 1000); |
| 262 | +''' % delay_retry |
| 263 | + return (output_objects, status) |
| 264 | + |
| 265 | + search_filter = default_search() |
| 266 | + search_filter['distinguished_name'] = client_id |
| 267 | + (_, hits) = search_users(search_filter, configuration, keyword_auto, False) |
| 268 | + # Filter out any gdp project users |
| 269 | + hits = [i for i in hits if not is_gdp_user(configuration, i[0])] |
| 270 | + if len(hits) != 1: |
| 271 | + logger.warning("%d local users unexpectedly matched %r" % (len(hits), |
| 272 | + client_id)) |
| 273 | + output_objects.append({ |
| 274 | + 'object_type': 'error_text', 'text': |
| 275 | + """Account action failed with internal error. Please report to |
| 276 | +support if the problem persists. |
| 277 | + """}) |
| 278 | + status = returnvalues.SYSTEM_ERROR |
| 279 | + return (output_objects, status) |
| 280 | + |
| 281 | + logger.debug('handle %s account %s request from %r' % (auth_type_name, |
| 282 | + action, client_id)) |
| 283 | + (uid, user_dict) = hits[0] |
| 284 | + if action == "RENEW_ACCESS": |
| 285 | + allowed, err = allow_renew_access(configuration, client_id, |
| 286 | + user_dict, auth_flavor) |
| 287 | + if not allowed: |
| 288 | + output_objects.append( |
| 289 | + {'object_type': 'error_text', 'text': |
| 290 | + 'Refused account access renew: %s' % err}) |
| 291 | + output_objects.append( |
| 292 | + {'object_type': 'link', 'destination': |
| 293 | + 'javascript:history.back();', |
| 294 | + 'class': 'genericbutton', 'text': "Try again"}) |
| 295 | + return (output_objects, returnvalues.CLIENT_ERROR) |
| 296 | + |
| 297 | + renewed, err = renew_access(configuration, client_id, user_dict, |
| 298 | + auth_flavor) |
| 299 | + if not renewed: |
| 300 | + output_objects.append( |
| 301 | + {'object_type': 'error_text', 'text': |
| 302 | + 'Failed to renew account access: %s' % err}) |
| 303 | + output_objects.append( |
| 304 | + {'object_type': 'link', 'destination': |
| 305 | + 'javascript:history.back();', |
| 306 | + 'class': 'genericbutton', 'text': "Try again"}) |
| 307 | + return (output_objects, returnvalues.CLIENT_ERROR) |
| 308 | + |
| 309 | + output_objects.append( |
| 310 | + {'object_type': 'text', 'text': |
| 311 | + 'Your account access was successfully renewed.'}) |
| 312 | + else: |
| 313 | + output_objects.append( |
| 314 | + {'object_type': 'error_text', 'text': |
| 315 | + 'Unsupported account action: %s' % action}) |
| 316 | + output_objects.append( |
| 317 | + {'object_type': 'link', 'destination': |
| 318 | + 'javascript:history.back();', |
| 319 | + 'class': 'genericbutton', 'text': "Try again"}) |
| 320 | + return (output_objects, returnvalues.CLIENT_ERROR) |
| 321 | + |
| 322 | + logger.debug("Account %s %s completed" % (auth_type_name, action)) |
| 323 | + output_objects.append( |
| 324 | + {'object_type': 'link', 'destination': 'account.py', |
| 325 | + 'class': 'genericbutton', 'text': "Show Account Info"}) |
| 326 | + return (output_objects, status) |
0 commit comments