1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240
|
# -*- coding: utf-8 -*-
#
# Copyright (C) 2011,2012 Steffen Hoffmann <hoff.st@web.de>
# All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution.
#
# Author: Steffen Hoffmann <hoff.st@web.de>
from datetime import timedelta
import json
import time
from trac.config import IntOption, Option
from trac.core import Component
from trac.util.datefmt import format_datetime, pretty_timedelta
from trac.util.datefmt import to_datetime, to_timestamp
from .compat import unicode
from .model import (del_user_attribute, get_user_attribute, set_user_attribute,
user_known)
class AccountGuard(Component):
"""The AccountGuard component protects against brute-force attacks
on user passwords.
It does so by adding logging of failed login attempts,
account status tracking and administative user account locking.
Configurable time-locks with exponential lock time prolongation
allow to balance graceful handling of failed login attempts and
reasonable protection against attempted brute-force attacks.
"""
login_attempt_max_count = IntOption(
'account-manager', 'login_attempt_max_count', 0,
doc="""Lock user account after specified number of login attempts.
Value zero means no limit.""")
user_lock_time = IntOption(
'account-manager', 'user_lock_time', 0,
doc="""Drop user account lock after specified time (seconds).
Value zero means unlimited lock time.""")
user_lock_max_time = IntOption(
'account-manager', 'user_lock_max_time', 86400,
doc="""Limit user account lock time to specified time (seconds).
This is relevant only with user_lock_time_progression > 1.""")
user_lock_time_progression = Option(
'account-manager', 'user_lock_time_progression', '1',
doc="""Extend user account lock time incrementally. This is
based on logarithmic calculation and decimal numbers accepted:
Value '1' means constant lock time per failed login attempt.
Value '2' means double locktime after 2nd lock activation,
four times the initial locktime after 3rd, and so on.""")
def __init__(self):
# Adjust related values to promote a sane configuration, because the
# combination of some default values is not meaningful.
cfg = self.config
if self.login_attempt_max_count < 0:
cfg.set('account-manager', 'login_attempt_max_count', 0)
options = ['user_lock_time', 'user_lock_max_time',
'user_lock_time_progression']
for option in options:
cfg.remove('account-manager', option)
self.log.warning("AccountGuard disabled by option, obsoleting "
"other options.")
elif self.user_lock_max_time < 1:
cfg.set('account-manager', 'user_lock_max_time',
cfg.defaults().get(
'account-manager')['user_lock_max_time'])
self.log.warning("AccountGuard option fixed, please check your "
"configuration.")
else:
return
# Changes are intentionally not written to file for persistence.
# This could cause the environment to reload a bit too early, even
# interrupting a rewrite in progress by another thread and causing
# a DoS condition by truncating the configuration file.
def failed_count(self, user, ipnr=None, reset=False):
"""Report number of previously logged failed login attempts.
Enforce login policy with regards to tracking of login attempts
and user account lock behavior.
Default `False` for reset value causes logging of another attempt.
`None` value for reset just reads failed login attempts count.
`True` value for reset triggers final log deletion.
"""
if not user or not user_known(self.env, user):
return 0
key = 'failed_logins_count'
value = get_user_attribute(self.env, user, 1, key)
count = value and user in value and int(value[user][1].get(key)) or 0
if reset is None:
# Report failed attempts count only.
return count
if not reset:
# Trigger the failed attempt logger.
attempts = self.get_failed_log(user)
log_length = len(attempts)
if log_length > self.login_attempt_max_count:
# Truncate attempts list preserving most recent events.
del attempts[:(log_length - self.login_attempt_max_count)]
attempts.append({'ipnr': ipnr, 'time': int(time.time())})
count += 1
# Update or create attempts counter and list.
set_user_attribute(self.env, user, 'failed_logins',
unicode(attempts))
set_user_attribute(self.env, user, key, count)
self.log.debug("AccountGuard.failed_count(%s) = %s", user, count)
else:
# Delete existing attempts counter and list.
del_user_attribute(self.env, user, 1, 'failed_logins')
del_user_attribute(self.env, user, 1, key)
# Delete the lock count too.
self.lock_count(user, 'reset')
return count
def get_failed_log(self, user):
"""Returns an iterable of previously logged failed login attempts.
The iterable contains a list of dicts in the following form:
{'ipnr': ipnr, 'time': time_stamp} or an empty list.
The time stamp format depends on Trac support for POSIX seconds
(before 0.12) or POSIX microseconds in more recent Trac versions.
"""
if not user:
return []
attempts = get_user_attribute(self.env, user, 1, 'failed_logins')
return attempts and eval(attempts[user][1].get('failed_logins')) or []
def lock_count(self, user, action='get'):
"""Count, log and report, how often in succession user account
lock conditions have been met.
This is the exponent for lock time prolongation calculation too.
"""
key = 'lock_count'
if action != 'reset':
value = get_user_attribute(self.env, user, 1, key)
count = value and user in value and \
int(value[user][1].get(key)) or 0
if action != 'get':
# Push and create or update cached count.
count += 1
set_user_attribute(self.env, user, key, count)
else:
# Reset/delete lock count cache.
del_user_attribute(self.env, user, 1, key)
count = 0
return count
def lock_time(self, user, next=False):
"""Calculate current time-lock length for user account."""
base = self.lock_time_progression
lock_count = self.lock_count(user)
if not user or not user_known(self.env, user):
return 0
else:
if next:
# Preview calculation.
exponent = lock_count
else:
exponent = lock_count - 1
t_lock = self.user_lock_time * base ** exponent
# Limit maximum lock time.
if t_lock > self.user_lock_max_time:
t_lock = self.user_lock_max_time
self.log.debug("AccountGuard.lock_time(%s) = %s%s",
user, t_lock, next and ' (preview)' or '')
return t_lock
@property
def lock_time_progression(self):
try:
progression = float(self.config.get('account-manager',
'user_lock_time_progression'))
if progression == int(progression):
progression = int(progression)
# Prevent unintended decreasing lock time.
if progression < 1:
progression = 1
except (TypeError, ValueError):
progression = float(self.config.defaults().get('account-manager')
['user_lock_time_progression'])
return progression
def pretty_lock_time(self, user, next=False):
"""Convenience method for formatting lock time to string."""
t_lock = self.lock_time(user, next)
return (t_lock > 0) and \
(pretty_timedelta(to_datetime(None) -
timedelta(seconds=t_lock))) or None
def pretty_release_time(self, req, user):
"""Convenience method for formatting lock time to string."""
ts_release = self.release_time(user)
if ts_release is None:
return None
return format_datetime(to_datetime(
self.release_time(user)), tzinfo=req.tz)
def release_time(self, user):
if self.login_attempt_max_count > 0:
if self.user_lock_time == 0:
return 0
# Logged attempts required for further checking.
attempts = self.get_failed_log(user)
if attempts:
return attempts[-1]['time'] + self.lock_time(user)
def user_locked(self, user):
"""Returns whether the user account is currently locked.
Expect True, if locked, False, if not and None otherwise.
"""
if self.login_attempt_max_count < 1 or not user or \
not user_known(self.env, user):
self.log.debug("AccountGuard.user_locked(%s) = None (%s)",
user, self.login_attempt_max_count < 1 and
'disabled by configuration' or 'anonymous user')
return None
count = self.failed_count(user, reset=None)
if count < self.login_attempt_max_count:
self.log.debug("AccountGuard.user_locked(%s) = False (try left)",
user)
return False
ts_release = self.release_time(user)
if ts_release == 0:
# Account locked permanently.
self.log.debug("AccountGuard.user_locked(%s) = True "
"(permanently)", user)
return True
# Time-locked or time-lock expired.
ts_now = to_timestamp(to_datetime(None))
locked = ts_release - ts_now > 0
self.log.debug("AccountGuard.user_locked(%s) = %s (%s)",
user, locked, locked and 'time-lock' or 'lock expired')
return locked
|