From adbd16f146256d0a673d18bb5ba91d81bd97dc0d Mon Sep 17 00:00:00 2001 From: blackout Date: Mon, 17 Jun 2024 14:59:04 -0400 Subject: [PATCH] change merge target --- synapse/lib/auth.py | 124 ++++++++++++++++++++++++++++++++- synapse/lib/cell.py | 5 +- synapse/lib/schemas.py | 84 ++++++++++++++++++++++ synapse/tests/test_lib_auth.py | 113 ++++++++++++++++++++++++++++++ 4 files changed, 323 insertions(+), 3 deletions(-) diff --git a/synapse/lib/auth.py b/synapse/lib/auth.py index 449e0e50040..7aa158f3c9c 100644 --- a/synapse/lib/auth.py +++ b/synapse/lib/auth.py @@ -118,7 +118,7 @@ class Auth(s_nexus.Pusher): ''' - async def __anit__(self, slab, dbname, pref='', nexsroot=None, seed=None, maxusers=0): + async def __anit__(self, slab, dbname, pref='', nexsroot=None, seed=None, maxusers=0, policy=None): ''' Args: slab (s_lmdb.Slab): The slab to use for persistent storage for auth @@ -137,6 +137,7 @@ async def __anit__(self, slab, dbname, pref='', nexsroot=None, seed=None, maxuse seed = s_common.guid() self.maxusers = maxusers + self.policy = policy self.userdefs = self.stor.getSubKeyVal('user:info:') self.useridenbyname = self.stor.getSubKeyVal('user:name:') @@ -1253,7 +1254,25 @@ async def tryPasswd(self, passwd, nexs=True): return False if isinstance(shadow, dict): - return await s_passwd.checkShadowV2(passwd=passwd, shadow=shadow) + result = await s_passwd.checkShadowV2(passwd=passwd, shadow=shadow) + if self.auth.policy and (attempts := self.auth.policy['attempts']) > 0: + if result: + await self.auth.setUserInfo(self.iden, 'policy:attempts', 0) + return True + + valu = self.info.get('policy:attempts', 0) + 1 + await self.auth.setUserInfo(self.iden, 'policy:attempts', valu) + + if valu >= attempts: + await self.auth.nexsroot.cell.setUserLocked(self.iden, True) + + mesg = f'User {self.name} has exceeded the number of allowed password attempts ({valu +1}), locking their account.' + extra = {'synapse': {'target_user': self.iden, 'target_username': self.name, 'status': 'MODIFY'}} + logger.info(mesg, extra=extra) + + raise s_exc.AuthDeny(mesg=mesg, attempts=attempts, user=self.iden, username=self.name) + + return result # Backwards compatible password handling salt, hashed = shadow @@ -1267,6 +1286,104 @@ async def tryPasswd(self, passwd, nexs=True): return False + async def _checkPasswdPolicy(self, passwd, shadow, nexs=True): + if not self.auth.policy: + return + + failures = [] + + # Check complexity of password + complexity = self.auth.policy.get('complexity') + + # Check password length + minlen = complexity.get('length') + if minlen and (passwd is None or len(passwd) < minlen): + failures.append(f'Password must be at least {minlen} characters.') + + if minlen and passwd is None: + # Set password to empty string so we get the rest of the failure info + passwd = '' + + if passwd is None: + return + + allvalid = [] + + # Check uppercase + count = complexity.get('upper:count') + valid = complexity.get('upper:valid') + allvalid.append(valid) + + if count and (found := len([k for k in passwd if k in valid])) < count: + failures.append(f'Password must contain at least {count} uppercase characters, {found} found.') + + # Check lowercase + count = complexity.get('lower:count') + valid = complexity.get('lower:valid') + allvalid.append(valid) + + if count and (found := len([k for k in passwd if k in valid])) < count: + failures.append(f'Password must contain at least {count} lowercase characters, {found} found.') + + # Check special + count = complexity.get('special:count') + valid = complexity.get('special:valid') + allvalid.append(valid) + + if count and (found := len([k for k in passwd if k in valid])) < count: + failures.append(f'Password must contain at least {count} special characters, {found} found.') + + # Check numbers + count = complexity.get('number:count') + valid = complexity.get('number:valid') + allvalid.append(valid) + + if count and (found := len([k for k in passwd if k in valid])) < count: + failures.append(f'Password must contain at least {count} digit characters, {found} found.') + + allvalid = ''.join(allvalid) + if (invalid := set(passwd) - set(allvalid)): + failures.append(f'Password contains invalid characters: {sorted(list(invalid))}') + + # Check sequences + seqlen = complexity.get('sequences') + if seqlen > 1: + # Convert each character to it's ordinal value so we can look for + # forward and reverse sequences in windows of seqlen. Doing it this + # way allows us to easily check unicode sequences too. + passb = [ord(k) for k in passwd] + for offs in range(len(passwd) - (seqlen - 1)): + curv = passb[offs] + fseq = list(range(curv, curv + seqlen)) + rseq = list(range(curv, curv - seqlen, -1)) + window = passb[offs:offs + seqlen] + if window == fseq or window == rseq: + failures.append(f'Password must not contain forward/reverse sequences longer than {seqlen} characters.') + break + + # Check for previous password reuse + prevvalu = self.auth.policy.get('previous') + if prevvalu: + previous = self.info.get('policy:previous', ()) + for prevshad in previous: + if await s_passwd.checkShadowV2(passwd, prevshad): + failures.append(f'Password cannot be the same as previous {prevvalu} password(s).') + break + + if failures: + mesg = ['Cannot change password due to the following policy violations:'] + mesg.extend(f' - {msg}' for msg in failures) + raise s_exc.BadArg(mesg='\n'.join(mesg), failures=failures) + + if prevvalu: + # Looks like this password is good, add it to the list of previous passwords + previous = self.info.get('policy:previous', ()) + previous = (shadow,) + previous + if nexs: + await self.auth.setUserInfo(self.iden, 'policy:previous', previous[:prevvalu]) + else: + await self.auth._hndlsetUserInfo(self.iden, 'policy:previous', previous[:prevvalu], logged=nexs) + async def setPasswd(self, passwd, nexs=True): # Prevent empty string or non-string values if passwd is None: @@ -1275,6 +1392,9 @@ async def setPasswd(self, passwd, nexs=True): shadow = await s_passwd.getShadowV2(passwd=passwd) else: raise s_exc.BadArg(mesg='Password must be a string') + + await self._checkPasswdPolicy(passwd, shadow, nexs=nexs) + if nexs: await self.auth.setUserInfo(self.iden, 'passwd', shadow) else: diff --git a/synapse/lib/cell.py b/synapse/lib/cell.py index 0fc0bf6e218..032e23715be 100644 --- a/synapse/lib/cell.py +++ b/synapse/lib/cell.py @@ -893,6 +893,7 @@ class Cell(s_nexus.Pusher, s_telepath.Aware): 'type': 'object', 'hideconf': True, }, + 'auth:passwd:policy': s_schemas.passwdPolicySchema, 'max:users': { 'default': 0, 'description': 'Maximum number of users allowed on system, not including root or locked/archived users (0 is no limit).', @@ -3196,6 +3197,7 @@ async def _initCellAuth(self): return await ctor(self) maxusers = self.conf.get('max:users') + policy = self.conf.get('auth:passwd:policy') seed = s_common.guid((self.iden, 'hive', 'auth')) @@ -3204,7 +3206,8 @@ async def _initCellAuth(self): 'auth', seed=seed, nexsroot=self.getCellNexsRoot(), - maxusers=maxusers + maxusers=maxusers, + policy=policy ) auth.link(self.dist) diff --git a/synapse/lib/schemas.py b/synapse/lib/schemas.py index 60ab078c4e3..28177c15165 100644 --- a/synapse/lib/schemas.py +++ b/synapse/lib/schemas.py @@ -1,3 +1,5 @@ +import string + import synapse.lib.const as s_const import synapse.lib.config as s_config import synapse.lib.msgpack as s_msgpack @@ -301,3 +303,85 @@ } } reqValidRules = s_config.getJsValidator(_authRulesSchema) + +passwdPolicySchema = { + 'description': 'Specify password policy/complexity requirements.', + 'type': 'object', + 'properties': { + 'complexity': { + 'type': 'object', + 'properties': { + 'length': { + 'type': 'number', + 'minimum': 0, + 'default': 1, + 'description': 'Minimum password character length.', + }, + 'sequences': { + 'type': 'number', + 'minimum': 0, + 'default': 0, + 'description': 'Maximum sequence length in a password. Sequences can be letters or number, forward or reverse.', + }, + 'upper:count': { + 'type': 'number', + 'minimum': 0, + 'default': 0, + 'description': 'The minimum number of uppercase characters required in password.', + }, + 'upper:valid': { + 'type': 'string', + 'default': string.ascii_uppercase, + 'description': 'All valid uppercase characters.', + }, + 'lower:count': { + 'type': 'number', + 'minimum': 0, + 'default': 0, + 'description': 'The minimum number of lowercase characters required in password.', + }, + 'lower:valid': { + 'type': 'string', + 'default': string.ascii_lowercase, + 'description': 'All valid lowercase characters.', + }, + 'special:count': { + 'type': 'number', + 'minimum': 0, + 'default': 0, + 'description': 'The minimum number of special characters required in password.', + }, + 'special:valid': { + 'type': 'string', + 'default': string.punctuation, + 'description': 'All valid special characters.', + }, + 'number:count': { + 'type': 'number', + 'minimum': 0, + 'default': 0, + 'description': 'The minimum number of digit characters required in password.', + }, + 'number:valid': { + 'type': 'string', + 'default': string.digits, + 'description': 'All valid digit characters.', + }, + }, + 'additionalProperties': False, + }, + 'attempts': { + 'type': 'number', + 'minimum': 0, + 'default': 0, + 'description': 'Maximum number of incorrect attempts before locking user account.', + }, + 'previous': { + 'type': 'number', + 'minimum': 1, + 'default': 1, + 'description': 'Number of previous passwords to disallow.', + }, + }, + 'additionalProperties': False, +} diff --git a/synapse/tests/test_lib_auth.py b/synapse/tests/test_lib_auth.py index a563d522fd6..982d7bffae2 100644 --- a/synapse/tests/test_lib_auth.py +++ b/synapse/tests/test_lib_auth.py @@ -1,3 +1,4 @@ +import string import pathlib import synapse.exc as s_exc @@ -394,3 +395,115 @@ async def test_auth_invalid(self): await core.auth.allrole.setRules([(True, ('hehe', 'haha'), 'newp')]) with self.raises(s_exc.SchemaViolation): await core.auth.allrole.setRules([(True, )]) + + async def test_auth_password_policy(self): + policy = { + 'complexity': { + 'length': 12, + 'sequences': 3, + 'upper:count': 2, + 'lower:count': 2, + 'lower:valid': string.ascii_lowercase + 'αβγ', + 'special:count': 2, + 'number:count': 2, + }, + 'attempts': 3, + 'previous': 2, + } + + conf = {'auth:passwd:policy': policy} + async with self.getTestCore(conf=conf) as core: + auth = core.auth + + user = await auth.addUser('blackout@vertex.link') + + # Compliant passwords + pass1 = 'jhf9_gaf-xaw-QBX4dqp' + pass2 = 'rek@hjv6VBV2rwe2qwd!' + pass3 = 'ZXN-pyv7ber-kzq2kgh' + await core.setUserPasswd(user.iden, pass1) + await core.setUserPasswd(user.iden, pass2) + await core.setUserPasswd(user.iden, pass3) + + # Test password attempt reset + await core.tryUserPasswd(user.name, 'foo') + self.eq(user.info.get('policy:attempts'), 1) + + await core.tryUserPasswd(user.name, pass3) + self.eq(user.info.get('policy:attempts'), 0) + + # Test lockout from too many invalid attempts + await core.tryUserPasswd(user.name, 'foo') + await core.tryUserPasswd(user.name, 'foo') + + with self.raises(s_exc.AuthDeny) as exc: + await core.tryUserPasswd(user.name, 'foo') + attempts = exc.exception.get('attempts') + self.eq(3, attempts) + self.true(user.info.get('locked')) + + await user.setLocked(False) + + # Test reusing previous password + with self.raises(s_exc.BadArg) as exc: + await core.setUserPasswd(user.iden, pass2) + self.eq(exc.exception.get('failures'), [ + 'Password cannot be the same as previous 2 password(s).' + ]) + + await core.setUserPasswd(user.iden, pass1) + + # Test password that doesn't meet complexity requirements + with self.raises(s_exc.BadArg) as exc: + await core.setUserPasswd(user.iden, 'Ff1!') + self.eq(exc.exception.get('failures'), [ + 'Password must be at least 12 characters.', + 'Password must contain at least 2 uppercase characters, 1 found.', + 'Password must contain at least 2 lowercase characters, 1 found.', + 'Password must contain at least 2 special characters, 1 found.', + 'Password must contain at least 2 digit characters, 1 found.' + ]) + + # Check sequences + seqmsg = f'Password must not contain forward/reverse sequences longer than 3 characters.' + passwords = [ + # letters + 'abcA', 'dcbA', 'Abcd', 'Acba', + + # numbers + '123A', '432A', 'A234', 'A321', + + # greek alphabet (unicode test) + 'αβγA', 'Aαβγ', 'γβαA', 'Aγβα', + + ] + + for password in passwords: + with self.raises(s_exc.BadArg) as exc: + await core.setUserPasswd(user.iden, password) + self.isin(seqmsg, exc.exception.get('failures')) + + with self.raises(s_exc.BadArg) as exc: + await core.setUserPasswd(user.iden, 'AAAA') + self.eq(exc.exception.get('failures'), [ + 'Password must be at least 12 characters.', + 'Password must contain at least 2 lowercase characters, 0 found.', + 'Password must contain at least 2 special characters, 0 found.', + 'Password must contain at least 2 digit characters, 0 found.' + ]) + + with self.raises(s_exc.BadArg) as exc: + await core.setUserPasswd(user.iden, 'aaaa') + self.eq(exc.exception.get('failures'), [ + 'Password must be at least 12 characters.', + 'Password must contain at least 2 uppercase characters, 0 found.', + 'Password must contain at least 2 special characters, 0 found.', + 'Password must contain at least 2 digit characters, 0 found.' + ]) + + with self.raises(s_exc.BadArg) as exc: + await core.setUserPasswd(user.iden, None) + self.isin( + 'Password must be at least 12 characters.', + exc.exception.get('failures') + )