chg: [2FA] user + admin manage 2FA OTP

This commit is contained in:
terrtia 2024-06-27 17:06:05 +02:00
parent 073181fbd8
commit 12e260a4d9
No known key found for this signature in database
GPG key ID: 1E1B1F50D84613D0
9 changed files with 317 additions and 31 deletions

View file

@ -167,10 +167,13 @@ def verify_user_totp(user_id, code):
totp = _get_totp(get_user_otp_secret(user_id))
return _verify_totp(totp, code)
def verify_user_hotp(user_id, code): # TODO IF valid increase counter
def verify_user_hotp(user_id, code):
hotp = _get_hotp(get_user_otp_secret(user_id))
counter = get_user_hotp_counter(user_id)
return _verify_hotp(hotp, counter, code)
valid = _verify_hotp(hotp, counter, code)
if valid:
r_serv_db.hincrby(f'ail:user:metadata:{user_id}', 'otp_counter', 1)
return valid
def verify_user_otp(user_id, code):
if verify_user_totp(user_id, code):
@ -298,6 +301,10 @@ class AILUser(UserMixin):
meta['api_key'] = self.get_api_key()
if 'role' in options:
meta['role'] = get_user_role(self.user_id)
if '2fa' in options:
meta['2fa'] = self.is_2fa_enabled()
if 'otp_setup' in options:
meta['otp_setup'] = self.is_2fa_setup()
return meta
## SESSION ##
@ -381,7 +388,8 @@ class AILUser(UserMixin):
def init_setup_2fa(self, create=True):
if create:
create_user_otp(self.user_id)
return get_user_otp_qr_code(self.user_id, 'AIL TEST'), get_user_hotp_code(self.user_id)
instance_name = 'AIL TEST'
return get_user_otp_qr_code(self.user_id, instance_name), get_user_otp_uri(self.user_id, instance_name), get_user_hotp_code(self.user_id)
def setup_2fa(self):
r_serv_db.hset(f'ail:user:metadata:{self.user_id}', 'otp_setup', 1)
@ -418,20 +426,62 @@ class AILUser(UserMixin):
def api_get_users_meta():
meta = {'users': []}
options = {'api_key', 'role'}
options = {'api_key', 'role', '2fa', 'otp_setup'}
for user_id in get_users():
user = AILUser(user_id)
meta['users'].append(user.get_meta(options=options))
return meta
def api_get_user_profile(user_id):
options = {'api_key', 'role'}
options = {'api_key', 'role', '2fa'}
user = AILUser(user_id)
if not user.exists():
return {'status': 'error', 'reason': 'User not found'}, 404
meta = user.get_meta(options=options)
return meta, 200
def api_get_user_hotp(user_id):
user = AILUser(user_id)
if not user.exists():
return {'status': 'error', 'reason': 'User not found'}, 404
hotp = get_user_hotp_code(user_id)
return hotp, 200
def api_enable_user_otp(user_id):
user = AILUser(user_id)
if not user.exists():
return {'status': 'error', 'reason': 'User not found'}, 404
if user.is_2fa_enabled():
return {'status': 'error', 'reason': 'User OTP is already setup'}, 400
delete_user_otp(user_id)
enable_user_2fa(user_id)
return user_id, 200
def api_disable_user_otp(user_id):
user = AILUser(user_id)
if not user.exists():
return {'status': 'error', 'reason': 'User not found'}, 404
if not user.is_2fa_enabled():
return {'status': 'error', 'reason': 'User OTP is not enabled'}, 400
if is_2fa_enabled():
return {'status': 'error', 'reason': '2FA is enforced on this instance'}, 400
disable_user_2fa(user_id)
delete_user_otp(user_id)
return user_id, 200
def api_reset_user_otp(admin_id, user_id):
user = AILUser(user_id)
if not user.exists():
return {'status': 'error', 'reason': 'User not found'}, 404
admin = AILUser(admin_id)
if not admin.is_in_role('admin'):
return {'status': 'error', 'reason': 'Access Denied'}, 403
if not user.is_2fa_setup():
return {'status': 'error', 'reason': 'User OTP is not setup'}, 400
delete_user_otp(user_id)
enable_user_2fa(user_id)
return user_id, 200
def api_create_user_api_key_self(user_id): # TODO LOG USER ID
user = AILUser(user_id)
if not user.exists():
@ -471,7 +521,7 @@ def get_users_metadata(list_users):
users.append(get_user_metadata(user))
return users
def create_user(user_id, password=None, chg_passwd=True, role=None): # TODO ###############################################################
def create_user(user_id, password=None, chg_passwd=True, role=None, otp=False): # TODO ###############################################################
# # TODO: check password strength
if password:
new_password = password
@ -482,7 +532,7 @@ def create_user(user_id, password=None, chg_passwd=True, role=None): # TODO ####
# EDIT
if exists_user(user_id):
if password or chg_passwd:
edit_user_password(user_id, password_hash, chg_passwd=chg_passwd)
edit_user(user_id, password_hash, chg_passwd=chg_passwd)
if role:
edit_user_role(user_id, role)
# CREATE USER
@ -503,7 +553,10 @@ def create_user(user_id, password=None, chg_passwd=True, role=None): # TODO ####
# create user token
generate_new_token(user_id)
def edit_user_password(user_id, password_hash, chg_passwd=False):
if otp or is_2fa_enabled():
enable_user_2fa(user_id)
def edit_user(user_id, password_hash, chg_passwd=False, otp=False): # TODO ######################################################3333
if chg_passwd:
r_serv_db.hset(f'ail:user:metadata:{user_id}', 'change_passwd', 'True')
else:
@ -517,6 +570,11 @@ def edit_user_password(user_id, password_hash, chg_passwd=False):
# create new token
generate_new_token(user_id)
if otp or is_2fa_enabled():
enable_user_2fa(user_id)
else:
disable_user_2fa(user_id)
# # TODO: solve edge_case self delete
def delete_user(user_id):
if exists_user(user_id):

