Skip to content

Commit

Permalink
Merge pull request #372 from TeskaLabs/feature/password-requirements
Browse files Browse the repository at this point in the history
Configurable basic password requirements
  • Loading branch information
byewokko authored May 14, 2024
2 parents 51d4d44 + ab079d1 commit b321881
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 1 deletion.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# CHANGELOG

## v24.20

### Pre-releases
- `v24.20-alpha1`

### Breaking changes
- Default password criteria are more restrictive (#372, `v24.20-alpha1`, Compatible with Seacat Auth Webui v24.19-alpha and later, Seacat Account Webui v24.08-beta and later)

### Features
- Configurable password criteria (#372, `v24.20-alpha1`)

---


## v24.17

### Pre-releases
Expand Down
8 changes: 8 additions & 0 deletions seacatauth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,14 @@
},

"seacatauth:password": {
# Password requirements
"max_length": 64,
"min_length": 10,
"min_lowercase_count": 1,
"min_uppercase_count": 1,
"min_digit_count": 1,
"min_special_count": 1,

# Timeout for password reset requests
"password_reset_expiration": "3 d",
},
Expand Down
46 changes: 45 additions & 1 deletion seacatauth/credentials/change_password/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,14 @@ def __init__(self, app, change_password_svc):

web_app = app.WebContainer.WebApp
web_app.router.add_put("/password", self.admin_request_password_reset)
web_app.router.add_get("/account/password/policy", self.password_policy)
web_app.router.add_put("/account/password-change", self.change_password)
web_app.router.add_get("/public/password/policy", self.password_policy)
web_app.router.add_put("/public/password-reset", self.reset_password)
web_app.router.add_put("/public/lost-password", self.lost_password)

web_app_public = app.PublicWebContainer.WebApp
web_app_public.router.add_get("/public/password/policy", self.password_policy)
web_app_public.router.add_put("/public/password-reset", self.reset_password)
web_app_public.router.add_put("/public/lost-password", self.lost_password)

Expand All @@ -46,6 +49,14 @@ def __init__(self, app, change_password_svc):
# <<<


async def password_policy(self, request):
"""
Get minimum password requirements
"""
response_data = await self.ChangePasswordService.password_policy()
return asab.web.rest.json_response(request, response_data)


@asab.web.rest.json_schema_handler({
"type": "object",
"required": [
Expand Down Expand Up @@ -75,11 +86,35 @@ async def change_password(self, request, *, json_data):
"cid": credentials_id, "from_ip": from_ip})
await self.LastActivityService.update_last_activity(
EventCode.PASSWORD_CHANGE_FAILED, credentials_id=credentials_id, from_ip=from_ip)
return asab.web.rest.json_response(request, status=401, data={"result": "FAILED"})
return asab.web.rest.json_response(request, status=401, data={
"result": "UNAUTHORIZED",
"tech_message": "Authentication failed.",
})

# Verify that the new password is different from the old one
# TODO: Users should not reuse their last 10 passwords at least
if new_password == old_password:
AuditLogger.log(asab.LOG_NOTICE, "Password change denied: Reusing old passwords is not allowed.", struct_data={
"cid": credentials_id, "from_ip": from_ip})
await self.LastActivityService.update_last_activity(
EventCode.PASSWORD_CHANGE_FAILED, credentials_id=credentials_id, from_ip=from_ip)
return asab.web.rest.json_response(request, status=400, data={
"result": "FAILED",
"tech_message": "Reusing old passwords is not allowed.",
})

# Change the password
try:
await self.ChangePasswordService.change_password(credentials_id, new_password)
except exceptions.WeakPasswordError as e:
AuditLogger.log(asab.LOG_NOTICE, "Password change denied: New password too weak.", struct_data={
"cid": credentials_id, "from_ip": from_ip})
await self.LastActivityService.update_last_activity(
EventCode.PASSWORD_CHANGE_FAILED, credentials_id=credentials_id, from_ip=from_ip)
return asab.web.rest.json_response(request, status=400, data={
"result": "FAILED",
"tech_message": str(e),
})
except Exception as e:
L.exception("Password change failed: {}".format(e))
AuditLogger.log(asab.LOG_NOTICE, "Password change failed: {}".format(e.__class__.__name__), struct_data={
Expand Down Expand Up @@ -147,6 +182,15 @@ async def reset_password(self, request, *, json_data):
await self.LastActivityService.update_last_activity(
EventCode.PASSWORD_CHANGE_FAILED, credentials_id=credentials_id, from_ip=from_ip)
return asab.web.rest.json_response(request, status=401, data={"result": "FAILED"})
except exceptions.WeakPasswordError as e:
AuditLogger.log(asab.LOG_NOTICE, "Password reset denied: New password too weak.", struct_data={
"cid": credentials_id, "from_ip": from_ip})
await self.LastActivityService.update_last_activity(
EventCode.PASSWORD_CHANGE_FAILED, credentials_id=credentials_id, from_ip=from_ip)
return asab.web.rest.json_response(request, status=400, data={
"result": "FAILED",
"tech_message": str(e),
})
except Exception as e:
L.exception("Password reset failed: {}".format(e))
AuditLogger.log(asab.LOG_NOTICE, "Password reset failed: {}".format(e.__class__.__name__), struct_data={
Expand Down
50 changes: 50 additions & 0 deletions seacatauth/credentials/change_password/service.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import hashlib
import logging
import datetime
import re

import asab
import asab.exceptions

from ... import exceptions
from ...generic import generate_ergonomic_token
Expand All @@ -26,6 +28,13 @@ class ChangePasswordService(asab.Service):
def __init__(self, app, cred_service, service_name="seacatauth.ChangePasswordService"):
super().__init__(app, service_name)

self.PasswordMaxLength = asab.Config.getint("seacatauth:password", "max_length")
self.PasswordMinLength = asab.Config.getint("seacatauth:password", "min_length")
self.PasswordMinLowerCount = asab.Config.getint("seacatauth:password", "min_lowercase_count")
self.PasswordMinUpperCount = asab.Config.getint("seacatauth:password", "min_uppercase_count")
self.PasswordMinDigitCount = asab.Config.getint("seacatauth:password", "min_digit_count")
self.PasswordMinSpecialCount = asab.Config.getint("seacatauth:password", "min_special_count")

self.CredentialsService = cred_service
self.CommunicationService = app.get_service("seacatauth.CommunicationService")
self.StorageService = app.get_service("asab.StorageService")
Expand Down Expand Up @@ -102,6 +111,19 @@ async def create_password_reset_token(self, credentials: dict, expiration: float
return await self._create_password_reset_token(credentials_id=credentials["_id"], expiration=expiration)


async def password_policy(self) -> dict:
"""
Password validation requirements
"""
return {
"min_length": self.PasswordMinLength,
"min_lowercase_count": self.PasswordMinLowerCount,
"min_uppercase_count": self.PasswordMinUpperCount,
"min_digit_count": self.PasswordMinDigitCount,
"min_special_count": self.PasswordMinSpecialCount,
}


async def init_password_reset_by_admin(
self,
credentials: dict,
Expand Down Expand Up @@ -174,6 +196,8 @@ async def change_password(self, credentials_id: str, new_password: str):
if credentials.get("suspended") is True:
raise exceptions.CredentialsSuspendedError(credentials_id)

self.verify_password_strength(new_password)

# Remove "password" from enforced factors
enforce_factors = set(credentials.get("enforce_factors", []))
if "password" in enforce_factors:
Expand All @@ -188,3 +212,29 @@ async def change_password(self, credentials_id: str, new_password: str):

async def _token_id_from_token_string(self, password_reset_token):
return hashlib.sha256(password_reset_token.encode("ascii")).digest()


def verify_password_strength(self, password: str):
if len(password) > self.PasswordMaxLength:
raise asab.exceptions.ValidationError(
"Password cannot be longer than {} characters.".format(self.PasswordMaxLength))

if len(password) < self.PasswordMinLength:
raise exceptions.WeakPasswordError(
"Password must be {} or more characters long.".format(self.PasswordMinLength))

if len(re.findall(r"[a-z]", password)) < self.PasswordMinLowerCount:
raise exceptions.WeakPasswordError(
"Password must contain at least {} lowercase letters.".format(self.PasswordMinLowerCount))

if len(re.findall(r"[A-Z]", password)) < self.PasswordMinUpperCount:
raise exceptions.WeakPasswordError(
"Password must contain at least {} uppercase letters.".format(self.PasswordMinUpperCount))

if len(re.findall(r"[0-9]", password)) < self.PasswordMinDigitCount:
raise exceptions.WeakPasswordError(
"Password must contain at least {} digits.".format(self.PasswordMinDigitCount))

if len(re.findall(r"[^a-zA-Z0-9]", password)) < self.PasswordMinSpecialCount:
raise exceptions.WeakPasswordError(
"Password must contain at least {} special characters.".format(self.PasswordMinSpecialCount))
10 changes: 10 additions & 0 deletions seacatauth/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import typing

import asab.exceptions


class SeacatAuthError(Exception):
"""
Expand Down Expand Up @@ -107,6 +109,14 @@ def __init__(self, credentials_id, *args):
super().__init__("Credentials {!r} suspended".format(self.CredentialsId), *args)


class WeakPasswordError(SeacatAuthError, asab.exceptions.ValidationError):
"""
Password does not comply with configured policies
"""
def __init__(self, message, *args):
super().__init__(message, *args)


class UnauthorizedTenantAccessError(AccessDeniedError):
"""
Session not authorized for the tenant.
Expand Down

0 comments on commit b321881

Please sign in to comment.