about summary refs log tree commit diff
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.py433
1 files changed, 410 insertions, 23 deletions
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index 8135ed3..cae2605 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 typing import Any, Sequence
 from urllib.parse import urljoin
+from functools import reduce, partial
+from datetime import datetime, timedelta
 from email.headerregistry import Address
 from email_validator import validate_email, EmailNotValidError
 from flask import (
@@ -27,6 +28,9 @@ 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.system.models import system_resource
+
+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,10 +42,12 @@ from gn_auth.auth.errors import (
     NotFoundError,
     UsernameError,
     PasswordError,
+    AuthorisationError,
     UserRegistrationError)
 
 
-from gn_auth.auth.authentication.users import valid_login, user_by_email
+from gn_auth.auth.authentication.users import (
+    valid_login, user_by_email, user_by_id)
 from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
 from gn_auth.auth.authentication.users import User, save_user, set_user_password
 from gn_auth.auth.authentication.oauth2.models.oauth2token import (
@@ -70,8 +76,24 @@ def user_details() -> Response:
                 False, lambda grp: grp)# type: ignore[arg-type]
             return jsonify({
                 **user_dets,
-                "group": asdict(the_group) if the_group else False
+                **({"group": asdict(the_group)} if the_group else {})
+            })
+
+@users.route("/<user_id>", methods=["GET"])
+def get_user(user_id: str) -> Response:
+    """Fetch user details by user_id."""
+    try:
+        with db.connection(current_app.config["AUTH_DB"]) as conn:
+            user = user_by_id(conn, uuid.UUID(user_id))
+            return jsonify({
+                "user_id": str(user.user_id),
+                "email": user.email,
+                "name": user.name
             })
+    except ValueError:
+        return jsonify({"error": "Invalid user ID format"}), 400
+    except NotFoundError:
+        return jsonify({"error": "User not found"}), 404
 
 @users.route("/roles", methods=["GET"])
 @require_oauth("role")
@@ -113,6 +135,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 +169,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 +182,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 +195,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 +226,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 +235,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 +283,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 +352,60 @@ 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")
+        }
+        or
+        {
+            "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 +437,303 @@ 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
+
+
+def __delete_users_individually__(cursor, user_ids, tables):
+    """Recovery function with dismal performance."""
+    _errors = tuple()
+    for _user_id in user_ids:
+        for _table, _col in tables:
+            try:
+                cursor.execute(
+                        f"DELETE FROM {_table} WHERE {_col}=?",
+                        (str(_user_id),))
+            except sqlite3.IntegrityError:
+                _errors = _errors + (
+                    (("user_id", _user_id),
+                     ("reason", f"User has data in table {_table}")),)
+
+    return _errors
+
+
+def __fetch_non_deletable_users__(cursor, ids_and_reasons):
+    """Fetch detail for non-deletable users."""
+    def __merge__(acc, curr):
+        _curr = dict(curr)
+        _this_dict = acc.get(
+            curr["user_id"], {"reasons": tuple()})
+        _this_dict["reasons"] = _this_dict["reasons"] + (_curr["reason"],)
+        return {**acc, curr["user_id"]: _this_dict}
+
+    _reasons_by_id = reduce(__merge__,
+                            (dict(row) for row in ids_and_reasons),
+                            {})
+    _user_ids = tuple(_reasons_by_id.keys())
+    _paramstr = ", ".join(["?"] * len(_user_ids))
+    cursor.execute(f"SELECT * FROM users WHERE user_id IN ({_paramstr})",
+                   _user_ids)
+    return tuple({
+        "user": dict(row),
+        "reasons": _reasons_by_id[row["user_id"]]["reasons"]
+    } for row in cursor.fetchall())
+
+
+def __non_deletable_with_reason__(
+        user_ids: tuple[str, ...],
+        dbrows: Sequence[sqlite3.Row],
+        reason: str
+    ) -> tuple[tuple[tuple[str, str], tuple[str, str]], ...]:
+    """Build a list of 'non-deletable' user objects."""
+    return tuple((("user_id", _uid), ("reason", reason))
+                 for _uid in user_ids
+                 if _uid in tuple(row["user_id"] for row in dbrows))
+
+
+@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()
+        if str(_token.user.user_id) in _user_ids:
+            _non_deletable.add(
+            (("user_id", str(_token.user.user_id),),
+             ("reason", "You are not allowed to delete yourself.")))
+
+        cursor.execute("SELECT user_id FROM group_users")
+        _group_members = tuple(row["user_id"] for row in cursor.fetchall())
+        _non_deletable.update(__non_deletable_with_reason__(
+            _user_ids,
+            cursor.fetchall(),
+            "User is member of a user group."))
+
+        cursor.execute("SELECT user_id FROM oauth2_clients;")
+        _non_deletable.update(__non_deletable_with_reason__(
+            _user_ids,
+            cursor.fetchall(),
+            "User is registered owner of an OAuth client."))
+
+        _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(__non_deletable_with_reason__(
+            _user_ids,
+            cursor.fetchall(),
+            f"User holds on of the following roles: {_important_roles}"))
+
+        _delete = tuple(uid for uid in _user_ids if uid not in
+                        (dict(row)["user_id"] for row 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"))
+            try:
+                for _table, _col in _dependent_tables:
+                    cursor.execute(
+                        f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
+                        _delete)
+            except sqlite3.IntegrityError:
+                _non_deletable.update(__delete_users_individually__(
+                    cursor, _delete, _dependent_tables))
+
+            _not_deleted = __fetch_non_deletable_users__(
+                cursor, _non_deletable)
+            _delete = tuple(# rebuild with those that failed.
+                _user_id for _user_id in _delete if _user_id not in
+                tuple(row["user"]["user_id"] for row in _not_deleted))
+            _paramstr = ", ".join(["?"] * len(_delete))
+            cursor.execute(
+                f"DELETE FROM users WHERE user_id IN ({_paramstr})",
+                _delete)
+            _deleted_rows = cursor.rowcount
+            return jsonify({
+                "total-requested": len(_user_ids),
+                "total-deleted": _deleted_rows,
+                "not-deleted": _not_deleted,
+                "deleted": _deleted_rows,
+                "message": (
+                    f"Successfully deleted {_deleted_rows} users." +
+                    (" Some users could not be deleted."
+                     if len(_user_ids) - _deleted_rows > 0
+                     else ""))
+            })
+
+        _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable)
+
+    return jsonify({
+        "total-requested": len(_user_ids),
+        "total-deleted": 0,
+        "not-deleted": _not_deleted,
+        "deleted": 0,
+        "error": "Zero users were deleted",
+        "error_description": (
+            "No users were selected for deletion."
+            if len(_user_ids) == 0
+            else ("The selected users are system administrators, group "
+                  "members, or resource owners."))
+    }), 400