View file

@ -87,8 +87,10 @@ def login():
if not user.is_2fa_setup():
return redirect(url_for('root.setup_2fa'))
else:
htop_counter = user.get_htop_counter()
return redirect(url_for('root.verify_2fa', htop_counter=htop_counter))
if next_page and next_page != 'None' and next_page != '/':
return redirect(url_for('root.verify_2fa', next=next_page))
else:
return redirect(url_for('root.verify_2fa'))
else:
# Login User
@ -152,6 +154,7 @@ def verify_2fa():
if request.method == 'POST':
code = request.form.get('otp')
next_page = request.form.get('next_page')
if user.is_valid_otp(code):
session.pop('user_id', None)
@ -164,20 +167,19 @@ def verify_2fa():
if user.request_password_change():
return redirect(url_for('root.change_password'))
else:
# # next page
# if next_page and next_page != 'None' and next_page != '/':
# return redirect(next_page)
# dashboard
# else:
# NEXT PAGE
if next_page and next_page != 'None' and next_page != '/':
return redirect(next_page)
return redirect(url_for('dashboard.index'))
else:
htop_counter = user.get_htop_counter()
error = "The OTP is incorrect or has expired"
return render_template("verify_otp.html", htop_counter=htop_counter, error=error)
return render_template("verify_otp.html", htop_counter=htop_counter, next_page=next_page, error=error)
else:
htop_counter = user.get_htop_counter()
return render_template("verify_otp.html", htop_counter=htop_counter)
next_page = request.args.get('next')
return render_template("verify_otp.html", htop_counter=htop_counter, next_page=next_page)
@root.route('/2fa/setup', methods=['POST', 'GET'])
def setup_2fa():
@ -222,10 +224,10 @@ def setup_2fa():
else:
error = request.args.get('error')
if error:
qr_code, hotp_codes = user.init_setup_2fa(create=False)
qr_code, otp_url, hotp_codes = user.init_setup_2fa(create=False)
else:
qr_code, hotp_codes = user.init_setup_2fa()
return render_template("setup_otp.html", qr_code=qr_code, hotp_codes=hotp_codes, error=error)
qr_code, otp_url, hotp_codes = user.init_setup_2fa()
return render_template("setup_otp.html", qr_code=qr_code, hotp_codes=hotp_codes, otp_url=otp_url, error=error)
@root.route('/change_password', methods=['POST', 'GET'])
@login_required

View file

