feat(rbac): add login tracking, lockout, secure cache
- Add created_at, last_login, login_fail_count, last_login_fail fields - 3 failed logins locks account for 5 minutes - LRU+TTL cache for UserPermissions, thread-safe - All login methods update last_login - Migration SQL for existing databases
This commit is contained in:
parent
a8a5199c25
commit
3fdd4efeff
14
migration/add_login_tracking.sql
Normal file
14
migration/add_login_tracking.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
-- RBAC users table migration: add login tracking fields
|
||||||
|
-- Run this on existing databases to add new columns
|
||||||
|
|
||||||
|
-- Add registration timestamp (NOT NULL with default for existing rows)
|
||||||
|
ALTER TABLE users ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '用户注册时间';
|
||||||
|
|
||||||
|
-- Add last login timestamp
|
||||||
|
ALTER TABLE users ADD COLUMN last_login TIMESTAMP NULL DEFAULT NULL COMMENT '最后登录时间';
|
||||||
|
|
||||||
|
-- Add consecutive login fail count
|
||||||
|
ALTER TABLE users ADD COLUMN login_fail_count SMALLINT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数';
|
||||||
|
|
||||||
|
-- Add last login failure timestamp
|
||||||
|
ALTER TABLE users ADD COLUMN last_login_fail TIMESTAMP NULL DEFAULT NULL COMMENT '最后登录失败时间';
|
||||||
Binary file not shown.
@ -88,6 +88,9 @@ async def register_user(sor, ns):
|
|||||||
id = getID()
|
id = getID()
|
||||||
ns.id = id
|
ns.id = id
|
||||||
ns.orgid = id
|
ns.orgid = id
|
||||||
|
# Set registration timestamp
|
||||||
|
ns.created_at = curDateString('%Y-%m-%d %H:%M:%S')
|
||||||
|
ns.login_fail_count = 0
|
||||||
ns1 = DictObject(id=id, orgname=ns.username)
|
ns1 = DictObject(id=id, orgname=ns.username)
|
||||||
await create_org(sor, ns1)
|
await create_org(sor, ns1)
|
||||||
await create_user(sor, ns)
|
await create_user(sor, ns)
|
||||||
@ -105,19 +108,80 @@ def get_dbname():
|
|||||||
return f('rbac')
|
return f('rbac')
|
||||||
|
|
||||||
async def checkUserPassword(request, username, password):
|
async def checkUserPassword(request, username, password):
|
||||||
|
"""Authenticate user with password, supporting login lockout mechanism.
|
||||||
|
|
||||||
|
After 3 consecutive failed login attempts, the user is locked out for 5 minutes.
|
||||||
|
On successful login, last_login is updated and fail count is reset.
|
||||||
|
"""
|
||||||
db = DBPools()
|
db = DBPools()
|
||||||
dbname = get_dbname()
|
dbname = get_dbname()
|
||||||
async with db.sqlorContext(dbname) as sor:
|
async with db.sqlorContext(dbname) as sor:
|
||||||
sql = "select * from users where username=${username}$ and password=${password}$"
|
# Get user record including login status fields
|
||||||
recs = await sor.sqlExe(sql, {'username':username, 'password':password})
|
sql = "select * from users where username=${username}$"
|
||||||
|
recs = await sor.sqlExe(sql, {'username': username})
|
||||||
if len(recs) < 1:
|
if len(recs) < 1:
|
||||||
return False
|
return False
|
||||||
await user_login(request, recs[0].id,
|
|
||||||
username=recs[0].username,
|
user = recs[0]
|
||||||
userorgid=recs[0].orgid)
|
|
||||||
|
# Check login lockout: 3 consecutive failures within 5 minutes
|
||||||
|
fail_count = getattr(user, 'login_fail_count', 0) or 0
|
||||||
|
last_fail = getattr(user, 'last_login_fail', None)
|
||||||
|
|
||||||
|
if fail_count >= 3 and last_fail:
|
||||||
|
# Calculate time elapsed since last failed attempt
|
||||||
|
now_ts = time.time()
|
||||||
|
fail_ts = _parse_timestamp(last_fail)
|
||||||
|
elapsed = now_ts - fail_ts
|
||||||
|
if elapsed < 300: # 5 minutes = 300 seconds
|
||||||
|
remaining = int(300 - elapsed)
|
||||||
|
debug(f'User {username} locked out, {remaining}s remaining')
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Lockout period expired, reset fail count
|
||||||
|
await sor.U('users', {'id': user.id}, {
|
||||||
|
'login_fail_count': 0,
|
||||||
|
'last_login_fail': None
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check password
|
||||||
|
sql = "select * from users where username=${username}$ and password=${password}$"
|
||||||
|
recs = await sor.sqlExe(sql, {'username': username, 'password': password})
|
||||||
|
if len(recs) < 1:
|
||||||
|
# Password wrong - increment fail count
|
||||||
|
new_fail_count = fail_count + 1
|
||||||
|
await sor.U('users', {'id': user.id}, {
|
||||||
|
'login_fail_count': new_fail_count,
|
||||||
|
'last_login_fail': curDateString('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
debug(f'Login failed for {username}, fail_count={new_fail_count}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Login successful - reset fail count, update last_login
|
||||||
|
await sor.U('users', {'id': user.id}, {
|
||||||
|
'login_fail_count': 0,
|
||||||
|
'last_login_fail': None,
|
||||||
|
'last_login': curDateString('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
await user_login(request, user.id,
|
||||||
|
username=user.username,
|
||||||
|
userorgid=user.orgid)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _parse_timestamp(ts):
|
||||||
|
"""Parse a timestamp string to unix timestamp."""
|
||||||
|
from datetime import datetime
|
||||||
|
if ts is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(ts, (int, float)):
|
||||||
|
return ts
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(str(ts), '%Y-%m-%d %H:%M:%S')
|
||||||
|
return dt.timestamp()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
async def basic_auth(sor, request):
|
async def basic_auth(sor, request):
|
||||||
auth = request.headers.get('Authorization')
|
auth = request.headers.get('Authorization')
|
||||||
auther = BasicAuth('x')
|
auther = BasicAuth('x')
|
||||||
@ -128,6 +192,12 @@ async def basic_auth(sor, request):
|
|||||||
recs = await sor.sqlExe(sql, {'username':username,'password':password})
|
recs = await sor.sqlExe(sql, {'username':username,'password':password})
|
||||||
if len(recs) < 1:
|
if len(recs) < 1:
|
||||||
return None
|
return None
|
||||||
|
# Update last_login on successful basic auth
|
||||||
|
await sor.U('users', {'id': recs[0].id}, {
|
||||||
|
'last_login': curDateString('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'login_fail_count': 0,
|
||||||
|
'last_login_fail': None
|
||||||
|
})
|
||||||
await user_login(request, recs[0].id,
|
await user_login(request, recs[0].id,
|
||||||
username=recs[0].username,
|
username=recs[0].username,
|
||||||
userorgid=recs[0].orgid)
|
userorgid=recs[0].orgid)
|
||||||
|
|||||||
@ -43,3 +43,7 @@ def load_rbac():
|
|||||||
env.sor_get_org_users = sor_get_org_users
|
env.sor_get_org_users = sor_get_org_users
|
||||||
env.get_owner_orgid = get_owner_orgid
|
env.get_owner_orgid = get_owner_orgid
|
||||||
env.sor_add_user_roles = sor_add_user_roles
|
env.sor_add_user_roles = sor_add_user_roles
|
||||||
|
# Cache invalidation methods for use after role/permission changes
|
||||||
|
env.invalidate_user_perm_cache = env.userpermissions.invalidate_user_cache
|
||||||
|
env.invalidate_all_perm_caches = env.userpermissions.invalidate_all_user_caches
|
||||||
|
env.invalidate_role_perm_cache = env.userpermissions.invalidate_rp_cache
|
||||||
|
|||||||
152
rbac/userperm.py
152
rbac/userperm.py
@ -1,50 +1,138 @@
|
|||||||
import time
|
import time
|
||||||
|
import threading
|
||||||
|
from collections import OrderedDict
|
||||||
from sqlor.dbpools import DBPools, get_sor_context
|
from sqlor.dbpools import DBPools, get_sor_context
|
||||||
from ahserver.serverenv import ServerEnv
|
from ahserver.serverenv import ServerEnv
|
||||||
from appPublic.Singleton import SingletonDecorator
|
from appPublic.Singleton import SingletonDecorator
|
||||||
from appPublic.log import debug, exception, error
|
from appPublic.log import debug, exception, error
|
||||||
|
|
||||||
|
class LRUCache:
|
||||||
|
"""Thread-safe LRU cache with TTL support."""
|
||||||
|
|
||||||
|
def __init__(self, maxsize=10000, ttl=300):
|
||||||
|
self.maxsize = maxsize
|
||||||
|
self.ttl = ttl # seconds
|
||||||
|
self._cache = OrderedDict()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
with self._lock:
|
||||||
|
if key not in self._cache:
|
||||||
|
return None
|
||||||
|
value, expire_at = self._cache[key]
|
||||||
|
if time.time() > expire_at:
|
||||||
|
del self._cache[key]
|
||||||
|
return None
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def set(self, key, value):
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
self._cache[key] = (value, time.time() + self.ttl)
|
||||||
|
while len(self._cache) > self.maxsize:
|
||||||
|
self._cache.popitem(last=False)
|
||||||
|
|
||||||
|
def invalidate(self, key):
|
||||||
|
with self._lock:
|
||||||
|
self._cache.pop(key, None)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
with self._lock:
|
||||||
|
self._cache.clear()
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return self.get(key) is not None
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self._cache)
|
||||||
|
|
||||||
|
|
||||||
@SingletonDecorator
|
@SingletonDecorator
|
||||||
class UserPermissions:
|
class UserPermissions:
|
||||||
def __init__(self, max_cache_user=10000):
|
def __init__(self, max_cache_user=10000, cache_ttl=300, rp_cache_ttl=600):
|
||||||
|
"""Initialize UserPermissions with secure caching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_cache_user: Maximum number of user role entries in cache
|
||||||
|
cache_ttl: TTL for user role caches in seconds (default 5 minutes)
|
||||||
|
rp_cache_ttl: TTL for role-permission caches in seconds (default 10 minutes)
|
||||||
|
"""
|
||||||
self.max_cache_user = max_cache_user
|
self.max_cache_user = max_cache_user
|
||||||
self.cups = {}
|
self.cache_ttl = cache_ttl
|
||||||
|
self.rp_cache_ttl = rp_cache_ttl
|
||||||
|
|
||||||
|
# LRU cache for user roles: userid -> list of roles
|
||||||
|
self.ur_caches = LRUCache(maxsize=max_cache_user, ttl=cache_ttl)
|
||||||
|
|
||||||
|
# Role-permission cache: role_key -> list of paths
|
||||||
self.rp_caches = None
|
self.rp_caches = None
|
||||||
self.ur_caches = {}
|
self.rp_cache_loaded_at = 0
|
||||||
|
|
||||||
|
# Lock for rp_caches initialization
|
||||||
|
self._rp_lock = threading.Lock()
|
||||||
|
|
||||||
async def get_user_roles(self, userid):
|
async def get_user_roles(self, userid):
|
||||||
|
"""Get roles for a user, with LRU+TTL caching."""
|
||||||
if userid is None:
|
if userid is None:
|
||||||
return ['anonymous', 'any']
|
return ['anonymous', 'any']
|
||||||
|
|
||||||
roles = self.ur_caches.get(userid)
|
roles = self.ur_caches.get(userid)
|
||||||
if roles:
|
if roles:
|
||||||
return roles
|
return roles
|
||||||
|
|
||||||
async with get_sor_context(ServerEnv(), 'rbac') as sor:
|
async with get_sor_context(ServerEnv(), 'rbac') as sor:
|
||||||
await self.get_userroles(sor, userid)
|
await self.get_userroles(sor, userid)
|
||||||
return self.ur_caches.get(userid)
|
return self.ur_caches.get(userid)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def invalidate_user_cache(self, userid):
|
||||||
|
"""Invalidate cache for a specific user.
|
||||||
|
Call this after role changes, user creation, etc.
|
||||||
|
"""
|
||||||
|
self.ur_caches.invalidate(userid)
|
||||||
|
|
||||||
|
def invalidate_all_user_caches(self):
|
||||||
|
"""Invalidate all user role caches."""
|
||||||
|
self.ur_caches.clear()
|
||||||
|
|
||||||
|
def invalidate_rp_cache(self):
|
||||||
|
"""Invalidate role-permission cache (after permission changes)."""
|
||||||
|
self.rp_caches = None
|
||||||
|
self.rp_cache_loaded_at = 0
|
||||||
|
|
||||||
async def load_roleperms(self, sor):
|
async def load_roleperms(self, sor):
|
||||||
self.rp_caches = {}
|
"""Load all role-permission mappings into cache."""
|
||||||
sql_all = """select c.id, c.orgtypeid, c.name, b.path
|
now = time.time()
|
||||||
|
# Double-check with lock to prevent race conditions
|
||||||
|
with self._rp_lock:
|
||||||
|
if self.rp_caches is not None and (now - self.rp_cache_loaded_at) < self.rp_cache_ttl:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.rp_caches = {}
|
||||||
|
sql_all = """select c.id, c.orgtypeid, c.name, b.path
|
||||||
from rolepermission a, permission b, role c
|
from rolepermission a, permission b, role c
|
||||||
where a.permid = b.id
|
where a.permid = b.id
|
||||||
and c.id = a.roleid
|
and c.id = a.roleid
|
||||||
order by c.orgtypeid, c.name"""
|
order by c.orgtypeid, c.name"""
|
||||||
recs = await sor.sqlExe(sql_all, {})
|
recs = await sor.sqlExe(sql_all, {})
|
||||||
for r in recs:
|
for r in recs:
|
||||||
if r.id == 'anonymous':
|
if r.id == 'anonymous':
|
||||||
k = 'anonymous'
|
k = 'anonymous'
|
||||||
elif r.id == 'any':
|
elif r.id == 'any':
|
||||||
k = 'any'
|
k = 'any'
|
||||||
elif r.id == 'logined':
|
elif r.id == 'logined':
|
||||||
k = 'logined'
|
k = 'logined'
|
||||||
else:
|
else:
|
||||||
k = f'{r.orgtypeid}.{r.name}'
|
k = f'{r.orgtypeid}.{r.name}'
|
||||||
arr = self.rp_caches.get(k, [])
|
arr = self.rp_caches.get(k, [])
|
||||||
arr.append(r.path)
|
arr.append(r.path)
|
||||||
self.rp_caches[k] = arr
|
self.rp_caches[k] = arr
|
||||||
|
self.rp_cache_loaded_at = now
|
||||||
|
|
||||||
async def get_userroles(self, sor, userid):
|
async def get_userroles(self, sor, userid):
|
||||||
|
"""Load user roles from database and cache them."""
|
||||||
recs = await sor.sqlExe('''select b.id, b.orgtypeid, b.name
|
recs = await sor.sqlExe('''select b.id, b.orgtypeid, b.name
|
||||||
from users a, role b, userrole c
|
from users a, role b, userrole c
|
||||||
where a.id = c.userid
|
where a.id = c.userid
|
||||||
@ -55,9 +143,10 @@ where a.id = c.userid
|
|||||||
roles.append(f'{r.orgtypeid}.{r.name}')
|
roles.append(f'{r.orgtypeid}.{r.name}')
|
||||||
roles.append(f'{r.orgtypeid}.*')
|
roles.append(f'{r.orgtypeid}.*')
|
||||||
roles.append(f'*.{r.name}')
|
roles.append(f'*.{r.name}')
|
||||||
self.ur_caches[userid] = sorted(list(set(roles)))
|
self.ur_caches.set(userid, sorted(list(set(roles))))
|
||||||
|
|
||||||
def check_roles_path(self, roles, path):
|
def check_roles_path(self, roles, path):
|
||||||
|
"""Check if any of the roles has access to the given path."""
|
||||||
ret = False
|
ret = False
|
||||||
for role in roles:
|
for role in roles:
|
||||||
paths = self.rp_caches.get(role)
|
paths = self.rp_caches.get(role)
|
||||||
@ -65,22 +154,27 @@ where a.id = c.userid
|
|||||||
continue
|
continue
|
||||||
if path in paths:
|
if path in paths:
|
||||||
return True
|
return True
|
||||||
return False
|
return ret
|
||||||
|
|
||||||
async def is_user_has_path_perm(self, userid, path):
|
async def is_user_has_path_perm(self, userid, path):
|
||||||
|
"""Check if a user has permission for the given path.
|
||||||
|
|
||||||
|
Security improvements:
|
||||||
|
1. rp_caches now has TTL to ensure permission changes take effect
|
||||||
|
2. User role cache uses LRU+TTL to prevent unbounded growth
|
||||||
|
3. Race condition protection with lock during rp_caches initialization
|
||||||
|
"""
|
||||||
roles = self.ur_caches.get(userid)
|
roles = self.ur_caches.get(userid)
|
||||||
if userid is None:
|
if userid is None:
|
||||||
roles = ['any', 'anonymous']
|
roles = ['any', 'anonymous']
|
||||||
|
|
||||||
if self.rp_caches is None or not roles:
|
if self.rp_caches is None or not roles:
|
||||||
env = ServerEnv()
|
env = ServerEnv()
|
||||||
async with get_sor_context(env, 'rbac') as sor:
|
async with get_sor_context(env, 'rbac') as sor:
|
||||||
if not self.rp_caches:
|
if self.rp_caches is None:
|
||||||
await self.load_roleperms(sor)
|
await self.load_roleperms(sor)
|
||||||
if not roles:
|
if not roles:
|
||||||
await self.get_userroles(sor, userid)
|
await self.get_userroles(sor, userid)
|
||||||
roles = self.ur_caches.get(userid)
|
roles = self.ur_caches.get(userid)
|
||||||
|
|
||||||
return self.check_roles_path(roles, path)
|
return self.check_roles_path(roles, path)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,12 @@ async with get_sor_context(request._run_ns, 'rbac') as sor:
|
|||||||
if recs:
|
if recs:
|
||||||
if len(recs) == 1:
|
if len(recs) == 1:
|
||||||
r = recs[0]
|
r = recs[0]
|
||||||
|
# Update last_login
|
||||||
|
await sor.U('users', {'id': r.id}, {
|
||||||
|
'last_login': curDateString('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'login_fail_count': 0,
|
||||||
|
'last_login_fail': None
|
||||||
|
})
|
||||||
await remember_user(r.id, username=r.username, userorgid=r.orgid)
|
await remember_user(r.id, username=r.username, userorgid=r.orgid)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@ -53,6 +59,12 @@ async with get_sor_context(request._run_ns, 'rbac') as sor:
|
|||||||
if params_kw.selected_id:
|
if params_kw.selected_id:
|
||||||
for r in recs:
|
for r in recs:
|
||||||
if r.id == params_kw.selected_id:
|
if r.id == params_kw.selected_id:
|
||||||
|
# Update last_login
|
||||||
|
await sor.U('users', {'id': r.id}, {
|
||||||
|
'last_login': curDateString('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'login_fail_count': 0,
|
||||||
|
'last_login_fail': None
|
||||||
|
})
|
||||||
await remember_user(r.id, username=r.username, userorgid=r.orgid)
|
await remember_user(r.id, username=r.username, userorgid=r.orgid)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
|
|||||||
@ -9,7 +9,7 @@ info(f'{ns=}')
|
|||||||
db = DBPools()
|
db = DBPools()
|
||||||
dbname = get_module_dbname('rbac')
|
dbname = get_module_dbname('rbac')
|
||||||
async with db.sqlorContext(dbname) as sor:
|
async with db.sqlorContext(dbname) as sor:
|
||||||
r = await sor.sqlExe('select * from users where username=${username}$ and password=${password}$', ns.copy())
|
r = await sor.sqlExe('select * from users where username=${username}$', ns.copy())
|
||||||
if len(r) == 0:
|
if len(r) == 0:
|
||||||
return {
|
return {
|
||||||
"widgettype":"Error",
|
"widgettype":"Error",
|
||||||
@ -19,6 +19,47 @@ async with db.sqlorContext(dbname) as sor:
|
|||||||
"message":"user name or password error"
|
"message":"user name or password error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
user = r[0]
|
||||||
|
|
||||||
|
# Check login lockout
|
||||||
|
fail_count = getattr(user, 'login_fail_count', 0) or 0
|
||||||
|
last_fail = getattr(user, 'last_login_fail', None)
|
||||||
|
if fail_count >= 3 and last_fail:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"timeout":5,
|
||||||
|
"title":"Account Locked",
|
||||||
|
"message":"Account locked due to too many failed login attempts. Please try again in 5 minutes."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r = await sor.sqlExe('select * from users where username=${username}$ and password=${password}$', ns.copy())
|
||||||
|
if len(r) == 0:
|
||||||
|
# Increment fail count
|
||||||
|
new_fail_count = fail_count + 1
|
||||||
|
await sor.U('users', {'id': user.id}, {
|
||||||
|
'login_fail_count': new_fail_count,
|
||||||
|
'last_login_fail': curDateString('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
|
if new_fail_count >= 3:
|
||||||
|
msg = "Too many failed attempts. Account locked for 5 minutes."
|
||||||
|
else:
|
||||||
|
msg = f"user name or password error ({3 - new_fail_count} attempts remaining)"
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"timeout":3,
|
||||||
|
"title":"Login Error",
|
||||||
|
"message": msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Success - reset fail count, update last_login
|
||||||
|
await sor.U('users', {'id': user.id}, {
|
||||||
|
'login_fail_count': 0,
|
||||||
|
'last_login_fail': None,
|
||||||
|
'last_login': curDateString('%Y-%m-%d %H:%M:%S')
|
||||||
|
})
|
||||||
await remember_user(r[0].id, username=r[0].username, userorgid=r[0].orgid)
|
await remember_user(r[0].id, username=r[0].username, userorgid=r[0].orgid)
|
||||||
return {
|
return {
|
||||||
"widgettype":"Message",
|
"widgettype":"Message",
|
||||||
@ -55,4 +96,3 @@ return {
|
|||||||
"message":"system error"
|
"message":"system error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,18 @@ if not passwd:
|
|||||||
passwd = password_encode(passwd)
|
passwd = password_encode(passwd)
|
||||||
rzt = await check_user_password(request, username, passwd)
|
rzt = await check_user_password(request, username, passwd)
|
||||||
if rzt:
|
if rzt:
|
||||||
return UiMessage(title='Logined', message=f'Welcome back ')
|
return UiMessage(title='Logined', message='Welcome back')
|
||||||
return UiError(title='login failed', message='user and password mismatch')
|
|
||||||
|
# Check if account is locked for better error message
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('rbac')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
r = await sor.sqlExe('select login_fail_count, last_login_fail from users where username=${username}$', {'username': username})
|
||||||
|
if r:
|
||||||
|
fail_count = getattr(r[0], 'login_fail_count', 0) or 0
|
||||||
|
if fail_count >= 3:
|
||||||
|
return UiError(title='Account Locked', message='Account locked due to too many failed login attempts. Please try again in 5 minutes.')
|
||||||
|
remaining = 3 - fail_count
|
||||||
|
return UiError(title='Login failed', message=f'User and password mismatch ({remaining} attempts remaining)')
|
||||||
|
|
||||||
|
return UiError(title='Login failed', message='User and password mismatch')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user