about summary refs log tree commit diff
diff options
context:
space:
mode:
authorFrederick Muriuki Muriithi2023-05-29 14:56:24 +0300
committerFrederick Muriuki Muriithi2023-05-29 14:56:24 +0300
commit2aa7abf383df814f24c88beea733c324cda682d0 (patch)
tree89a9297a8854aa2759f9bd7c3e8217cd3d23d163
parent25c6da03e1639895f0051e8be6502762beefde0b (diff)
downloadgenenetwork3-2aa7abf383df814f24c88beea733c324cda682d0.tar.gz
auth: Enable registration of OAuth2 clients
Add UI and code to enable the administrative user to register new OAuth2
clients that can access the API server.
-rw-r--r--gn3/auth/authentication/oauth2/models/oauth2client.py43
-rw-r--r--gn3/auth/authentication/users.py12
-rw-r--r--gn3/auth/authorisation/users/admin/ui.py3
-rw-r--r--gn3/auth/authorisation/users/admin/views.py94
-rw-r--r--gn3/settings.py3
-rw-r--r--gn3/templates/admin/dashboard.html16
-rw-r--r--gn3/templates/admin/login.html (renamed from gn3/templates/login.html)0
-rw-r--r--gn3/templates/admin/register-client.html78
-rw-r--r--gn3/templates/admin/registered-client.html21
9 files changed, 250 insertions, 20 deletions
diff --git a/gn3/auth/authentication/oauth2/models/oauth2client.py b/gn3/auth/authentication/oauth2/models/oauth2client.py
index b7d37be..da20200 100644
--- a/gn3/auth/authentication/oauth2/models/oauth2client.py
+++ b/gn3/auth/authentication/oauth2/models/oauth2client.py
@@ -7,7 +7,7 @@ from typing import Sequence, Optional, NamedTuple
 from pymonad.maybe import Just, Maybe, Nothing
 
 from gn3.auth import db
-from gn3.auth.authentication.users import User, user_by_id
+from gn3.auth.authentication.users import User, user_by_id, same_password
 
 from gn3.auth.authorisation.errors import NotFoundError
 
@@ -161,16 +161,45 @@ def client_by_id_and_secret(conn: db.DbConnection, client_id: uuid.UUID,
     """Retrieve a client by its ID and secret"""
     with db.cursor(conn) as cursor:
         cursor.execute(
-            "SELECT * FROM oauth2_clients WHERE client_id=? AND "
-            "client_secret=?",
-            (str(client_id), client_secret))
+            "SELECT * FROM oauth2_clients WHERE client_id=?",
+            (str(client_id),))
         row = cursor.fetchone()
-        if bool(row):
+        if bool(row) and same_password(client_secret, row["client_secret"]):
             return OAuth2Client(
                 client_id, client_secret,
                 datetime.datetime.fromtimestamp(row["client_id_issued_at"]),
-                datetime.datetime.fromtimestamp(row["client_secret_expires_at"]),
+                datetime.datetime.fromtimestamp(
+                    row["client_secret_expires_at"]),
                 json.loads(row["client_metadata"]),
                 user_by_id(conn, uuid.UUID(row["user_id"])))
 
-        raise NotFoundError(f"Could not find client with ID '{client_id}'")
+        raise NotFoundError("Could not find client with the given credentials.")
+
+def save_client(conn: db.DbConnection, the_client: OAuth2Client) -> OAuth2Client:
+    """Persist the client details into the database."""
+    with db.cursor(conn) as cursor:
+        query = (
+            "INSERT INTO oauth2_clients "
+            "(client_id, client_secret, client_id_issued_at, "
+            "client_secret_expires_at, client_metadata, user_id) "
+            "VALUES "
+            "(:client_id, :client_secret, :client_id_issued_at, "
+            ":client_secret_expires_at, :client_metadata, :user_id) "
+            "ON CONFLICT (client_id) DO UPDATE SET "
+            "client_secret=:client_secret, "
+            "client_id_issued_at=:client_id_issued_at, "
+            "client_secret_expires_at=:client_secret_expires_at, "
+            "client_metadata=:client_metadata, user_id=:user_id")
+        cursor.execute(
+            query,
+            {
+                "client_id": str(the_client.client_id),
+                "client_secret": the_client.client_secret,
+                "client_id_issued_at": (
+                    the_client.client_id_issued_at.timestamp()),
+                "client_secret_expires_at": (
+                    the_client.client_secret_expires_at.timestamp()),
+                "client_metadata": json.dumps(the_client.client_metadata),
+                "user_id": str(the_client.user.user_id)
+            })
+        return the_client
diff --git a/gn3/auth/authentication/users.py b/gn3/auth/authentication/users.py
index 17e89ae..8b4f115 100644
--- a/gn3/auth/authentication/users.py
+++ b/gn3/auth/authentication/users.py
@@ -48,6 +48,13 @@ def user_by_id(conn: db.DbConnection, user_id: UUID) -> User:
 
     raise NotFoundError(f"Could not find user with ID {user_id}")
 
