Skip to content

Commit

Permalink
FOGL-8753 : Implemented user blocking for incorrect password (#1397)
Browse files Browse the repository at this point in the history
* FOGL-8753 : Implemented user blocking for incorrect password

Signed-off-by: nandan <nandan.ghildiyal@gmail.com>
  • Loading branch information
gnandan committed Jun 24, 2024
1 parent 8d4900c commit 60272c5
Show file tree
Hide file tree
Showing 16 changed files with 218 additions and 39 deletions.
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
fledge_version=2.4.0
fledge_schema=71
fledge_schema=72
60 changes: 59 additions & 1 deletion python/fledge/services/core/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
MIN_USERNAME_LENGTH = 4
USERNAME_REGEX_PATTERN = '^[a-zA-Z0-9_.-]+$'
FORBIDDEN_MSG = 'Resource you were trying to reach is absolutely forbidden for some reason'
DATE_FORMAT = "%Y-%m-%d %H:%M:%S.%f"

# TODO: remove me, use from roles table
ADMIN_ROLE_ID = 1
Expand Down Expand Up @@ -315,7 +316,6 @@ async def get_user(request):

if 'username' in request.query and request.query['username'] != '':
user_name = request.query['username'].lower()

if user_id or user_name:
try:
user = await User.Objects.get(user_id, user_name)
Expand All @@ -342,6 +342,11 @@ async def get_user(request):
u["accessMethod"] = row["access_method"]
u["realName"] = row["real_name"]
u["description"] = row["description"]
if row["block_until"]:
curr_time = datetime.datetime.now(datetime.timezone.utc).strftime(DATE_FORMAT)
block_time = row["block_until"].split('.')[0] # strip time after HH:MM:SS for display
if datetime.datetime.strptime(row["block_until"], DATE_FORMAT) > datetime.datetime.strptime(curr_time, DATE_FORMAT):
u["blockUntil"] = block_time
res.append(u)
result = {'users': res}

Expand Down Expand Up @@ -680,6 +685,59 @@ async def enable_user(request):
raise web.HTTPInternalServerError(reason=str(exc), body=json.dumps({"message": msg}))
return web.json_response({'message': 'User with ID:<{}> has been {} successfully.'.format(int(user_id), _text)})

@has_permission("admin")
async def unblock_user(request):
""" Unblock the user got blocked due to multiple invalid log in attempts
:Example:
curl -H "authorization: <token>" -X PUT http://localhost:8081/fledge/admin/{user_id}/unblock
"""
if request.is_auth_optional:
_logger.warning(FORBIDDEN_MSG)
raise web.HTTPForbidden

user_id = request.match_info.get('user_id')

try:
from fledge.services.core import connect
storage_client = connect.get_storage_async()
result = await _unblock_user(user_id,storage_client)
if 'response' in result:
if result['response'] == 'updated':
# USRUB audit trail entry
audit = AuditLogger(storage_client)
await audit.information('USRUB', {'user_id': int(user_id),
"message": "User with ID:<{}> has been unblocked.".format(user_id)})
else:
raise KeyError("Unblock operation for user with ID:<{}> failed".format(user_id))
except (KeyError, ValueError) as err:
msg = str(err)
raise web.HTTPBadRequest(reason=str(err), body=json.dumps({"message": msg}))
except User.DoesNotExist:
msg = "User with ID:<{}> does not exist.".format(int(user_id))
raise web.HTTPNotFound(reason=msg, body=json.dumps({"message": msg}))
except Exception as exc:
msg = str(exc)
_logger.error(exc, "Failed to unblock user ID:<{}>.".format(user_id))
raise web.HTTPInternalServerError(reason=str(exc), body=json.dumps({"message": msg}))
return web.json_response({'message': 'User with ID:<{}> has been unblocked successfully.'.format(int(user_id))})


async def _unblock_user(user_id, storage_client):
""" implementation for unblock user
"""

from fledge.common.storage_client.payload_builder import PayloadBuilder

payload = PayloadBuilder().SELECT("id").WHERE(
['id', '=', user_id]).payload()
old_result = await storage_client.query_tbl_with_payload('users', payload)
if len(old_result['rows']) == 0:
raise User.DoesNotExist('User does not exist')

# Clear the failed_attempts so that maximum allowed attempts can be used correctly
payload = PayloadBuilder().SET(block_until=None, failed_attempts=0).WHERE(['id', '=', user_id]).payload()
result = await storage_client.update_tbl("users", payload)
return result

@has_permission("admin")
async def reset(request):
Expand Down
1 change: 1 addition & 0 deletions python/fledge/services/core/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def setup(app):
app.router.add_route('DELETE', '/fledge/admin/{user_id}/delete', auth.delete_user)
app.router.add_route('PUT', '/fledge/admin/{user_id}', auth.update_user)
app.router.add_route('PUT', '/fledge/admin/{user_id}/enable', auth.enable_user)
app.router.add_route('PUT', '/fledge/admin/{user_id}/unblock', auth.unblock_user)
app.router.add_route('PUT', '/fledge/admin/{user_id}/reset', auth.reset)

# Configuration
Expand Down
92 changes: 87 additions & 5 deletions python/fledge/services/core/user_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import json
import uuid
import hashlib
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
import jwt

from fledge.common.audit_logger import AuditLogger
Expand Down Expand Up @@ -185,6 +185,13 @@ async def update(cls, user_id, user_data):
if 'role_id' in user_data:
old_kwargs["role_id"] = old_data['role_id']
new_kwargs.update({"role_id": user_data['role_id']})
if 'failed_attempts' in user_data:
old_kwargs["failed_attempts"] = old_data['failed_attempts']
new_kwargs.update({"failed_attempts": user_data['failed_attempts']})
if 'block_until' in user_data:
old_kwargs["block_until"] = old_data['block_until']
new_kwargs.update({"block_until": str(user_data['block_until'])})

storage_client = connect.get_storage_async()
hashed_pwd = None
pwd_history_list = []
Expand Down Expand Up @@ -255,7 +262,7 @@ async def filter(cls, **kwargs):
user_name = kwargs['username']

q = PayloadBuilder().SELECT("id", "uname", "role_id", "access_method", "real_name", "description",
"hash_algorithm").WHERE(['enabled', '=', 't'])
"hash_algorithm", "block_until", "failed_attempts").WHERE(['enabled', '=', 't'])

