diff options
author | Frederick Muriuki Muriithi | 2023-05-29 14:56:24 +0300 |
---|---|---|
committer | Frederick Muriuki Muriithi | 2023-05-29 14:56:24 +0300 |
commit | 2aa7abf383df814f24c88beea733c324cda682d0 (patch) | |
tree | 89a9297a8854aa2759f9bd7c3e8217cd3d23d163 | |
parent | 25c6da03e1639895f0051e8be6502762beefde0b (diff) | |
download | genenetwork3-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.py | 43 | ||||
-rw-r--r-- | gn3/auth/authentication/users.py | 12 | ||||
-rw-r--r-- | gn3/auth/authorisation/users/admin/ui.py | 3 | ||||
-rw-r--r-- | gn3/auth/authorisation/users/admin/views.py | 94 | ||||
-rw-r--r-- | gn3/settings.py | 3 | ||||
-rw-r--r-- | gn3/templates/admin/dashboard.html | 16 | ||||
-rw-r--r-- | gn3/templates/admin/login.html (renamed from gn3/templates/login.html) | 0 | ||||
-rw-r--r-- | gn3/templates/admin/register-client.html | 78 | ||||
-rw-r--r-- | gn3/templates/admin/registered-client.html | 21 |
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%} |