aboutsummaryrefslogtreecommitdiff
path: root/gn3/auth
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 /gn3/auth
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.
Diffstat (limited to 'gn3/auth')
-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
4 files changed, 132 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)