Skip to content

Commit

Permalink
change merge target
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaelSquires committed Jun 17, 2024
1 parent f247b09 commit adbd16f
Show file tree
Hide file tree
Showing 4 changed files with 323 additions and 3 deletions.
124 changes: 122 additions & 2 deletions synapse/lib/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:')
Expand Down Expand Up @@ -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
Expand All @@ -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

Check warning on line 1308 in synapse/lib/auth.py

View check run for this annotation

Codecov / codecov/patch

synapse/lib/auth.py#L1308

Added line #L1308 was not covered by tests

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 warning on line 1346 in synapse/lib/auth.py

View check run for this annotation

Codecov / codecov/patch

synapse/lib/auth.py#L1346

Added line #L1346 was not covered by tests

# 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)

Check warning on line 1385 in synapse/lib/auth.py

View check run for this annotation

Codecov / codecov/patch

synapse/lib/auth.py#L1385

Added line #L1385 was not covered by tests

async def setPasswd(self, passwd, nexs=True):
# Prevent empty string or non-string values
if passwd is None:
Expand All @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion synapse/lib/cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).',
Expand Down Expand Up @@ -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'))

Expand All @@ -3204,7 +3206,8 @@ async def _initCellAuth(self):
'auth',
seed=seed,
nexsroot=self.getCellNexsRoot(),
maxusers=maxusers
maxusers=maxusers,
policy=policy
)

auth.link(self.dist)
Expand Down
84 changes: 84 additions & 0 deletions synapse/lib/schemas.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
}
113 changes: 113 additions & 0 deletions synapse/tests/test_lib_auth.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import string
import pathlib

import synapse.exc as s_exc
Expand Down Expand Up @@ -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('[email protected]')

# 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')
)

0 comments on commit adbd16f

Please sign in to comment.