if user_id is not None:
q = q.AND_WHERE(['id', '=', user_id])
Expand Down Expand Up @@ -342,7 +349,7 @@ async def login(cls, username, password, host):

# get user info on the basis of username
payload = PayloadBuilder().SELECT("pwd", "id", "role_id", "access_method", "pwd_last_changed",
"real_name", "description", "hash_algorithm")\
"real_name", "description", "hash_algorithm", "block_until", "failed_attempts")\
.WHERE(['uname', '=', username])\
.ALIAS("return", ("pwd_last_changed", 'pwd_last_changed'))\
.FORMAT("return", ("pwd_last_changed", "YYYY-MM-DD HH24:MI:SS.MS"))\
Expand All @@ -364,13 +371,87 @@ async def login(cls, username, password, host):
# user will be forced to change their password.
raise User.PasswordExpired(found_user['id'])

failed_attempts = found_user['failed_attempts']
block_until = found_user['block_until']

# Do not block already blocked account further
if block_until:
curr_time = datetime.now(timezone.utc).strftime(DATE_FORMAT)
if datetime.strptime(block_until, DATE_FORMAT) > datetime.strptime(curr_time, DATE_FORMAT):
diff = datetime.strptime(block_until, DATE_FORMAT) - datetime.strptime(curr_time, DATE_FORMAT)
hours = diff.seconds // 3600
hours_left = ""
if hours == 1 :
hours_left = "{} hour ".format(hours)
elif hours > 1:
hours_left = "{} hours ".format(hours)

minutes = (diff.seconds % 3600) // 60
minutes_left = " 1 minute" #Show minutes 1 or less than 1 as "1 minute"
if minutes > 1:
minutes_left = " {} minutes ".format(minutes)

