diff --git a/package/etc/ahriman.ini b/package/etc/ahriman.ini index 4e384ad7..f9ccb0d8 100644 --- a/package/etc/ahriman.ini +++ b/package/etc/ahriman.ini @@ -43,5 +43,6 @@ command = rsync --archive --compress --partial --delete chunk_size = 8388608 [web] +auth = no host = 127.0.0.1 templates = /usr/share/ahriman \ No newline at end of file diff --git a/setup.py b/setup.py index 594de771..f303b883 100644 --- a/setup.py +++ b/setup.py @@ -97,6 +97,10 @@ setup( "Jinja2", "aiohttp", "aiohttp_jinja2", + "aiohttp_session", + "aiohttp_security", + "cryptography", + "passlib", ], }, diff --git a/src/ahriman/core/auth.py b/src/ahriman/core/auth.py new file mode 100644 index 00000000..2e4d0313 --- /dev/null +++ b/src/ahriman/core/auth.py @@ -0,0 +1,98 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from typing import Dict, Optional, Set + +from ahriman.core.configuration import Configuration +from ahriman.models.user import User +from ahriman.models.user_access import UserAccess + + +class Auth: + """ + helper to deal with user authorization + :ivar allowed_paths: URI paths which can be accessed without authorization + :ivar allowed_paths_groups: URI paths prefixes which can be accessed without authorization + :ivar salt: random generated string to salt passwords + :ivar users: map of username to its descriptor + :cvar ALLOWED_PATHS: URI paths which can be accessed without authorization, predefined + :cvar ALLOWED_PATHS_GROUPS: URI paths prefixes which can be accessed without authorization, predefined + """ + + ALLOWED_PATHS = {"/", "/favicon.ico", "/login", "/logout"} + ALLOWED_PATHS_GROUPS: Set[str] = set() + + def __init__(self, configuration: Configuration) -> None: + """ + default constructor + :param configuration: configuration instance + """ + self.salt = configuration.get("auth", "salt") + self.users = self.get_users(configuration) + + self.allowed_paths = set(configuration.getlist("auth", "allowed_paths")) + self.allowed_paths.update(self.ALLOWED_PATHS) + self.allowed_paths_groups = set(configuration.getlist("auth", "allowed_paths_groups")) + self.allowed_paths_groups.update(self.ALLOWED_PATHS_GROUPS) + + @staticmethod + def get_users(configuration: Configuration) -> Dict[str, User]: + """ + load users from settings + :param configuration: configuration instance + :return: map of username to its descriptor + """ + users: Dict[str, User] = {} + for role in UserAccess: + section = configuration.section_name("auth", role.value) + if not configuration.has_section(section): + continue + for user, password in configuration[section].items(): + users[user] = User(user, password, role) + return users + + def check_credentials(self, username: Optional[str], password: Optional[str]) -> bool: + """ + validate user password + :param username: username + :param password: entered password + :return: True in case if password matches, False otherwise + """ + if username is None or password is None: + return False # invalid data supplied + return username in self.users and self.users[username].check_credentials(password, self.salt) + + def is_safe_request(self, uri: Optional[str]) -> bool: + """ + check if requested path are allowed without authorization + :param uri: request uri + :return: True in case if this URI can be requested without authorization and False otherwise + """ + if uri is None: + return False # request without context is not allowed + return uri in self.ALLOWED_PATHS or any(uri.startswith(path) for path in self.ALLOWED_PATHS_GROUPS) + + def verify_access(self, username: str, required: UserAccess) -> bool: + """ + validate if user has access to requested resource + :param username: username + :param required: required access level + :return: True in case if user is allowed to do this request and False otherwise + """ + return username in self.users and self.users[username].verify_access(required) diff --git a/src/ahriman/core/configuration.py b/src/ahriman/core/configuration.py index b55d9e8d..dbe72693 100644 --- a/src/ahriman/core/configuration.py +++ b/src/ahriman/core/configuration.py @@ -78,14 +78,14 @@ class Configuration(configparser.RawConfigParser): return config @staticmethod - def section_name(section: str, architecture: str) -> str: + def section_name(section: str, suffix: str) -> str: """ - generate section name for architecture specific sections + generate section name for sections which depends on context :param section: section name - :param architecture: repository architecture + :param suffix: session suffix, e.g. repository architecture :return: correct section name for repository specific section """ - return f"{section}:{architecture}" + return f"{section}:{suffix}" def dump(self) -> Dict[str, Dict[str, str]]: """ diff --git a/src/ahriman/core/status/client.py b/src/ahriman/core/status/client.py index ea1b8087..e774bf0e 100644 --- a/src/ahriman/core/status/client.py +++ b/src/ahriman/core/status/client.py @@ -43,7 +43,7 @@ class Client: port = configuration.getint("web", "port", fallback=None) if host is not None and port is not None: from ahriman.core.status.web_client import WebClient - return WebClient(host, port) + return WebClient(configuration) return cls() def add(self, package: Package, status: BuildStatusEnum) -> None: diff --git a/src/ahriman/core/status/web_client.py b/src/ahriman/core/status/web_client.py index 9e8cf64d..a52b6837 100644 --- a/src/ahriman/core/status/web_client.py +++ b/src/ahriman/core/status/web_client.py @@ -22,37 +22,90 @@ import requests from typing import List, Optional, Tuple +from ahriman.core.configuration import Configuration from ahriman.core.status.client import Client from ahriman.core.util import exception_response_text from ahriman.models.build_status import BuildStatusEnum, BuildStatus from ahriman.models.internal_status import InternalStatus from ahriman.models.package import Package +from ahriman.models.user import User class WebClient(Client): """ build status reporter web client - :ivar host: host of web service + :ivar address: address of the web service :ivar logger: class logger - :ivar port: port of web service + :ivar user: web service user descriptor """ - def __init__(self, host: str, port: int) -> None: + def __init__(self, configuration: Configuration) -> None: """ default constructor - :param host: host of web service - :param port: port of web service + :param configuration: configuration instance """ self.logger = logging.getLogger("http") - self.host = host - self.port = port + self.address = self.parse_address(configuration) + self.user = User.from_option( + configuration.get("web", "username", fallback=None), + configuration.get("web", "password", fallback=None)) + self.__session = requests.session() + @property def _ahriman_url(self) -> str: """ - url generator :return: full url for web service for ahriman service itself """ - return f"http://{self.host}:{self.port}/api/v1/ahriman" + return f"{self.address}/api/v1/ahriman" + + @property + def _login_url(self) -> str: + """ + :return: full url for web service to login + """ + return f"{self.address}/login" + + @property + def _status_url(self) -> str: + """ + :return: full url for web service for status + """ + return f"{self.address}/api/v1/status" + + @staticmethod + def parse_address(configuration: Configuration) -> str: + """ + parse address from configuration + :param configuration: configuration instance + :return: valid http address + """ + address = configuration.get("web", "address", fallback=None) + if not address: + # build address from host and port directly + host = configuration.get("web", "host") + port = configuration.getint("web", "port") + address = f"http://{host}:{port}" + return address + + def login(self) -> None: + """ + process login to the service + """ + if self.user is None: + return # no auth configured + + payload = { + "username": self.user.username, + "password": self.user.password + } + + try: + response = self.__session.post(self._login_url, json=payload) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + self.logger.exception("could not login as %s: %s", self.user, exception_response_text(e)) + except Exception: + self.logger.exception("could not login as %s", self.user) def _package_url(self, base: str = "") -> str: """ @@ -60,14 +113,7 @@ class WebClient(Client): :param base: package base to generate url :return: full url of web service for specific package base """ - return f"http://{self.host}:{self.port}/api/v1/packages/{base}" - - def _status_url(self) -> str: - """ - url generator - :return: full url for web service for status - """ - return f"http://{self.host}:{self.port}/api/v1/status" + return f"{self.address}/api/v1/packages/{base}" def add(self, package: Package, status: BuildStatusEnum) -> None: """ @@ -81,7 +127,7 @@ class WebClient(Client): } try: - response = requests.post(self._package_url(package.base), json=payload) + response = self.__session.post(self._package_url(package.base), json=payload) response.raise_for_status() except requests.exceptions.HTTPError as e: self.logger.exception("could not add %s: %s", package.base, exception_response_text(e)) @@ -95,7 +141,7 @@ class WebClient(Client): :return: list of current package description and status if it has been found """ try: - response = requests.get(self._package_url(base or "")) + response = self.__session.get(self._package_url(base or "")) response.raise_for_status() status_json = response.json() @@ -115,7 +161,7 @@ class WebClient(Client): :return: current internal (web) service status """ try: - response = requests.get(self._status_url()) + response = self.__session.get(self._status_url) response.raise_for_status() status_json = response.json() @@ -132,7 +178,7 @@ class WebClient(Client): :return: current ahriman status """ try: - response = requests.get(self._ahriman_url()) + response = self.__session.get(self._ahriman_url) response.raise_for_status() status_json = response.json() @@ -149,7 +195,7 @@ class WebClient(Client): :param base: basename to remove """ try: - response = requests.delete(self._package_url(base)) + response = self.__session.delete(self._package_url(base)) response.raise_for_status() except requests.exceptions.HTTPError as e: self.logger.exception("could not delete %s: %s", base, exception_response_text(e)) @@ -165,7 +211,7 @@ class WebClient(Client): payload = {"status": status.value} try: - response = requests.post(self._package_url(base), json=payload) + response = self.__session.post(self._package_url(base), json=payload) response.raise_for_status() except requests.exceptions.HTTPError as e: self.logger.exception("could not update %s: %s", base, exception_response_text(e)) @@ -180,7 +226,7 @@ class WebClient(Client): payload = {"status": status.value} try: - response = requests.post(self._ahriman_url(), json=payload) + response = self.__session.post(self._ahriman_url, json=payload) response.raise_for_status() except requests.exceptions.HTTPError as e: self.logger.exception("could not update service status: %s", exception_response_text(e)) diff --git a/src/ahriman/models/user.py b/src/ahriman/models/user.py new file mode 100644 index 00000000..474334ea --- /dev/null +++ b/src/ahriman/models/user.py @@ -0,0 +1,78 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Type +from passlib.handlers.sha2_crypt import sha512_crypt # type: ignore + +from ahriman.models.user_access import UserAccess + + +@dataclass +class User: + """ + authorized web user model + :ivar username: username + :ivar password: hashed user password with salt + :ivar access: user role + """ + username: str + password: str + access: UserAccess + + @classmethod + def from_option(cls: Type[User], username: Optional[str], password: Optional[str]) -> Optional[User]: + """ + build user descriptor from configuration options + :param username: username + :param password: password as string + :return: generated user descriptor if all options are supplied and None otherwise + """ + if username is None or password is None: + return None + return cls(username, password, UserAccess.Status) + + def check_credentials(self, password: str, salt: str) -> bool: + """ + validate user password + :param password: entered password + :param salt: salt for hashed password + :return: True in case if password matches, False otherwise + """ + verified: bool = sha512_crypt.verify(password + salt, self.password) + return verified + + def verify_access(self, required: UserAccess) -> bool: + """ + validate if user has access to requested resource + :param required: required access level + :return: True in case if user is allowed to do this request and False otherwise + """ + if self.access == UserAccess.Write: + return True # everything is allowed + return self.access == required + + def __repr__(self) -> str: + """ + generate string representation of object + :return: unique string representation + """ + return f"User(username={self.username}, access={self.access})" diff --git a/src/ahriman/models/user_access.py b/src/ahriman/models/user_access.py new file mode 100644 index 00000000..b0a9f1fc --- /dev/null +++ b/src/ahriman/models/user_access.py @@ -0,0 +1,33 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from enum import Enum + + +class UserAccess(Enum): + """ + web user access enumeration + :cvar Read: user can read status page + :cvar Write: user can modify task and package list + :cvar Status: user can update statuses via API + """ + + Read = "read" + Write = "write" + Status = "status" diff --git a/src/ahriman/web/middlewares/__init__.py b/src/ahriman/web/middlewares/__init__.py index fb32931e..1950c456 100644 --- a/src/ahriman/web/middlewares/__init__.py +++ b/src/ahriman/web/middlewares/__init__.py @@ -17,3 +17,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +from aiohttp.web import Request +from aiohttp.web_response import StreamResponse +from typing import Awaitable, Callable + + +HandlerType = Callable[[Request], Awaitable[StreamResponse]] +MiddlewareType = Callable[[Request, HandlerType], Awaitable[StreamResponse]] diff --git a/src/ahriman/web/middlewares/auth_handler.py b/src/ahriman/web/middlewares/auth_handler.py new file mode 100644 index 00000000..df162e51 --- /dev/null +++ b/src/ahriman/web/middlewares/auth_handler.py @@ -0,0 +1,101 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from aiohttp import web +from aiohttp.web import middleware, Request +from aiohttp.web_response import StreamResponse +from aiohttp_security import setup as setup_security # type: ignore +from aiohttp_security import AbstractAuthorizationPolicy, SessionIdentityPolicy, check_permission +from typing import Optional + +from ahriman.core.auth import Auth +from ahriman.core.configuration import Configuration +from ahriman.models.user_access import UserAccess +from ahriman.web.middlewares import HandlerType, MiddlewareType + + +class AuthorizationPolicy(AbstractAuthorizationPolicy): # type: ignore + """ + authorization policy implementation + :ivar validator: validator instance + """ + + def __init__(self, configuration: Configuration) -> None: + """ + default constructor + :param configuration: configuration instance + """ + self.validator = Auth(configuration) + + async def authorized_userid(self, identity: str) -> Optional[str]: + """ + retrieve authorized username + :param identity: username + :return: user identity (username) in case if user exists and None otherwise + """ + return identity if identity in self.validator.users else None + + async def permits(self, identity: str, permission: UserAccess, context: Optional[str] = None) -> bool: + """ + check user permissions + :param identity: username + :param permission: requested permission level + :param context: URI request path + :return: True in case if user is allowed to perform this request and False otherwise + """ + if self.validator.is_safe_request(context): + return True + return self.validator.verify_access(identity, permission) + + +def auth_handler() -> MiddlewareType: + """ + authorization and authentication middleware + :return: built middleware + """ + @middleware + async def handle(request: Request, handler: HandlerType) -> StreamResponse: + if request.path.startswith("/api"): + permission = UserAccess.Status + elif request.method in ("HEAD", "GET", "OPTIONS"): + permission = UserAccess.Read + else: + permission = UserAccess.Write + await check_permission(request, permission, request.path) + + return await handler(request) + + return handle + + +def setup_auth(application: web.Application, configuration: Configuration) -> web.Application: + """ + setup authorization policies for the application + :param application: web application instance + :param configuration: configuration instance + :return: configured web application + """ + authorization_policy = AuthorizationPolicy(configuration) + identity_policy = SessionIdentityPolicy() + + application["validator"] = authorization_policy.validator + setup_security(application, identity_policy, authorization_policy) + application.middlewares.append(auth_handler()) + + return application diff --git a/src/ahriman/web/middlewares/exception_handler.py b/src/ahriman/web/middlewares/exception_handler.py index ebbcabcc..63e32f40 100644 --- a/src/ahriman/web/middlewares/exception_handler.py +++ b/src/ahriman/web/middlewares/exception_handler.py @@ -21,13 +21,11 @@ from aiohttp.web import middleware, Request from aiohttp.web_exceptions import HTTPClientError from aiohttp.web_response import StreamResponse from logging import Logger -from typing import Awaitable, Callable + +from ahriman.web.middlewares import HandlerType, MiddlewareType -HandlerType = Callable[[Request], Awaitable[StreamResponse]] - - -def exception_handler(logger: Logger) -> Callable[[Request, HandlerType], Awaitable[StreamResponse]]: +def exception_handler(logger: Logger) -> MiddlewareType: """ exception handler middleware. Just log any exception (except for client ones) :param logger: class logger diff --git a/src/ahriman/web/routes.py b/src/ahriman/web/routes.py index 9f50a8fa..e9e1d7df 100644 --- a/src/ahriman/web/routes.py +++ b/src/ahriman/web/routes.py @@ -21,6 +21,8 @@ from aiohttp.web import Application from ahriman.web.views.ahriman import AhrimanView from ahriman.web.views.index import IndexView +from ahriman.web.views.login import LoginView +from ahriman.web.views.logout import LogoutView from ahriman.web.views.package import PackageView from ahriman.web.views.packages import PackagesView from ahriman.web.views.status import StatusView @@ -35,6 +37,9 @@ def setup_routes(application: Application) -> None: GET / get build status page GET /index.html same as above + POST /login login to service + POST /logout logout from service + GET /api/v1/ahriman get current service status POST /api/v1/ahriman update service status @@ -52,6 +57,9 @@ def setup_routes(application: Application) -> None: application.router.add_get("/", IndexView) application.router.add_get("/index.html", IndexView) + application.router.add_post("/login", LoginView) + application.router.add_post("/logout", LogoutView) + application.router.add_get("/api/v1/ahriman", AhrimanView) application.router.add_post("/api/v1/ahriman", AhrimanView) diff --git a/src/ahriman/web/views/ahriman.py b/src/ahriman/web/views/ahriman.py index 83b5fe62..42f85bc8 100644 --- a/src/ahriman/web/views/ahriman.py +++ b/src/ahriman/web/views/ahriman.py @@ -46,7 +46,7 @@ class AhrimanView(BaseView): :return: 204 on success """ - data = await self.request.json() + data = await self.extract_data() try: status = BuildStatusEnum(data["status"]) diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py index 0110cf7f..bb078570 100644 --- a/src/ahriman/web/views/base.py +++ b/src/ahriman/web/views/base.py @@ -18,7 +18,9 @@ # along with this program. If not, see . # from aiohttp.web import View +from typing import Any, Dict +from ahriman.core.auth import Auth from ahriman.core.status.watcher import Watcher @@ -34,3 +36,22 @@ class BaseView(View): """ watcher: Watcher = self.request.app["watcher"] return watcher + + @property + def validator(self) -> Auth: + """ + :return: authorization service instance + """ + validator: Auth = self.request.app["validator"] + return validator + + async def extract_data(self) -> Dict[str, Any]: + """ + extract json data from either json or form data + :return: raw json object or form data converted to json + """ + try: + json: Dict[str, Any] = await self.request.json() + return json + except ValueError: + return dict(await self.request.post()) diff --git a/src/ahriman/web/views/login.py b/src/ahriman/web/views/login.py new file mode 100644 index 00000000..2b1b01aa --- /dev/null +++ b/src/ahriman/web/views/login.py @@ -0,0 +1,51 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from aiohttp.web import HTTPFound, HTTPUnauthorized, Response +from aiohttp_security import remember # type: ignore + +from ahriman.web.views.base import BaseView + + +class LoginView(BaseView): + """ + login endpoint view + """ + + async def post(self) -> Response: + """ + login user to service + + either JSON body or form data must be supplied the following fields are required: + { + "username": "username" # username to use for login + "password": "pa55w0rd" # password to use for login + } + + :return: redirect to main page + """ + data = await self.extract_data() + username = data.get("username") + + response = HTTPFound("/") + if self.validator.check_credentials(username, data.get("password")): + await remember(self.request, response, username) + return response + + raise HTTPUnauthorized() diff --git a/src/ahriman/web/views/logout.py b/src/ahriman/web/views/logout.py new file mode 100644 index 00000000..4abe88d8 --- /dev/null +++ b/src/ahriman/web/views/logout.py @@ -0,0 +1,41 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from aiohttp.web import HTTPFound, Response +from aiohttp_security import check_authorized, forget # type: ignore + +from ahriman.web.views.base import BaseView + + +class LogoutView(BaseView): + """ + logout endpoint view + """ + + async def post(self) -> Response: + """ + logout user from the service. No parameters supported here + :return: redirect to main page + """ + await check_authorized(self.request) + + response = HTTPFound("/") + await forget(self.request, response) + + return response diff --git a/src/ahriman/web/views/package.py b/src/ahriman/web/views/package.py index 97de0dff..3789a896 100644 --- a/src/ahriman/web/views/package.py +++ b/src/ahriman/web/views/package.py @@ -74,7 +74,7 @@ class PackageView(BaseView): :return: 204 on success """ base = self.request.match_info["package"] - data = await self.request.json() + data = await self.extract_data() try: package = Package.from_json(data["package"]) if "package" in data else None diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index a18339de..a42cce29 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -18,14 +18,19 @@ # along with this program. If not, see . # import aiohttp_jinja2 +import base64 import jinja2 import logging from aiohttp import web +from aiohttp_session import setup as setup_session # type: ignore +from aiohttp_session.cookie_storage import EncryptedCookieStorage # type: ignore +from cryptography import fernet from ahriman.core.configuration import Configuration from ahriman.core.exceptions import InitializeException from ahriman.core.status.watcher import Watcher +from ahriman.web.middlewares.auth_handler import setup_auth from ahriman.web.middlewares.exception_handler import exception_handler from ahriman.web.routes import setup_routes @@ -92,4 +97,12 @@ def setup_service(architecture: str, configuration: Configuration) -> web.Applic application.logger.info("setup watcher") application["watcher"] = Watcher(architecture, configuration) + fernet_key = fernet.Fernet.generate_key() + secret_key = base64.urlsafe_b64decode(fernet_key) + storage = EncryptedCookieStorage(secret_key, cookie_name='API_SESSION') + setup_session(application, storage) + + if configuration.getboolean("web", "auth", fallback=False): + setup_auth(application, configuration) + return application diff --git a/tests/ahriman/core/status/conftest.py b/tests/ahriman/core/status/conftest.py index e12c2365..653d67cd 100644 --- a/tests/ahriman/core/status/conftest.py +++ b/tests/ahriman/core/status/conftest.py @@ -2,6 +2,7 @@ import pytest from typing import Any, Dict +from ahriman.core.configuration import Configuration from ahriman.core.status.client import Client from ahriman.core.status.web_client import WebClient from ahriman.models.build_status import BuildStatus, BuildStatusEnum @@ -40,9 +41,10 @@ def client() -> Client: @pytest.fixture -def web_client() -> WebClient: +def web_client(configuration: Configuration) -> WebClient: """ fixture for web client :return: web client test instance """ - return WebClient("localhost", 8080) + configuration.set("web", "port", 8080) + return WebClient(configuration) diff --git a/tests/ahriman/core/status/test_web_client.py b/tests/ahriman/core/status/test_web_client.py index fd8fb2cb..1a816ccb 100644 --- a/tests/ahriman/core/status/test_web_client.py +++ b/tests/ahriman/core/status/test_web_client.py @@ -15,31 +15,31 @@ def test_ahriman_url(web_client: WebClient) -> None: """ must generate service status url correctly """ - assert web_client._ahriman_url().startswith(f"http://{web_client.host}:{web_client.port}") - assert web_client._ahriman_url().endswith("/api/v1/ahriman") - - -def test_package_url(web_client: WebClient, package_ahriman: Package) -> None: - """ - must generate package status correctly - """ - assert web_client._package_url(package_ahriman.base).startswith(f"http://{web_client.host}:{web_client.port}") - assert web_client._package_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}") + assert web_client._ahriman_url.startswith(web_client.address) + assert web_client._ahriman_url.endswith("/api/v1/ahriman") def test_status_url(web_client: WebClient) -> None: """ must generate service status url correctly """ - assert web_client._status_url().startswith(f"http://{web_client.host}:{web_client.port}") - assert web_client._status_url().endswith("/api/v1/status") + assert web_client._status_url.startswith(web_client.address) + assert web_client._status_url.endswith("/api/v1/status") + + +def test_package_url(web_client: WebClient, package_ahriman: Package) -> None: + """ + must generate package status correctly + """ + assert web_client._package_url(package_ahriman.base).startswith(web_client.address) + assert web_client._package_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}") def test_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must process package addition """ - requests_mock = mocker.patch("requests.post") + requests_mock = mocker.patch("requests.Session.post") payload = pytest.helpers.get_package_status(package_ahriman) web_client.add(package_ahriman, BuildStatusEnum.Unknown) @@ -50,7 +50,7 @@ def test_add_failed(web_client: WebClient, package_ahriman: Package, mocker: Moc """ must suppress any exception happened during addition """ - mocker.patch("requests.post", side_effect=Exception()) + mocker.patch("requests.Session.post", side_effect=Exception()) web_client.add(package_ahriman, BuildStatusEnum.Unknown) @@ -58,7 +58,7 @@ def test_add_failed_http_error(web_client: WebClient, package_ahriman: Package, """ must suppress any exception happened during addition """ - mocker.patch("requests.post", side_effect=requests.exceptions.HTTPError()) + mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError()) web_client.add(package_ahriman, BuildStatusEnum.Unknown) @@ -71,7 +71,7 @@ def test_get_all(web_client: WebClient, package_ahriman: Package, mocker: Mocker response_obj._content = json.dumps(response).encode("utf8") response_obj.status_code = 200 - requests_mock = mocker.patch("requests.get", return_value=response_obj) + requests_mock = mocker.patch("requests.Session.get", return_value=response_obj) result = web_client.get(None) requests_mock.assert_called_once() @@ -83,7 +83,7 @@ def test_get_failed(web_client: WebClient, mocker: MockerFixture) -> None: """ must suppress any exception happened during status getting """ - mocker.patch("requests.get", side_effect=Exception()) + mocker.patch("requests.Session.get", side_effect=Exception()) assert web_client.get(None) == [] @@ -91,7 +91,7 @@ def test_get_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> """ must suppress any exception happened during status getting """ - mocker.patch("requests.get", side_effect=requests.exceptions.HTTPError()) + mocker.patch("requests.Session.get", side_effect=requests.exceptions.HTTPError()) assert web_client.get(None) == [] @@ -104,7 +104,7 @@ def test_get_single(web_client: WebClient, package_ahriman: Package, mocker: Moc response_obj._content = json.dumps(response).encode("utf8") response_obj.status_code = 200 - requests_mock = mocker.patch("requests.get", return_value=response_obj) + requests_mock = mocker.patch("requests.Session.get", return_value=response_obj) result = web_client.get(package_ahriman.base) requests_mock.assert_called_once() @@ -120,7 +120,7 @@ def test_get_internal(web_client: WebClient, mocker: MockerFixture) -> None: response_obj._content = json.dumps(InternalStatus(architecture="x86_64").view()).encode("utf8") response_obj.status_code = 200 - requests_mock = mocker.patch("requests.get", return_value=response_obj) + requests_mock = mocker.patch("requests.Session.get", return_value=response_obj) result = web_client.get_internal() requests_mock.assert_called_once() @@ -131,7 +131,7 @@ def test_get_internal_failed(web_client: WebClient, mocker: MockerFixture) -> No """ must suppress any exception happened during web service status getting """ - mocker.patch("requests.get", side_effect=Exception()) + mocker.patch("requests.Session.get", side_effect=Exception()) assert web_client.get_internal() == InternalStatus() @@ -139,7 +139,7 @@ def test_get_internal_failed_http_error(web_client: WebClient, mocker: MockerFix """ must suppress any exception happened during web service status getting """ - mocker.patch("requests.get", side_effect=requests.exceptions.HTTPError()) + mocker.patch("requests.Session.get", side_effect=requests.exceptions.HTTPError()) assert web_client.get_internal() == InternalStatus() @@ -151,7 +151,7 @@ def test_get_self(web_client: WebClient, mocker: MockerFixture) -> None: response_obj._content = json.dumps(BuildStatus().view()).encode("utf8") response_obj.status_code = 200 - requests_mock = mocker.patch("requests.get", return_value=response_obj) + requests_mock = mocker.patch("requests.Session.get", return_value=response_obj) result = web_client.get_self() requests_mock.assert_called_once() @@ -162,7 +162,7 @@ def test_get_self_failed(web_client: WebClient, mocker: MockerFixture) -> None: """ must suppress any exception happened during service status getting """ - mocker.patch("requests.get", side_effect=Exception()) + mocker.patch("requests.Session.get", side_effect=Exception()) assert web_client.get_self().status == BuildStatusEnum.Unknown @@ -170,7 +170,7 @@ def test_get_self_failed_http_error(web_client: WebClient, mocker: MockerFixture """ must suppress any exception happened during service status getting """ - mocker.patch("requests.get", side_effect=requests.exceptions.HTTPError()) + mocker.patch("requests.Session.get", side_effect=requests.exceptions.HTTPError()) assert web_client.get_self().status == BuildStatusEnum.Unknown @@ -178,7 +178,7 @@ def test_remove(web_client: WebClient, package_ahriman: Package, mocker: MockerF """ must process package removal """ - requests_mock = mocker.patch("requests.delete") + requests_mock = mocker.patch("requests.Session.delete") web_client.remove(package_ahriman.base) requests_mock.assert_called_with(pytest.helpers.anyvar(str, True)) @@ -188,7 +188,7 @@ def test_remove_failed(web_client: WebClient, package_ahriman: Package, mocker: """ must suppress any exception happened during removal """ - mocker.patch("requests.delete", side_effect=Exception()) + mocker.patch("requests.Session.delete", side_effect=Exception()) web_client.remove(package_ahriman.base) @@ -196,7 +196,7 @@ def test_remove_failed_http_error(web_client: WebClient, package_ahriman: Packag """ must suppress any exception happened during removal """ - mocker.patch("requests.delete", side_effect=requests.exceptions.HTTPError()) + mocker.patch("requests.Session.delete", side_effect=requests.exceptions.HTTPError()) web_client.remove(package_ahriman.base) @@ -204,7 +204,7 @@ def test_update(web_client: WebClient, package_ahriman: Package, mocker: MockerF """ must process package update """ - requests_mock = mocker.patch("requests.post") + requests_mock = mocker.patch("requests.Session.post") web_client.update(package_ahriman.base, BuildStatusEnum.Unknown) requests_mock.assert_called_with(pytest.helpers.anyvar(str, True), json={"status": BuildStatusEnum.Unknown.value}) @@ -214,7 +214,7 @@ def test_update_failed(web_client: WebClient, package_ahriman: Package, mocker: """ must suppress any exception happened during update """ - mocker.patch("requests.post", side_effect=Exception()) + mocker.patch("requests.Session.post", side_effect=Exception()) web_client.update(package_ahriman.base, BuildStatusEnum.Unknown) @@ -222,7 +222,7 @@ def test_update_failed_http_error(web_client: WebClient, package_ahriman: Packag """ must suppress any exception happened during update """ - mocker.patch("requests.post", side_effect=requests.exceptions.HTTPError()) + mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError()) web_client.update(package_ahriman.base, BuildStatusEnum.Unknown) @@ -230,7 +230,7 @@ def test_update_self(web_client: WebClient, mocker: MockerFixture) -> None: """ must process service update """ - requests_mock = mocker.patch("requests.post") + requests_mock = mocker.patch("requests.Session.post") web_client.update_self(BuildStatusEnum.Unknown) requests_mock.assert_called_with(pytest.helpers.anyvar(str, True), json={"status": BuildStatusEnum.Unknown.value}) @@ -240,7 +240,7 @@ def test_update_self_failed(web_client: WebClient, mocker: MockerFixture) -> Non """ must suppress any exception happened during service update """ - mocker.patch("requests.post", side_effect=Exception()) + mocker.patch("requests.Session.post", side_effect=Exception()) web_client.update_self(BuildStatusEnum.Unknown) @@ -248,5 +248,5 @@ def test_update_self_failed_http_error(web_client: WebClient, mocker: MockerFixt """ must suppress any exception happened during service update """ - mocker.patch("requests.post", side_effect=requests.exceptions.HTTPError()) + mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError()) web_client.update_self(BuildStatusEnum.Unknown)