Skip to content

Commit

Permalink
Implement e-kirjasto authentication flow for admins
Browse files Browse the repository at this point in the history
  • Loading branch information
attemoi committed Feb 29, 2024
1 parent f6cea35 commit c12a878
Show file tree
Hide file tree
Showing 11 changed files with 342 additions and 10 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Create admincredentials table
Revision ID: 9966f6f95674
Revises: 993729d4bf97
Create Date: 2024-02-22 02:36:07.130941+00:00
"""
import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = "9966f6f95674"
down_revision = "993729d4bf97"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"admincredentials",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("external_id", sa.Unicode(), nullable=False),
sa.Column("admin_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["admin_id"], ["admins.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_admincredentials_admin_id"),
"admincredentials",
["admin_id"],
unique=False,
)


def downgrade() -> None:
op.drop_index(op.f("ix_admincredentials_admin_id"), table_name="admincredentials")
op.drop_table("admincredentials")
9 changes: 7 additions & 2 deletions api/admin/controller/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

import flask

from api.admin.ekirjasto_admin_authentication_provider import (
EkirjastoAdminAuthenticationProvider,
)
from api.admin.exceptions import AdminNotAuthorized
from api.admin.password_admin_authentication_provider import (
PasswordAdminAuthenticationProvider,
Expand Down Expand Up @@ -35,8 +38,10 @@ def __init__(self, manager):
@property
def admin_auth_providers(self):
if Admin.with_password(self._db).count() != 0:
return [PasswordAdminAuthenticationProvider()]

return [
EkirjastoAdminAuthenticationProvider(),
PasswordAdminAuthenticationProvider(),
]
return []

def admin_auth_provider(self, type):
Expand Down
36 changes: 34 additions & 2 deletions api/admin/controller/individual_admin_settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
from typing import Literal

import flask
from flask import Response
Expand Down Expand Up @@ -75,7 +76,11 @@ def append_role(roles, role):
roles.append(role_dict)

admins = []
for admin in self._db.query(Admin).order_by(Admin.email):
for admin in (
self._db.query(Admin)
.join(Admin.admin_credentials, isouter=True)
.order_by(Admin.email)
):
roles = []
show_admin = True
for role in admin.roles:
Expand Down Expand Up @@ -105,12 +110,20 @@ def append_role(roles, role):
append_role(roles, role)

if len(roles):
admins.append(dict(email=admin.email, roles=roles))
admins.append(
dict(email=admin.email, roles=roles, authType=self.auth_type(admin))
)

return dict(
individualAdmins=admins,
)

@staticmethod
def auth_type(admin: Admin) -> Literal["EXTERNAL", "PASSWORD"]:
if admin.admin_credentials:
return "EXTERNAL"
return "PASSWORD"

def process_post_create_first_admin(self, email: str):
"""Create the first admin in the system."""

Expand Down Expand Up @@ -280,6 +293,14 @@ def check_permissions(self, admin, settingUp):
if admin.is_system_admin():
raise AdminNotAuthorized()

# Finland
# Only password-authenticated users can create other password-based users.
if (
user.is_sitewide_library_manager()
and self.auth_type(user) != "PASSWORD"
):
raise AdminNotAuthorized

# By this point, we know no one is a system admin.
if user.is_sitewide_library_manager():
return
Expand Down Expand Up @@ -421,6 +442,17 @@ def handle_password(self, password, admin: Admin, is_new, settingUp):
can_change_pw = True
if not can_change_pw:
raise AdminNotAuthorized(message)

# Finland
if (
not admin.password_hashed
and admin.admin_credentials
and not user.is_system_admin()
):
raise AdminNotAuthorized(
"Not authorized to set password for externally authenticated user."
)

admin.password = password
try:
self._db.flush()
Expand Down
3 changes: 2 additions & 1 deletion api/admin/controller/reset_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ def forgot_password(self) -> ProblemDetail | WerkzeugResponse:

admin = self._extract_admin_from_request(flask.request)

if not admin:
# Finland, extra check to disable password reset for ekirjasto authenticated users.
if not admin or admin.admin_credentials:
return self._response_with_message_and_redirect_button(
INVALID_ADMIN_CREDENTIALS.detail,
url_for("admin_forgot_password"),
Expand Down
99 changes: 99 additions & 0 deletions api/admin/controller/sign_in.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@

from api.admin.config import Configuration as AdminClientConfig
from api.admin.controller.base import AdminController
from api.admin.ekirjasto_admin_authentication_provider import (
EkirjastoAdminAuthenticationProvider,
)
from api.admin.password_admin_authentication_provider import (
PasswordAdminAuthenticationProvider,
)
from api.admin.problem_details import (
ADMIN_AUTH_MECHANISM_NOT_CONFIGURED,
ADMIN_AUTH_NOT_CONFIGURED,
ADMIN_NOT_AUTHORIZED,
INVALID_ADMIN_CREDENTIALS,
)
from api.admin.template_styles import (
Expand All @@ -26,6 +30,8 @@
section_style,
small_link_style,
)
from core.model import get_one, get_one_or_create
from core.model.admin import Admin, AdminCredential, AdminRole
from core.util.problem_detail import ProblemDetail


Expand Down Expand Up @@ -67,6 +73,87 @@ class SignInController(AdminController):
logo=logo_style,
)

# Finland
def ekirjasto_auth_finish(self):
auth: EkirjastoAdminAuthenticationProvider = self.admin_auth_provider(
EkirjastoAdminAuthenticationProvider.NAME
)
if not auth:
return ADMIN_AUTH_MECHANISM_NOT_CONFIGURED

result = flask.request.form.get("result")
if result != "success":
logging.error("Ekirjasto authentication failed, result = %s", result)

ekirjasto_token = flask.request.form.get("token")

user_info = auth.ekirjasto_authenticate(ekirjasto_token)
if type(user_info) is ProblemDetail:
return user_info

try:
credentials = get_one(self._db, AdminCredential, external_id=user_info.sub)

circulation_role = self._to_circulation_role(user_info.role)
if not circulation_role:
return self.error_response(ADMIN_NOT_AUTHORIZED)

if credentials:
admin = credentials.admin
else:
# No existing credentials found, create new admin & credentials
# TODO: handle e-mail field
admin, _ = get_one_or_create(
self._db,
Admin,
email=f"{user_info.given_name}-{user_info.sub}",
)
_, _ = get_one_or_create(
self._db,
AdminCredential,
external_id=user_info.sub,
admin_id=admin.id,
)

# Update roles if changed
existing_roles = [(role.role, role.library) for role in admin.roles]
if [(circulation_role, None)] != existing_roles:
for role in admin.roles:
admin.remove_role(role.role, role.library)
admin.add_role(circulation_role)

except Exception as e:
logging.error("Internal error during signup", exc_info=e)
self._db.rollback()
# TODO: more specific error response, code 500
return ADMIN_AUTH_MECHANISM_NOT_CONFIGURED

# Set up the admin's flask session.
flask.session["admin_email"] = admin.email
flask.session["auth_type"] = auth.NAME

# This one is extra compared to password auth provider
flask.session["ekirjasto_token"] = ekirjasto_token

# A permanent session expires after a fixed time, rather than
# when the user closes the browser.
flask.session.permanent = True

redirect_uri = flask.request.args.get("redirect_uri", "/admin/web")
return SanitizedRedirections.redirect(redirect_uri)

def _to_circulation_role(self, ekirjasto_role: str) -> str | None:
if ekirjasto_role == "orgadmin":
return AdminRole.SYSTEM_ADMIN
if ekirjasto_role == "admin":
return AdminRole.SITEWIDE_LIBRARY_MANAGER
elif ekirjasto_role == "registrant":
return AdminRole.SITEWIDE_LIBRARIAN
else:
# other possible values are "sysadmin" and "customer",
# these are not allowed as circulation admins
return None

def sign_in(self):
"""Redirects admin if they're signed in, or shows the sign in page."""
if not self.admin_auth_providers:
Expand Down Expand Up @@ -127,13 +214,25 @@ def sign_out(self):
flask.session.pop("admin_email", None)
flask.session.pop("auth_type", None)