blocked_message = "Account is blocked for {}{}".format(hours_left,minutes_left)
raise User.PasswordDoesNotMatch(blocked_message)

# validate password
is_valid_pwd = cls.check_password(found_user['pwd'], str(password), algorithm=found_user['hash_algorithm'])
if not is_valid_pwd:
# Another condition to check password is ONLY for the case:
# when we have requested password with hashed value and this comes only with microservice to get token
if found_user['pwd'] != str(password):
raise User.PasswordDoesNotMatch('Username or Password do not match')
# Do not block admin user
if int(found_user['role_id']) == 1:
raise User.PasswordDoesNotMatch('Username or Password do not match')

MAX_LOGIN_ATTEMPTS = 5
failed_attempts += 1
audit_log_message = ""
blocked_message = ""

# Do not block users for first failed attempt
if failed_attempts < MAX_LOGIN_ATTEMPTS - 3:
await cls.update(found_user['id'],{'failed_attempts': failed_attempts})
raise User.PasswordDoesNotMatch('Username or Password do not match')

# Check for other users
if failed_attempts == MAX_LOGIN_ATTEMPTS - 3: # Block for 1 minute after 2 failed attempts
block_until = datetime.now(timezone.utc) + timedelta(seconds=60)
audit_log_message = "'{}' user blocked for 1 minute.".format(username)
blocked_message = "Invalid username/password attempted multiple times. Account blocked for 1 minute."

elif failed_attempts == MAX_LOGIN_ATTEMPTS - 2: # Block for 15 minutes after 3 failed attempts
block_until = datetime.now(timezone.utc) + timedelta(minutes=15)
audit_log_message = "'{}' user blocked for 15 minutes.".format(username)
blocked_message = "Invalid username/password attempted multiple times. Account blocked for 15 minutes."

elif failed_attempts == MAX_LOGIN_ATTEMPTS - 1: # Block for 1 hour after 4 failed attempts
block_until = datetime.now(timezone.utc) + timedelta(hours=1)
audit_log_message = "'{}' user blocked for 1 hour.".format(username)
blocked_message = "Invalid username/password attempted multiple times. Account blocked for 1 hour."

elif failed_attempts == MAX_LOGIN_ATTEMPTS: # Block for 24 hours after 5 failed attempts
block_until = datetime.now(timezone.utc) + timedelta(hours=24)
audit_log_message = "'{}' user blocked for 24 hours.".format(username)
blocked_message = "Invalid username/password attempted multiple times. Account blocked for 24 hours."

# Raise Alert if user is blocked for 24 hours
from fledge.common.alert_manager import AlertManager
alert_manager = AlertManager(storage_client)
param = {"key": "USRBK", "message": audit_log_message, "urgency": 2}
await alert_manager.add(param)

# USRBK audit trail entry
if failed_attempts >= MAX_LOGIN_ATTEMPTS - 3:
await cls.update(found_user['id'],{'failed_attempts': failed_attempts, 'block_until':block_until})
audit = AuditLogger(storage_client)
await audit.information('USRBK', {'user_id': found_user['id'], 'user_name': username, 'failed_attempts':failed_attempts,
"message": audit_log_message})
raise User.PasswordDoesNotMatch(blocked_message)

# Clear failed_attempts on successful login
if int(found_user['failed_attempts']) > 0:
await cls.update(found_user['id'],{'failed_attempts': 0})

uid, jwt_token, is_admin = await cls._get_new_token(storage_client, found_user, host)
return uid, jwt_token, is_admin
Expand Down Expand Up @@ -545,4 +626,5 @@ async def remove(cls, data):
async def clear(cls):
# To avoid cyclic import
from fledge.services.core import server
server.Server._user_sessions = []
server.Server._user_sessions = []