@ -70,7 +70,94 @@ def user_profile():
if r[1] != 200:
return create_json_response(r[0], r[1])
meta = r[0]
return render_template("user_profile.html", meta=meta, acl_admin=acl_admin)
global_2fa = ail_users.is_2fa_enabled()
return render_template("user_profile.html", meta=meta, global_2fa=global_2fa,acl_admin=acl_admin)
@settings_b.route("/settings/user/hotp", methods=['GET'])
@login_required
@login_read_only
def user_hotp():
# if not current_user.is_authenticated: # TODO CHECK IF FRESH LOGIN/SESSION -> check last loging time -> rerequest if expired
acl_admin = current_user.is_in_role('admin')
user_id = current_user.get_user_id()
r = ail_users.api_get_user_hotp(user_id)
if r[1] != 200:
return create_json_response(r[0], r[1])
hotp = r[0]
return render_template("user_hotp.html", hotp=hotp, acl_admin=acl_admin)
@settings_b.route("/settings/user/otp/enable/self", methods=['GET'])
@login_required
@login_read_only
def user_otp_enable_self():
user_id = current_user.get_user_id()
r = ail_users.api_enable_user_otp(user_id)
if r[1] != 200:
return create_json_response(r[0], r[1])
current_user.kill_session()
return redirect(url_for('settings_b.user_profile'))
@settings_b.route("/settings/user/otp/disable/self", methods=['GET'])
@login_required
@login_read_only
def user_otp_disable_self():
user_id = current_user.get_user_id()
r = ail_users.api_disable_user_otp(user_id)
if r[1] != 200:
return create_json_response(r[0], r[1])
current_user.kill_session()
return redirect(url_for('settings_b.user_profile'))
@settings_b.route("/settings/user/otp/reset/self", methods=['GET'])
@login_required
@login_admin
def user_otp_reset_self(): # TODO ask for password ?
user_id = current_user.get_user_id()
r = ail_users.api_reset_user_otp(user_id, user_id)
if r[1] != 200:
return create_json_response(r[0], r[1])
else:
current_user.kill_session()
return redirect(url_for('settings_b.user_profile'))
@settings_b.route("/settings/user/otp/enable", methods=['GET'])
@login_required
@login_admin
def user_otp_enable():
user_id = request.args.get('user_id')
r = ail_users.api_enable_user_otp(user_id)
if r[1] != 200:
return create_json_response(r[0], r[1])
user = ail_users.AILUser.get(user_id)
user.kill_session()
return redirect(url_for('settings_b.users_list'))
@settings_b.route("/settings/user/otp/disable", methods=['GET'])
@login_required
@login_admin
def user_otp_disable():
user_id = request.args.get('user_id')
r = ail_users.api_disable_user_otp(user_id)
if r[1] != 200:
return create_json_response(r[0], r[1])
user = ail_users.AILUser.get(user_id)
user.kill_session()
return redirect(url_for('settings_b.users_list'))
@settings_b.route("/settings/user/otp/reset", methods=['GET'])
@login_required
@login_admin
def user_otp_reset(): # TODO ask for password ?
user_id = request.args.get('user_id')
admin_id = current_user.get_user_id()
r = ail_users.api_reset_user_otp(admin_id, user_id)
if r[1] != 200:
return create_json_response(r[0], r[1])
else:
user = ail_users.AILUser.get(user_id)
user.kill_session()
return redirect(url_for('settings_b.user_profile'))
@settings_b.route("/settings/user/api_key/new", methods=['GET'])
@login_required
@ -132,6 +219,11 @@ def create_user_post():
role = request.form.get('user_role')
password1 = request.form.get('password1')
password2 = request.form.get('password2')
enable_2_fa = request.form.get('enable_2_fa')
if enable_2_fa or ail_users.is_2fa_enabled():
enable_2_fa = True
else:
enable_2_fa = False
all_roles = ail_users.get_all_roles()
@ -156,14 +248,14 @@ def create_user_post():
if not password1 and not password2:
password = None
str_password = 'Password not changed'
ail_users.create_user(email, password=password, role=role)
new_user = {'email': email, 'password': str_password}
ail_users.create_user(email, password=password, role=role, otp=enable_2_fa)
new_user = {'email': email, 'password': str_password, 'otp': enable_2_fa}
return render_template("create_user.html", new_user=new_user, meta={}, all_roles=all_roles, acl_admin=True)
else:
return render_template("create_user.html", all_roles=all_roles, acl_admin=True)
return render_template("create_user.html", all_roles=all_roles, meta={}, acl_admin=True)
else:
return render_template("create_user.html", all_roles=all_roles, error_mail=True, acl_admin=True)
return render_template("create_user.html", all_roles=all_roles, meta={}, error_mail=True, acl_admin=True)