# Finland, revoke ekirjasto session
self._try_revoke_ekirjasto_session()

redirect_url = url_for(
"admin_sign_in",
redirect=url_for("admin_view", _external=True),
_external=True,
)
return SanitizedRedirections.redirect(redirect_url)

# Finland
def _try_revoke_ekirjasto_session(self):
ekirjasto_token = flask.session.pop("ekirjasto_token", None)
auth: EkirjastoAdminAuthenticationProvider = self.admin_auth_provider(
EkirjastoAdminAuthenticationProvider.NAME
)
if ekirjasto_token and auth:
auth.try_revoke_ekirjasto_session(ekirjasto_token)

def error_response(self, problem_detail):
"""Returns a problem detail as an HTML response"""
html = self.ERROR_RESPONSE_TEMPLATE % dict(
Expand Down
111 changes: 111 additions & 0 deletions api/admin/ekirjasto_admin_authentication_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import logging

import requests
from flask import url_for
from pydantic import BaseModel

from api.admin.admin_authentication_provider import AdminAuthenticationProvider
from api.admin.template_styles import button_style, input_style, label_style
from api.admin.templates import ekirjasto_sign_in_template
from api.circulation_exceptions import RemoteInitiatedServerError
from api.problem_details import (
EKIRJASTO_REMOTE_AUTHENTICATION_FAILED,
INVALID_EKIRJASTO_TOKEN,
)
from core.util.problem_detail import ProblemDetail


class EkirjastoUserInfo(BaseModel):
exp: int
family_name: str = ""
given_name: str = ""
role: str
sub: str
sid: str
municipality: str
verified: bool = False
passkeys: list[dict] = []


class EkirjastoAdminAuthenticationProvider(AdminAuthenticationProvider):
NAME = "Ekirjasto Auth"

SIGN_IN_TEMPLATE = ekirjasto_sign_in_template.format(
label=label_style, input=input_style, button=button_style
)

# TODO: make configurable
_ekirjasto_api_url = "https://e-kirjasto.loikka.dev"

def sign_in_template(self, redirect):
redirect_uri = url_for(
"ekirjasto_auth_finish", redirect_uri=redirect, _external=True
)
# TODO: make configurable
state = ":T0008" # test state for "orgadmin" role
ekirjasto_auth_url = (
f"{self._ekirjasto_api_url}/v1/auth/tunnistus/start"
f"?locale=fi"
f"&state={state}"
f"&redirect_uri={redirect_uri}"
)
return self.SIGN_IN_TEMPLATE % dict(
ekirjasto_auth_url=ekirjasto_auth_url,
)

def active_credentials(self, admin):
# TODO: Not sure where this is used, need to figure it out.
return True

def ekirjasto_authenticate(
self, ekirjasto_token
) -> EkirjastoUserInfo | ProblemDetail:
return self._get_user_info(ekirjasto_token)

def _get_user_info(self, ekirjasto_token: str) -> EkirjastoUserInfo | ProblemDetail:
userinfo_url = self._ekirjasto_api_url + "/v1/auth/userinfo"
try:
response = requests.get(
userinfo_url, headers={"Authorization": f"Bearer {ekirjasto_token}"}
)
except requests.exceptions.ConnectionError as e:
raise RemoteInitiatedServerError(str(e), self.__class__.__name__)

if response.status_code == 401:
return INVALID_EKIRJASTO_TOKEN
elif response.status_code != 200:
logging.error(
"Got unexpected response code %d, content=%s",
response.status_code,
(response.content or b"No content").decode("utf-8", errors="replace"),
)
return EKIRJASTO_REMOTE_AUTHENTICATION_FAILED
else:
try:
return EkirjastoUserInfo(**response.json())
except requests.exceptions.JSONDecodeError as e:
raise RemoteInitiatedServerError(str(e), self.__class__.__name__)

def try_revoke_ekirjasto_session(self, ekirjasto_token: str) -> None:
revoke_url = self._ekirjasto_api_url + "/v1/auth/revoke"

try:
response = requests.post(
revoke_url, headers={"Authorization": f"Bearer {ekirjasto_token}"}
)
except requests.exceptions.ConnectionError as e:
logging.exception(
"Failed to revoke ekirjasto session due to connection error."
)
# Ignore connection error, we tried our best

# Response codes in 4xx range mean that session is already expired, thus ok.
# For 5xx range, we want to log the response
if 500 <= response.status_code < 600:
# Decode response content assuming UTF-8, replace with the correct encoding if necessary
logging.error(
"Failed to revoke ekirjasto session due to server error, status=%s, content=%s",
response.status_code,
(response.content or b"No content").decode("utf-8", errors="replace"),
)
# Ignore the error response, we tried our best
Loading

0 comments on commit c12a878

Please sign in to comment.