3 changes: 3 additions & 0 deletions scripts/plugins/storage/postgres/downgrade/71.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE fledge.users DROP COLUMN failed_attempts;
ALTER TABLE fledge.users DROP COLUMN block_until;
DELETE FROM fledge.log_codes where code IN ('USRBK', 'USRUB');
5 changes: 4 additions & 1 deletion scripts/plugins/storage/postgres/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,8 @@ CREATE TABLE fledge.users (
pwd_last_changed timestamp(6) with time zone NOT NULL DEFAULT now(),
access_method character varying(5) CHECK( access_method IN ('any','pwd','cert') ) NOT NULL DEFAULT 'any',
hash_algorithm character varying(6) CHECK( hash_algorithm IN ('SHA256', 'SHA512') ) NOT NULL DEFAULT 'SHA512',
failed_attempts integer DEFAULT 0,
block_until timestamp(6) DEFAULT NULL,
CONSTRAINT users_pkey PRIMARY KEY (id),
CONSTRAINT users_fk1 FOREIGN KEY (role_id)
REFERENCES fledge.roles (id) MATCH SIMPLE
Expand Down Expand Up @@ -1037,7 +1039,8 @@ INSERT INTO fledge.log_codes ( code, description )
( 'CTSAD', 'Control Script Added' ),( 'CTSCH', 'Control Script Changed' ),('CTSDL', 'Control Script Deleted' ),
( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ),
( 'CTEAD', 'Control Entrypoint Added' ),( 'CTECH', 'Control Entrypoint Changed' ),('CTEDL', 'Control Entrypoint Deleted' ),
( 'BUCAD', 'Bucket Added' ), ( 'BUCCH', 'Bucket Changed' ), ( 'BUCDL', 'Bucket Deleted' )
( 'BUCAD', 'Bucket Added' ), ( 'BUCCH', 'Bucket Changed' ), ( 'BUCDL', 'Bucket Deleted' ),
( 'USRBK', 'User Blocked' ), ( 'USRUB', 'User Unblocked' )
;

--
Expand Down
4 changes: 4 additions & 0 deletions scripts/plugins/storage/postgres/upgrade/72.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ALTER TABLE fledge.users ADD COLUMN failed_attempts integer DEFAULT 0;
ALTER TABLE fledge.users ADD COLUMN block_until timestamp(6) DEFAULT NULL;
INSERT INTO fledge.log_codes ( code, description )
VALUES ( 'USRBK', 'User Blocked' ), ( 'USRUB', 'User Unblocked' );
3 changes: 3 additions & 0 deletions scripts/plugins/storage/sqlite/downgrade/71.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE fledge.users DROP COLUMN failed_attempts;
ALTER TABLE fledge.users DROP COLUMN block_until;
DELETE FROM fledge.log_codes where code IN ('USRBK', 'USRUB');
5 changes: 4 additions & 1 deletion scripts/plugins/storage/sqlite/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,8 @@ CREATE TABLE fledge.users (
pwd_last_changed DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW', 'localtime')),
access_method TEXT CHECK( access_method IN ('any','pwd','cert') ) NOT NULL DEFAULT 'any',
hash_algorithm TEXT CHECK( hash_algorithm IN ('SHA256', 'SHA512') ) NOT NULL DEFAULT 'SHA512',
failed_attempts INTEGER DEFAULT 0,
block_until DATETIME DEFAULT NULL,
CONSTRAINT users_fk1 FOREIGN KEY (role_id)
REFERENCES roles (id) MATCH SIMPLE
ON UPDATE NO ACTION
Expand Down Expand Up @@ -792,7 +794,8 @@ INSERT INTO fledge.log_codes ( code, description )
( 'CTSAD', 'Control Script Added' ),( 'CTSCH', 'Control Script Changed' ),('CTSDL', 'Control Script Deleted' ),
( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ),
( 'CTEAD', 'Control Entrypoint Added' ),( 'CTECH', 'Control Entrypoint Changed' ),('CTEDL', 'Control Entrypoint Deleted' ),
( 'BUCAD', 'Bucket Added' ), ( 'BUCCH', 'Bucket Changed' ), ( 'BUCDL', 'Bucket Deleted' )
( 'BUCAD', 'Bucket Added' ), ( 'BUCCH', 'Bucket Changed' ), ( 'BUCDL', 'Bucket Deleted' ),
( 'USRBK', 'User Blocked' ), ( 'USRUB', 'User Unblocked' )
;

--
Expand Down
4 changes: 4 additions & 0 deletions scripts/plugins/storage/sqlite/upgrade/72.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ALTER TABLE fledge.users ADD COLUMN failed_attempts INTEGER DEFAULT 0;
ALTER TABLE fledge.users ADD COLUMN block_until DATETIME DEFAULT NULL;
INSERT INTO fledge.log_codes ( code, description )
VALUES ( 'USRBK', 'User Blocked' ), ( 'USRUB', 'User Unblocked' );
3 changes: 3 additions & 0 deletions scripts/plugins/storage/sqlitelb/downgrade/71.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE fledge.users DROP COLUMN failed_attempts;
ALTER TABLE fledge.users DROP COLUMN block_until;
DELETE FROM fledge.log_codes where code IN ('USRBK', 'USRUB');
5 changes: 4 additions & 1 deletion scripts/plugins/storage/sqlitelb/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,8 @@ CREATE TABLE fledge.users (
pwd_last_changed DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW', 'localtime')),
access_method TEXT CHECK( access_method IN ('any','pwd','cert') ) NOT NULL DEFAULT 'any',
hash_algorithm TEXT CHECK( hash_algorithm IN ('SHA256', 'SHA512') ) NOT NULL DEFAULT 'SHA512',
failed_attempts INTEGER DEFAULT 0,
block_until DATETIME DEFAULT NULL,
CONSTRAINT users_fk1 FOREIGN KEY (role_id)
REFERENCES roles (id) MATCH SIMPLE
ON UPDATE NO ACTION
Expand Down Expand Up @@ -792,7 +794,8 @@ INSERT INTO fledge.log_codes ( code, description )
( 'CTSAD', 'Control Script Added' ),( 'CTSCH', 'Control Script Changed' ),('CTSDL', 'Control Script Deleted' ),
( 'CTPAD', 'Control Pipeline Added' ),( 'CTPCH', 'Control Pipeline Changed' ),('CTPDL', 'Control Pipeline Deleted' ),
( 'CTEAD', 'Control Entrypoint Added' ),( 'CTECH', 'Control Entrypoint Changed' ),('CTEDL', 'Control Entrypoint Deleted' ),
( 'BUCAD', 'Bucket Added' ), ( 'BUCCH', 'Bucket Changed' ), ( 'BUCDL', 'Bucket Deleted' )
( 'BUCAD', 'Bucket Added' ), ( 'BUCCH', 'Bucket Changed' ), ( 'BUCDL', 'Bucket Deleted' ),
( 'USRBK', 'User Blocked' ), ( 'USRUB', 'User Unblocked' )
;

--
Expand Down
4 changes: 4 additions & 0 deletions scripts/plugins/storage/sqlitelb/upgrade/72.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ALTER TABLE fledge.users ADD COLUMN failed_attempts INTEGER DEFAULT 0;
ALTER TABLE fledge.users ADD COLUMN block_until DATETIME DEFAULT NULL;
INSERT INTO fledge.log_codes ( code, description )
VALUES ( 'USRBK', 'User Blocked' ), ( 'USRUB', 'User Unblocked' );
3 changes: 2 additions & 1 deletion tests/system/python/api/test_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ def test_get_log_codes(self, fledge_url, reset_and_start_fledge):
'CTSAD', 'CTSCH', 'CTSDL',
'CTPAD', 'CTPCH', 'CTPDL',
'CTEAD', 'CTECH', 'CTEDL',
'BUCAD', 'BUCCH', 'BUCDL'
'BUCAD', 'BUCCH', 'BUCDL',
'USRBK','USRUB'
]
conn = http.client.HTTPConnection(fledge_url)
conn.request("GET", '/fledge/audit/logcode')
Expand Down
Loading

0 comments on commit 60272c5

Please sign in to comment.