aboutsummaryrefslogtreecommitdiff
path: root/gn_auth/auth/authorisation/users/views.py
diff options
context:
space:
mode:
Diffstat (limited to 'gn_auth/auth/authorisation/users/views.py')
-rw-r--r--gn_auth/auth/authorisation/users/views.py318
1 files changed, 299 insertions, 19 deletions
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index 8135ed3..be4296b 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -1,12 +1,13 @@
"""User authorisation endpoints."""
+import uuid
import sqlite3
import secrets
-import datetime
import traceback
from typing import Any
from functools import partial
from dataclasses import asdict
from urllib.parse import urljoin
+from datetime import datetime, timedelta
from email.headerregistry import Address
from email_validator import validate_email, EmailNotValidError
from flask import (
@@ -27,6 +28,7 @@ from gn_auth.auth.requests import request_json
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.authorisation.resources.checks import authorised_for2
from gn_auth.auth.authorisation.resources.models import (
user_resources as _user_resources)
from gn_auth.auth.authorisation.roles.models import (
@@ -38,6 +40,7 @@ from gn_auth.auth.errors import (
NotFoundError,
UsernameError,
PasswordError,
+ AuthorisationError,
UserRegistrationError)
@@ -113,6 +116,30 @@ def user_address(user: User) -> Address:
"""Compute the `email.headerregistry.Address` from a `User`"""
return Address(display_name=user.name, addr_spec=user.email)
+
+def display_minutes_for_humans(minutes):
+ """Convert minutes into human-readable display."""
+ _week_ = 10080 # minutes
+ _day_ = 1440 # minutes
+ _remainder_ = minutes
+
+ _human_readable_ = ""
+ if _remainder_ >= _week_:
+ _weeks_ = _remainder_ // _week_
+ _remainder_ = _remainder_ % _week_
+ _human_readable_ += f"{_weeks_} week" + ("s" if _weeks_ > 1 else "")
+
+ if _remainder_ >= _day_:
+ _days_ = _remainder_ // _day_
+ _remainder_ = _remainder_ % _day_
+ _human_readable_ += (" " if bool(_human_readable_) else "") + \
+ f"{_days_} day" + ("s" if _days_ > 1 else "")
+
+ if _remainder_ > 0:
+ _human_readable_ += (" " if bool(_human_readable_) else "") + f"{_remainder_} minutes"
+
+ return _human_readable_
+
def send_verification_email(
conn,
user: User,
@@ -123,8 +150,8 @@ def send_verification_email(
"""Send an email verification message."""
subject="GeneNetwork: Please Verify Your Email"
verification_code = secrets.token_urlsafe(64)
- generated = datetime.datetime.now()
- expiration_minutes = 15
+ generated = datetime.now()
+ expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"]
def __render__(template):
return render_template(template,
subject=subject,
@@ -136,7 +163,8 @@ def send_verification_email(
client_id=client_id,
redirect_uri=redirect_uri,
verificationcode=verification_code)),
- expiration_minutes=expiration_minutes)
+ expiration_minutes=display_minutes_for_humans(
+ expiration_minutes))
with db.cursor(conn) as cursor:
cursor.execute(
("INSERT INTO "
@@ -148,12 +176,13 @@ def send_verification_email(
"generated": int(generated.timestamp()),
"expires": int(
(generated +
- datetime.timedelta(
+ timedelta(
minutes=expiration_minutes)).timestamp())
})
- send_message(smtp_user=current_app.config["SMTP_USER"],
- smtp_passwd=current_app.config["SMTP_PASSWORD"],
+ send_message(smtp_user=current_app.config.get("SMTP_USER", ""),
+ smtp_passwd=current_app.config.get("SMTP_PASSWORD", ""),
message=build_email_message(
+ from_address=current_app.config["EMAIL_ADDRESS"],
to_addresses=(user_address(user),),
subject=subject,
txtmessage=__render__("emails/verify-email.txt"),
@@ -178,7 +207,7 @@ def register_user() -> Response:
with db.cursor(conn) as cursor:
user, _hashed_password = set_user_password(
cursor, save_user(
- cursor, email["email"], user_name), password)
+ cursor, email["email"], user_name), password) # type: ignore
assign_default_roles(cursor, user)
send_verification_email(conn,
user,
@@ -187,14 +216,14 @@ def register_user() -> Response:
redirect_uri=form["redirect_uri"])
return jsonify(asdict(user))
except sqlite3.IntegrityError as sq3ie:
- current_app.logger.debug(traceback.format_exc())
+ current_app.logger.error(traceback.format_exc())
raise UserRegistrationError(
"A user with that email already exists") from sq3ie
except EmailNotValidError as enve:
- current_app.logger.debug(traceback.format_exc())
+ current_app.logger.error(traceback.format_exc())
raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve
- raise Exception(
+ raise Exception(# pylint: disable=[broad-exception-raised]
"unknown_error", "The system experienced an unexpected error.")
def delete_verification_code(cursor, code: str):
@@ -235,11 +264,12 @@ def verify_user():
return loginuri
results = results[0]
- if (datetime.datetime.fromtimestamp(
- int(results["expires"])) < datetime.datetime.now()):
+ if (datetime.fromtimestamp(
+ int(results["expires"])) < datetime.now()):
delete_verification_code(cursor, verificationcode)
flash("Invalid verification code: code has expired.",
"alert-danger")
+ return loginuri
# Code is good!
delete_verification_code(cursor, verificationcode)
@@ -303,22 +333,54 @@ def user_join_request_exists():
@require_oauth("profile user")
def list_all_users() -> Response:
"""List all the users."""
- with require_oauth.acquire("profile group") as _the_token:
- return jsonify(tuple(
- asdict(user) for user in with_db_connection(list_users)))
+ _kwargs = {
+ key: value
+ for key, value in request.json.items()
+ if key in ("email", "name", "verified", "age")
+ }
+
+ with (require_oauth.acquire("profile group") as _the_token,
+ db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ _users = list_users(conn, **_kwargs)
+ _start = int(_kwargs.get("start", "0"))
+ _length = int(_kwargs.get("length", "0"))
+ cursor.execute("SELECT COUNT(*) FROM users")
+ _total_users = int(cursor.fetchone()["COUNT(*)"])
+ return jsonify({
+ "users": tuple(asdict(user) for user in
+ (_users[_start:_start+_length]
+ if _length else _users)),
+ "total-users": _total_users,
+ "total-filtered": len(_users)
+ })
@users.route("/handle-unverified", methods=["POST"])
def handle_unverified():
"""Handle case where user tries to login but is unverified"""
- form = request_json()
+ email = request.args["email"]
# TODO: Maybe have a GN2_URI setting here?
# or pass the client_id here?
+ with (db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ cursor.execute(
+ "DELETE FROM user_verification_codes WHERE expires <= ?",
+ (int(datetime.now().timestamp()),))
+ cursor.execute(
+ "SELECT u.user_id, u.email, uvc.* FROM users AS u "
+ "INNER JOIN user_verification_codes AS uvc "
+ "ON u.user_id=uvc.user_id "
+ "WHERE u.email=?",
+ (email,))
+ token_found = bool(cursor.fetchone())
+
return render_template(
"users/unverified-user.html",
- email=form.get("user:email"),
+ email=email,
response_type=request.args["response_type"],
client_id=request.args["client_id"],
- redirect_uri=request.args["redirect_uri"])
+ redirect_uri=request.args["redirect_uri"],
+ token_found=token_found)
@users.route("/send-verification", methods=["POST"])
def send_verification_code():
@@ -350,3 +412,221 @@ def send_verification_code():
})
resp.code = 400
return resp
+
+
+def send_forgot_password_email(
+ conn,
+ user: User,
+ client_id: uuid.UUID,
+ redirect_uri: str,
+ response_type: str
+):
+ """Send the 'forgot-password' email."""
+ subject="GeneNetwork: Change Your Password"
+ token = secrets.token_urlsafe(64)
+ generated = datetime.now()
+ expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"]
+ def __render__(template):
+ return render_template(template,
+ subject=subject,
+ forgot_password_uri=urljoin(
+ request.url,
+ url_for("oauth2.users.change_password",
+ forgot_password_token=token,
+ client_id=client_id,
+ redirect_uri=redirect_uri,
+ response_type=response_type)),
+ expiration_minutes=display_minutes_for_humans(
+ expiration_minutes))
+
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ ("INSERT OR REPLACE INTO "
+ "forgot_password_tokens(user_id, token, generated, expires) "
+ "VALUES (:user_id, :token, :generated, :expires)"),
+ {
+ "user_id": str(user.user_id),
+ "token": token,
+ "generated": int(generated.timestamp()),
+ "expires": int(
+ (generated +
+ timedelta(
+ minutes=expiration_minutes)).timestamp())
+ })
+
+ send_message(smtp_user=current_app.config["SMTP_USER"],
+ smtp_passwd=current_app.config["SMTP_PASSWORD"],
+ message=build_email_message(
+ from_address=current_app.config["EMAIL_ADDRESS"],
+ to_addresses=(user_address(user),),
+ subject=subject,
+ txtmessage=__render__("emails/forgot-password.txt"),
+ htmlmessage=__render__("emails/forgot-password.html")),
+ host=current_app.config["SMTP_HOST"],
+ port=current_app.config["SMTP_PORT"])
+
+
+@users.route("/forgot-password", methods=["GET", "POST"])
+def forgot_password():
+ """Enable user to request password change."""
+ if request.method == "GET":
+ return render_template("users/forgot-password.html",
+ client_id=request.args["client_id"],
+ redirect_uri=request.args["redirect_uri"],
+ response_type=request.args["response_type"])
+
+ form = request.form
+ email = form.get("email", "").strip()
+ if not bool(email):
+ flash("You MUST provide an email.", "alert-danger")
+ return redirect(url_for("oauth2.users.forgot_password"))
+
+ with db.connection(current_app.config["AUTH_DB"]) as conn:
+ user = user_by_email(conn, form["email"])
+ if not bool(user):
+ flash("We could not find an account with that email.",
+ "alert-danger")
+ return redirect(url_for("oauth2.users.forgot_password"))
+
+ send_forgot_password_email(conn,
+ user,
+ request.args["client_id"],
+ request.args["redirect_uri"],
+ request.args["response_type"])
+ return render_template("users/forgot-password-token-send-success.html",
+ email=form["email"])
+
+
+@users.route("/change-password/<forgot_password_token>", methods=["GET", "POST"])
+def change_password(forgot_password_token):
+ """Enable user to perform password change."""
+ login_page = redirect(url_for("oauth2.auth.authorise",
+ client_id=request.args["client_id"],
+ redirect_uri=request.args["redirect_uri"],
+ response_type=request.args["response_type"]))
+ with (db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ cursor.execute("DELETE FROM forgot_password_tokens WHERE expires<=?",
+ (int(datetime.now().timestamp()),))
+ cursor.execute(
+ "SELECT fpt.*, u.email FROM forgot_password_tokens AS fpt "
+ "INNER JOIN users AS u ON fpt.user_id=u.user_id WHERE token=?",
+ (forgot_password_token,))
+ token = cursor.fetchone()
+ if request.method == "GET":
+ if bool(token):
+ return render_template(
+ "users/change-password.html",
+ email=token["email"],
+ client_id=request.args["client_id"],
+ redirect_uri=request.args["redirect_uri"],
+ response_type=request.args["response_type"],
+ forgot_password_token=forgot_password_token)
+ flash("Invalid Token: We cannot change your password!",
+ "alert-danger")
+ return login_page
+
+ password = request.form["password"]
+ confirm_password = request.form["confirm-password"]
+ change_password_page = redirect(url_for(
+ "oauth2.users.change_password",
+ client_id=request.args["client_id"],
+ redirect_uri=request.args["redirect_uri"],
+ response_type=request.args["response_type"],
+ forgot_password_token=forgot_password_token))
+ if bool(password) and bool(confirm_password):
+ if password == confirm_password:
+ _user, _hashed_password = set_user_password(
+ cursor, user_by_email(conn, token["email"]), password)
+ cursor.execute(
+ "DELETE FROM forgot_password_tokens WHERE token=?",
+ (forgot_password_token,))
+ flash("Password changed successfully!", "alert-success")
+ return login_page
+
+ flash("Passwords do not match!", "alert-danger")
+ return change_password_page
+
+ flash("Both the password and its confirmation MUST be provided!",
+ "alert-danger")
+ return change_password_page
+
+
+@users.route("/delete", methods=["POST"])
+@require_oauth("profile user role")
+def delete_users():
+ """Delete the specified user."""
+ with (require_oauth.acquire("profile") as _token,
+ db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ if not authorised_for2(conn,
+ _token.user,
+ system_resource(conn),
+ ("system:user:delete-user",)):
+ raise AuthorisationError(
+ "You need the `system:user:delete-user` privilege to delete "
+ "users from the system.")
+
+ _form = request_json()
+ _user_ids = _form.get("user_ids", [])
+ _non_deletable = set((str(_token.user.user_id),))
+
+ cursor.execute("SELECT user_id FROM group_users")
+ _non_deletable.update(row["user_id"] for row in cursor.fetchall())
+
+ cursor.execute("SELECT user_id FROM oauth2_clients;")
+ _non_deletable.update(row["user_id"] for row in cursor.fetchall())
+
+ _important_roles = (
+ "group-leader",
+ "resource-owner",
+ "system-administrator",
+ "inbredset-group-owner")
+ _paramstr = ",".join(["?"] * len(_important_roles))
+ cursor.execute(
+ "SELECT DISTINCT user_roles.user_id FROM user_roles "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ f"WHERE roles.role_name IN ({_paramstr})",
+ _important_roles)
+ _non_deletable.update(row["user_id"] for row in cursor.fetchall())
+
+ _delete = tuple(uid for uid in _user_ids if uid not in _non_deletable)
+ _paramstr = ", ".join(["?"] * len(_delete))
+ if len(_delete) > 0:
+ _dependent_tables = (
+ ("authorisation_code", "user_id"),
+ ("forgot_password_tokens", "user_id"),
+ ("group_join_requests", "requester_id"),
+ ("jwt_refresh_tokens", "user_id"),
+ ("oauth2_tokens", "user_id"),
+ ("user_credentials", "user_id"),
+ ("user_roles", "user_id"),
+ ("user_verification_codes", "user_id"))
+ for _table, _col in _dependent_tables:
+ cursor.execute(
+ f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
+ _delete)
+
+ cursor.execute(
+ f"DELETE FROM users WHERE user_id IN ({_paramstr})",
+ _delete)
+ _deleted_rows = cursor.rowcount
+ _diff = len(_user_ids) - _deleted_rows
+ return jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": _deleted_rows,
+ "not-deleted": _diff,
+ "message": (
+ f"Successfully deleted {_deleted_rows} users." +
+ (f" Some users could not be deleted." if _diff > 0 else ""))
+ })
+
+ return jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": 0,
+ "not-deleted": len(_user_ids),
+ "error": "Zero users were deleted",
+ "error_description": (
+ "Either no users were selected or all the selected users are "
+ "system administrators, group members, or resource owners.")
+ }), 400