View file

@ -65,6 +65,11 @@
{% endfor %}
</select>
<div class="custom-control custom-switch mt-4 mb-3">
<input type="checkbox" class="custom-control-input" id="enable_2_fa" name="enable_2_fa" checked>
<label class="custom-control-label" for="enable_2_fa">2FA OTP</label>
</div>
<div class="custom-control custom-switch mt-4 mb-3">
<input type="checkbox" class="custom-control-input" id="set_manual_password" value="" onclick="toggle_password_fields();">
<label class="custom-control-label" for="set_manual_password">{% if meta['id'] %}Reset{% else %}Set{% endif %} Password</label>

View file

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html>
<head>
<title>User Profile - AIL</title>
<link rel="icon" href="{{ url_for('static', filename='image/ail-icon.png') }}">
<!-- Core CSS -->
<link href="{{ url_for('static', filename='css/bootstrap4.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/font-awesome.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/dataTables.bootstrap.min.css') }}" rel="stylesheet">
<!-- JS -->
<script src="{{ url_for('static', filename='js/jquery.js')}}"></script>
<script src="{{ url_for('static', filename='js/popper.min.js')}}"></script>
<script src="{{ url_for('static', filename='js/bootstrap4.min.js')}}"></script>
<script src="{{ url_for('static', filename='js/jquery.dataTables.min.js')}}"></script>
<script src="{{ url_for('static', filename='js/dataTables.bootstrap.min.js')}}"></script>
</head>
<body>
{% include 'nav_bar.html' %}
<div class="container-fluid">
<div class="row">
{% include 'settings/menu_sidebar.html' %}
<div class="col-12 col-lg-10" id="core_content">
<h1 class="text-center mt-2">HOTP - Paper-Based Single Use Tokens</h1>
<div>In case you don't have access to your phone or authentication software, please use the following list of tokens.</div>
<div>Be sure to print these tokens and keep them in a secure place for future use.</div>
<div class="text-center my-4">
{% for code in hotp %}
<div><i>{{ code[:-6] }}</i> <b>{{ code[-6:] }}</b></div>
{% endfor %}
</div>
</div>
</div>
</div>
</body>
<script>
$(document).ready(function(){
$("#nav_edit_profile").addClass("active");
//$("#nav_my_profile").removeClass("text-muted");
} );
function toggle_sidebar(){
if($('#nav_menu').is(':visible')){
$('#nav_menu').hide();
$('#side_menu').removeClass('border-right')
$('#side_menu').removeClass('col-lg-2')
$('#core_content').removeClass('col-lg-10')
}else{
$('#nav_menu').show();
$('#side_menu').addClass('border-right')
$('#side_menu').addClass('col-lg-2')
$('#core_content').addClass('col-lg-10')
}
}
</script>
</html>

View file

@ -62,6 +62,35 @@
</span>
</td>
</tr>
<tr>
<td>2FA OTP</td>
<td>
{% if meta['2fa'] %}
<span class="badge badge-success" style="font-size: 1.0rem;"><b>YES</b></span>
{% if acl_admin %}
<div>
<a class="btn btn-sm btn-danger mt-1" href="{{url_for('settings_b.user_otp_reset_self')}}"><i class="fas fa-sync"></i> RESET OTP</a>
</div>
{% endif %}
{% if not global_2fa %}
<div>
<a class="btn btn-sm btn-danger mt-1" href="{{url_for('settings_b.user_otp_disable_self')}}"><i class="fas fa-warning"></i> DISABLE 2FA OTP</a>
</div>
{% endif %}
{% else %}
<span class="badge badge-danger" style="font-size: 1.0rem;"><b>NO</b></span>
<div>
<a class="btn btn-success" href="{{url_for('settings_b.user_otp_enable_self')}}"><i class="fas fa-plus"></i> ACTIVATE 2FA OTP</a>
</div>
{% endif %}
</td>
</tr>
{% if meta['2fa'] %}
<tr>
<td>TOTP</td>
<td><a class="btn btn-info" href="{{url_for('settings_b.user_hotp')}}"><i class="fas fa-eye"></i> View Paper Tokens</a></td>
</tr>
{% endif %}
</tbody>
</table>
</div>