+def same_password(password: str, hashed: str) -> bool:
+    """Check that `raw_password` is hashed to `hash`"""
+    try:
+        return hasher().verify(hashed, password)
+    except VerifyMismatchError as _vme:
+        return False
+
 def valid_login(conn: db.DbConnection, user: User, password: str) -> bool:
     """Check the validity of the provided credentials for login."""
     with db.cursor(conn) as cursor:
@@ -61,10 +68,7 @@ def valid_login(conn: db.DbConnection, user: User, password: str) -> bool:
     if row is None:
         return False
 
-    try:
-        return hasher().verify(row["password"], password)
-    except VerifyMismatchError as _vme:
-        return False
+    return same_password(password, row["password"])
 
 def save_user(cursor: db.DbCursor, email: str, name: str) -> User:
     """
diff --git a/gn3/auth/authorisation/users/admin/ui.py b/gn3/auth/authorisation/users/admin/ui.py
index 3918eb1..7357136 100644
--- a/gn3/auth/authorisation/users/admin/ui.py
+++ b/gn3/auth/authorisation/users/admin/ui.py
@@ -20,7 +20,8 @@ def logged_in(func):
         if bool(session.get(SESSION_KEY)) and not __session_expired__():
             return func(*args, **kwargs)
         flash("You need to be logged in to access that page.", "alert-danger")
-        return redirect(url_for("oauth2.admin.login", next=request.url))
+        return redirect(url_for(
+            "oauth2.admin.login", next=request.url_rule.endpoint))
     return __logged_in__
 
 def is_admin(func):
diff --git a/gn3/auth/authorisation/users/admin/views.py b/gn3/auth/authorisation/users/admin/views.py
index 6a86da3..cf6fa59 100644
--- a/gn3/auth/authorisation/users/admin/views.py
+++ b/gn3/auth/authorisation/users/admin/views.py
@@ -1,4 +1,8 @@
 """UI for admin stuff"""
+import uuid
+import random
+import string
+from functools import partial
 from datetime import datetime, timezone, timedelta
 
 from email_validator import validate_email, EmailNotValidError
@@ -13,7 +17,17 @@ from flask import (
     render_template)
 
 from gn3.auth import db
-from gn3.auth.authentication.users import valid_login, user_by_email
+from gn3.auth.db_utils import with_db_connection
+
+from gn3.auth.authentication.oauth2.models.oauth2client import (
+    save_client,
+    OAuth2Client)
+from gn3.auth.authentication.users import (
+    User,
+    user_by_id,
+    valid_login,
+    user_by_email,
+    hash_password)
 
 from .ui import SESSION_KEY, is_admin
 
@@ -27,21 +41,27 @@ def update_expires():
         if now >= session[SESSION_KEY]["expires"]:
             flash("Session has expired. Logging out...", "alert-warning")
             session.pop(SESSION_KEY)
-            return redirect("/version")
+            return redirect(url_for("oauth2.admin.login"))
         # If not expired, extend expiry.
         session[SESSION_KEY]["expires"] = now + timedelta(minutes=10)
 
     return None
 
+@admin.route("/dashboard", methods=["GET"])
+def dashboard():
+    """Admin dashboard."""
+    return render_template("admin/dashboard.html")
+
 @admin.route("/login", methods=["GET", "POST"])
 def login():
     """Log in to GN3 directly without OAuth2 client."""
     if request.method == "GET":
         return render_template(
-            "login.html", next_uri=request.args.get("next", "/api/version"))
+            "admin/login.html",
+            next_uri=request.args.get("next", "oauth2.admin.dashboard"))
 
     form = request.form
-    next_uri = form.get("next_uri", "/api/version")
+    next_uri = form.get("next_uri", "oauth2.admin.dashboard")
     error_message = "Invalid email or password provided."
     login_page = redirect(url_for("oauth2.admin.login", next=next_uri))
     try:
@@ -55,7 +75,7 @@ def login():
                     "user": user._asdict(),
                     "expires": datetime.now(tz=timezone.utc) + timedelta(minutes=10)
                 }
-                return redirect(next_uri)
+                return redirect(url_for(next_uri))
             flash(error_message, "alert-danger")
             return login_page
     except EmailNotValidError as _enve:
@@ -67,13 +87,71 @@ def logout():
     """Log out the admin."""
     if not bool(session.get(SESSION_KEY)):
         flash("Not logged in.", "alert-info")
-        return redirect(url_for("general.version"))
+        return redirect(url_for("oauth2.admin.login"))
     session.pop(SESSION_KEY)
     flash("Logged out", "alert-success")
-    return redirect(url_for("general.version"))
+    return redirect(url_for("oauth2.admin.login"))
+
+def random_string(length: int = 64) -> str:
+    """Generate a random string."""
+    return "".join(
+        random.choice(string.ascii_letters + string.digits + string.punctuation)
+        for _idx in range(0, length))
+
+def __response_types__(grant_types: tuple[str, ...]) -> tuple[str, ...]:
+    """Compute response types from grant types."""
+    resps = {
+        "password": ("token",),
+        "authorization_code": ("token", "code"),
+        "refresh_token": ("token",)
+    }
+    return tuple(set(
+        resp_typ for types_list
+        in (types for grant, types in resps.items() if grant in grant_types)
+        for resp_typ in types_list))
 
 @admin.route("/register-client", methods=["GET", "POST"])
 @is_admin
 def register_client():
     """Register an OAuth2 client."""
-    return "WOULD REGISTER ..."
+    def __list_users__(conn):
+        with db.cursor(conn) as cursor:
+            cursor.execute("SELECT * FROM users")
+            return tuple(
+                User(uuid.UUID(row["user_id"]), row["email"], row["name"])
+                for row in cursor.fetchall())
+    if request.method == "GET":
+        return render_template(
+            "admin/register-client.html",
+            scope=current_app.config["OAUTH2_SCOPE"],
+            users=with_db_connection(__list_users__),
+            current_user=session[SESSION_KEY]["user"])
+
+    form = request.form
+    raw_client_secret = random_string()
+    default_redirect_uri = form["redirect_uri"]
+    grant_types = form.getlist("grants[]")
+    client = OAuth2Client(
+        client_id = uuid.uuid4(),
+        client_secret = hash_password(raw_client_secret),
+        client_id_issued_at = datetime.now(tz=timezone.utc),
+        client_secret_expires_at = datetime.fromtimestamp(0),
+        client_metadata = {
+            "client_name": "GN2 Dev Server",
+            "token_endpoint_auth_method": [
+                "client_secret_post", "client_secret_basic"],
+            "client_type": "confidential",
+            "grant_types": ["password", "authorization_code", "refresh_token"],
+            "default_redirect_uri": default_redirect_uri,
+            "redirect_uris": [default_redirect_uri] + form.get("other_redirect_uri", "").split(),
+            "response_type": __response_types__(tuple(grant_types)),
+            "scope": form.getlist("scope[]")
+        },
+        user = with_db_connection(partial(
+            user_by_id, user_id=uuid.UUID(form["user"])))
+    )
+    client = with_db_connection(partial(save_client, the_client=client))
+    return render_template(
+        "admin/registered-client.html",
+        client=client,
+        client_secret = raw_client_secret)
diff --git a/gn3/settings.py b/gn3/settings.py
index 8fe9c01..b3dc343 100644
--- a/gn3/settings.py
+++ b/gn3/settings.py
@@ -73,6 +73,9 @@ MULTIPROCESSOR_PROCS = 6 # Number of processes to spawn
 AUTH_MIGRATIONS = "migrations/auth"
 AUTH_DB = os.environ.get(
     "AUTH_DB", f"{os.environ.get('HOME')}/genenetwork/gn3_files/db/auth.db")
+OAUTH2_SCOPE = (
+    "profile", "group", "role", "resource", "register-client", "user",
+    "masquerade", "migrate-data", "introspect")
 
 try:
     # *** SECURITY CONCERN ***
diff --git a/gn3/templates/admin/dashboard.html b/gn3/templates/admin/dashboard.html
new file mode 100644
index 0000000..49bf2f6
--- /dev/null
+++ b/gn3/templates/admin/dashboard.html
@@ -0,0 +1,16 @@
+{%extends "base.html"%}
+
+{%block title%}Genenetwork3: Admin Dashboard{%endblock%}
+
+{%block content%}
+{{flash_messages()}}
+
+<h1>Genenetwork3: Admin Dashboard</h1>
+
+<ul class="nav">
+  <li>
+    <a href="{{url_for('oauth2.admin.register_client')}}"
+       title="Register a new OAuth2 client.">Register OAuth2 Client</a>
+  </li>
+</ul>
+{%endblock%}
diff --git a/gn3/templates/login.html b/gn3/templates/admin/login.html
index cf46009..cf46009 100644
--- a/gn3/templates/login.html
+++ b/gn3/templates/admin/login.html
diff --git a/gn3/templates/admin/register-client.html b/gn3/templates/admin/register-client.html
new file mode 100644
index 0000000..3058aee
--- /dev/null
+++ b/gn3/templates/admin/register-client.html
@@ -0,0 +1,78 @@
+{%extends "base.html"%}
+
+{%block title%}Genenetwork3: Register OAuth2 Client{%endblock%}
+
+{%block content%}
+{{flash_messages()}}
+
+<h1>Genenetwork3: Register OAuth2 Client</h1>
+
+<form method="POST" action="{{url_for('oauth2.admin.register_client')}}">
+
+  <fieldset>
+    <legend>Select client scope</legend>
+
+    {%for scp in scope%}
+    <input name="scope[]" id="chk:{{scp}}"type="checkbox" value="{{scp}}"
+	   {%if scp=="profile"%}checked="checked"{%endif%} />
+    <label for="chk:{{scp}}">{{scp}}</label><br />
+    {%endfor%}
+
+  </fieldset>
+
+  <fieldset>
+    <legend>Basic OAuth2 client information</legend>
+
+    
+    <label for="txt:client-name">Client name</label>
+    <input name="client_name" type="text" id="txt:client-name"
+	   required="required" />
+    <br /><br />
+
+    <label for="txt:redirect-uri">Redirect URI</label>
+    <input name="redirect_uri" type="text" id="txt:redirect-uri"
+	   required="required" />
+    <br /><br />
+
+    <label for="txt:other-redirect-uris">
+      Other redirect URIs (Enter one URI per line)</label>
+    <br />
+    <textarea name="other_redirect_uris" id="txt:other-redirect-uris"
+	      cols="80" rows="10"
+	      title="Enter one URI per line."></textarea>
+    <br /><br />
+    <fieldset>
+      <legend>Supported grant types</legend>
+      <input name="grants[]"
+	     type="checkbox"
+	     value="authorization_code"
+	     id="chk:authorization-code"
+	     checked="checked" />
+      <label for="chk:authorization-code">Authorization Code</label>
+      <br /><br />
+
+      <input name="grants[]"
+	     type="checkbox"
+	     value="refresh_token"
+	     id="chk:refresh-token" />
+      <label for="chk:refresh-token">Refresh Token</label>
+    </fieldset>
+  </fieldset>
+
+  <fieldset>
+    <legend>User information</legend>
+
+    <p>The user to register this client for</p>
+    <select name="user" required="required">
+      {%for user in users%}
+      <option value="{{user.user_id}}"
+	      {%if user.user_id==current_user.user_id%}
+	      selected="selected"
+	      {%endif%}>{{user.name}} ({{user.email}})</option>
+      {%endfor%}
+    </select>
+  </fieldset>
+  
+  <input type="submit" value="register client" />
+</form>
+{%endblock%}
diff --git a/gn3/templates/admin/registered-client.html b/gn3/templates/admin/registered-client.html
new file mode 100644
index 0000000..2fc3990
--- /dev/null
+++ b/gn3/templates/admin/registered-client.html
@@ -0,0 +1,21 @@
+{%extends "base.html"%}
+
+{%block title%}Genenetwork3: Register OAuth2 Client{%endblock%}
+
+{%block content%}
+{{flash_messages()}}
+
+<h1>Genenetwork3: Register OAuth2 Client</h1>
+
+<p>Client has been registered successfully.</p>
+
+<p>Please save the following client details somewhere. There is no way to
+  retrieve the the <strong>CLIENT_SECRET</strong> once you leave this page.</p>
+
+<dl>
+  <dt>CLIENT_ID</dt>
+  <dd>{{client.client_id}}</dd>
+  <dt>CLIENT_SECRET</dt>
+  <dd>{{client_secret}}</dd>
+</dl>
+{%endblock%}