View file

@ -35,6 +35,7 @@
<th>Role</th>
<th>Api Key</th>
<th></th>
<th>2FA</th>
<th>Actions</th>
</tr>
</thead>
@ -45,7 +46,7 @@
<td>{{user['role']}}</td>
<td>
<span id="censored_key_{{loop.index0}}">
{{user['api_key'][:4]}}*********************************{{user['api_key'][-4:]}}
{{user['api_key'][:4]}}**...**{{user['api_key'][-4:]}}
</span>
<span id="uncensored_key_{{loop.index0}}" style="display: none;">
{{user['api_key']}}
@ -59,6 +60,27 @@
<i class="fas fa-eye"></i>
</span>
</td>
<td>
{% if user['2fa'] %}
{% if user['otp_setup'] %}
<span class="badge badge-success" style="font-size: 1.0rem;"><b>YES</b></span>
<a class="btn btn-outline-danger px-1 py-0" href="{{ url_for('settings_b.user_otp_reset', user_id=user['id']) }}">
<i class="fas fa-random"></i> Reset
</a>
{% else %}
<span class="badge badge-warning" style="font-size: 1.0rem;"><b>ENFORCED</b></span>
{% endif %}
<a class="btn btn-outline-danger px-1 py-0" href="{{ url_for('settings_b.user_otp_disable', user_id=user['id']) }}">
<i class="fas fa-times"></i>
</a>
{% else %}
<span class="badge badge-danger" style="font-size: 1.0rem;"><b>NO</b></span>
<a class="btn btn-outline-success px-1 py-0" href="{{ url_for('settings_b.user_otp_enable', user_id=user['id']) }}">
<i class="fas fa-plus"></i>
</a>
{% endif %}
</td>
<td>
<div class="d-flex justify-content-start">
<a class="btn btn-outline-primary ml-3 px-1 py-0" href="{{ url_for('settings_b.edit_user', user_id=user['id']) }}">

View file

@ -21,13 +21,23 @@
<div class="row">
<div class="col-lg-6">
<h3 class="text-secondary">TOTP</h3>
<h3 class="text-secondary mb-0"><b>TOTP</b></h3>
<img src="data:image/png;base64, {{ qr_code }}">
<div style="font-size: 20px;"> - Install an <b>authenticator application</b> on your mobile.</div>
<div style="font-size: 20px;"> - <b>Scan</b> the QRCode</div>
</div>
<p>
<button class="btn btn-sm btn-info" type="button" data-toggle="collapse" data-target="#collapseExample" aria-expanded="false" aria-controls="collapseExample">
<i class="fas fa-link"></i> <b>TOTP URL</b>
</button>
</p>
<div class="collapse" id="collapseExample">
<div class="card card-body">
{{ otp_url }}
</div>
</div>
</div>
<div class="col-lg-6">
<h3 class="text-secondary">HOTP</h3>
<h3 class="text-secondary"><b>HOTP</b></h3>
{% for code in hotp_codes %}
<div><i>{{ code[:-6] }}</i> <b>{{ code[-6:] }}</b></div>
{% endfor %}

View file

@ -68,10 +68,10 @@
<form class="form-signin" action="{{ url_for('root.verify_2fa')}}" method="post">
<img class="mb-4" src="{{ url_for('static', filename='image/ail-project.png')}}" width="300">
<h1 class="h3 mb-3 text-secondary">2FA: Please Enter your TOTP or HOTP</h1>
<h1 class="h3 mb-3 text-secondary">2FA: Please Enter your TOTP or HOTP #{{ htop_counter }}</h1>
{# <label for="inputEmail" class="sr-only">Email address</label>#}
<input type="text" id="otp" name="otp" class="form-control {% if error %}is-invalid{% endif %}" placeholder="OTP Code" autocomplete="off" required autofocus>
{# <input type="text" id="next_page" name="next_page" value="{{next_page}}" hidden>#}
<input type="text" id="next_page" name="next_page" value="{{next_page}}" hidden>
{% if error %}
<div class="invalid-feedback">
{{error}}